@ottocode/server 0.1.192 → 0.1.194

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": "@ottocode/server",
3
- "version": "0.1.192",
3
+ "version": "0.1.194",
4
4
  "description": "HTTP API server for ottocode",
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
- "@ottocode/sdk": "0.1.192",
33
- "@ottocode/database": "0.1.192",
32
+ "@ottocode/sdk": "0.1.194",
33
+ "@ottocode/database": "0.1.194",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { registerSessionApprovalRoute } from './routes/session-approval.ts';
20
20
  import { registerSetuRoutes } from './routes/setu.ts';
21
21
  import { registerAuthRoutes } from './routes/auth.ts';
22
22
  import { registerTunnelRoutes } from './routes/tunnel.ts';
23
+ import { registerProviderUsageRoutes } from './routes/provider-usage.ts';
23
24
  import type { AgentConfigEntry } from './runtime/agent/registry.ts';
24
25
 
25
26
  const globalTerminalManager = new TerminalManager();
@@ -76,6 +77,7 @@ function initApp() {
76
77
  registerSetuRoutes(app);
77
78
  registerAuthRoutes(app);
78
79
  registerTunnelRoutes(app);
80
+ registerProviderUsageRoutes(app);
79
81
 
80
82
  return app;
81
83
  }
@@ -148,6 +150,7 @@ export function createStandaloneApp(_config?: StandaloneAppConfig) {
148
150
  registerSetuRoutes(honoApp);
149
151
  registerAuthRoutes(honoApp);
150
152
  registerTunnelRoutes(honoApp);
153
+ registerProviderUsageRoutes(honoApp);
151
154
 
152
155
  return honoApp;
153
156
  }
@@ -248,6 +251,7 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
248
251
  registerSetuRoutes(honoApp);
249
252
  registerAuthRoutes(honoApp);
250
253
  registerTunnelRoutes(honoApp);
254
+ registerProviderUsageRoutes(honoApp);
251
255
 
252
256
  return honoApp;
253
257
  }
@@ -350,6 +350,52 @@ export const gitPaths = {
350
350
  },
351
351
  },
352
352
  },
353
+ '/v1/git/pull': {
354
+ post: {
355
+ tags: ['git'],
356
+ operationId: 'pullChanges',
357
+ summary: 'Pull changes from remote',
358
+ description: 'Pulls changes from the configured remote repository',
359
+ requestBody: {
360
+ required: false,
361
+ content: {
362
+ 'application/json': {
363
+ schema: {
364
+ type: 'object',
365
+ properties: {
366
+ project: { type: 'string' },
367
+ },
368
+ },
369
+ },
370
+ },
371
+ },
372
+ responses: {
373
+ 200: {
374
+ description: 'OK',
375
+ content: {
376
+ 'application/json': {
377
+ schema: {
378
+ type: 'object',
379
+ properties: {
380
+ status: { type: 'string', enum: ['ok'] },
381
+ data: {
382
+ type: 'object',
383
+ properties: {
384
+ output: { type: 'string' },
385
+ },
386
+ required: ['output'],
387
+ },
388
+ },
389
+ required: ['status', 'data'],
390
+ },
391
+ },
392
+ },
393
+ },
394
+ 400: gitErrorResponse(),
395
+ 500: gitErrorResponse(),
396
+ },
397
+ },
398
+ },
353
399
  '/v1/git/restore': {
354
400
  post: {
355
401
  tags: ['git'],
@@ -161,6 +161,27 @@ export function registerAuthRoutes(app: Hono) {
161
161
  }
162
162
  });
163
163
 
164
+ app.get('/v1/auth/setu/export', async (c) => {
165
+ try {
166
+ const projectRoot = process.cwd();
167
+ const wallet = await getSetuWallet(projectRoot);
168
+
169
+ if (!wallet) {
170
+ return c.json({ error: 'Setu wallet not configured' }, 404);
171
+ }
172
+
173
+ return c.json({
174
+ success: true,
175
+ publicKey: wallet.publicKey,
176
+ privateKey: wallet.privateKey,
177
+ });
178
+ } catch (error) {
179
+ logger.error('Failed to export Setu wallet', error);
180
+ const errorResponse = serializeError(error);
181
+ return c.json(errorResponse, errorResponse.error.status || 500);
182
+ }
183
+ });
184
+
164
185
  app.post('/v1/auth/:provider', async (c) => {
165
186
  try {
166
187
  const provider = c.req.param('provider') as ProviderId;
@@ -5,6 +5,7 @@ import { registerDiffRoute } from './diff.ts';
5
5
  import { registerStagingRoutes } from './staging.ts';
6
6
  import { registerCommitRoutes } from './commit.ts';
7
7
  import { registerPushRoute } from './push.ts';
8
+ import { registerPullRoute } from './pull.ts';
8
9
 
9
10
  export type { GitFile } from './types.ts';
10
11
 
@@ -15,4 +16,5 @@ export function registerGitRoutes(app: Hono) {
15
16
  registerStagingRoutes(app);
16
17
  registerCommitRoutes(app);
17
18
  registerPushRoute(app);
19
+ registerPullRoute(app);
18
20
  }
@@ -0,0 +1,98 @@
1
+ import type { Hono } from 'hono';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { gitPullSchema } from './schemas.ts';
5
+ import { validateAndGetGitRoot } from './utils.ts';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export function registerPullRoute(app: Hono) {
10
+ app.post('/v1/git/pull', async (c) => {
11
+ try {
12
+ let body = {};
13
+ try {
14
+ body = await c.req.json();
15
+ } catch {
16
+ body = {};
17
+ }
18
+
19
+ const { project } = gitPullSchema.parse(body);
20
+
21
+ const requestedPath = project || process.cwd();
22
+
23
+ const validation = await validateAndGetGitRoot(requestedPath);
24
+ if ('error' in validation) {
25
+ return c.json(
26
+ { status: 'error', error: validation.error, code: validation.code },
27
+ 400,
28
+ );
29
+ }
30
+
31
+ const { gitRoot } = validation;
32
+
33
+ try {
34
+ const result = await execFileAsync('git', ['pull'], {
35
+ cwd: gitRoot,
36
+ });
37
+
38
+ return c.json({
39
+ status: 'ok',
40
+ data: {
41
+ output: result.stdout.trim() || result.stderr.trim(),
42
+ },
43
+ });
44
+ } catch (pullErr: unknown) {
45
+ const error = pullErr as {
46
+ message?: string;
47
+ stderr?: string;
48
+ };
49
+ const errorMessage = error.stderr || error.message || 'Failed to pull';
50
+
51
+ if (
52
+ errorMessage.includes('CONFLICT') ||
53
+ errorMessage.includes('merge conflict')
54
+ ) {
55
+ return c.json(
56
+ {
57
+ status: 'error',
58
+ error: 'Merge conflicts detected. Resolve conflicts manually',
59
+ details: errorMessage,
60
+ },
61
+ 400,
62
+ );
63
+ }
64
+
65
+ if (
66
+ errorMessage.includes('Permission denied') ||
67
+ errorMessage.includes('authentication')
68
+ ) {
69
+ return c.json(
70
+ {
71
+ status: 'error',
72
+ error: 'Authentication failed. Check your git credentials',
73
+ details: errorMessage,
74
+ },
75
+ 401,
76
+ );
77
+ }
78
+
79
+ return c.json(
80
+ {
81
+ status: 'error',
82
+ error: 'Failed to pull changes',
83
+ details: errorMessage,
84
+ },
85
+ 500,
86
+ );
87
+ }
88
+ } catch (error) {
89
+ return c.json(
90
+ {
91
+ status: 'error',
92
+ error: error instanceof Error ? error.message : 'Failed to pull',
93
+ },
94
+ 500,
95
+ );
96
+ }
97
+ });
98
+ }
@@ -46,3 +46,7 @@ export const gitGenerateCommitMessageSchema = z.object({
46
46
  export const gitPushSchema = z.object({
47
47
  project: z.string().optional(),
48
48
  });
49
+
50
+ export const gitPullSchema = z.object({
51
+ project: z.string().optional(),
52
+ });
@@ -0,0 +1,199 @@
1
+ import type { Hono } from 'hono';
2
+ import {
3
+ getAuth,
4
+ refreshToken,
5
+ refreshOpenAIToken,
6
+ type ProviderId,
7
+ } from '@ottocode/sdk';
8
+ import { logger } from '@ottocode/sdk';
9
+ import { setAuth } from '@ottocode/sdk';
10
+ import { serializeError } from '../runtime/errors/api-error.ts';
11
+ import type { OAuth } from '@ottocode/sdk';
12
+
13
+ async function ensureValidOAuth(
14
+ provider: ProviderId,
15
+ ): Promise<{ access: string; oauth: OAuth } | null> {
16
+ const projectRoot = process.cwd();
17
+ const auth = await getAuth(provider, projectRoot);
18
+ if (!auth || auth.type !== 'oauth') return null;
19
+
20
+ if (auth.access && auth.expires > Date.now()) {
21
+ return { access: auth.access, oauth: auth };
22
+ }
23
+
24
+ try {
25
+ const refreshFn = provider === 'openai' ? refreshOpenAIToken : refreshToken;
26
+ const newTokens = await refreshFn(auth.refresh);
27
+ const updated: OAuth = {
28
+ ...auth,
29
+ access: newTokens.access,
30
+ refresh: newTokens.refresh,
31
+ expires: newTokens.expires,
32
+ };
33
+ await setAuth(provider, updated, projectRoot, 'global');
34
+ return { access: updated.access, oauth: updated };
35
+ } catch {
36
+ return { access: auth.access, oauth: auth };
37
+ }
38
+ }
39
+
40
+ async function fetchAnthropicUsage(access: string) {
41
+ const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
42
+ headers: {
43
+ Authorization: `Bearer ${access}`,
44
+ 'anthropic-beta': 'oauth-2025-04-20',
45
+ Accept: 'application/json',
46
+ },
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new Error(`Anthropic usage API returned ${response.status}`);
51
+ }
52
+
53
+ const data = (await response.json()) as {
54
+ five_hour?: { utilization: number; resets_at: string | null };
55
+ seven_day?: { utilization: number; resets_at: string | null };
56
+ seven_day_sonnet?: { utilization: number; resets_at: string | null };
57
+ extra_usage?: {
58
+ is_enabled: boolean;
59
+ monthly_limit: number;
60
+ used_credits: number;
61
+ utilization: number | null;
62
+ };
63
+ };
64
+
65
+ return {
66
+ provider: 'anthropic' as const,
67
+ primaryWindow: data.five_hour
68
+ ? {
69
+ usedPercent: data.five_hour.utilization,
70
+ windowSeconds: 18000,
71
+ resetsAt: data.five_hour.resets_at,
72
+ }
73
+ : null,
74
+ secondaryWindow: data.seven_day
75
+ ? {
76
+ usedPercent: data.seven_day.utilization,
77
+ windowSeconds: 604800,
78
+ resetsAt: data.seven_day.resets_at,
79
+ }
80
+ : null,
81
+ sonnetWindow: data.seven_day_sonnet
82
+ ? {
83
+ usedPercent: data.seven_day_sonnet.utilization,
84
+ resetsAt: data.seven_day_sonnet.resets_at,
85
+ }
86
+ : null,
87
+ extraUsage: data.extra_usage ?? null,
88
+ limitReached: (data.five_hour?.utilization ?? 0) >= 100,
89
+ raw: data,
90
+ };
91
+ }
92
+
93
+ async function fetchOpenAIUsage(access: string, accountId?: string) {
94
+ const headers: Record<string, string> = {
95
+ Authorization: `Bearer ${access}`,
96
+ Accept: '*/*',
97
+ };
98
+ if (accountId) {
99
+ headers['ChatGPT-Account-Id'] = accountId;
100
+ }
101
+ const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
102
+ headers,
103
+ });
104
+
105
+ if (!response.ok) {
106
+ throw new Error(`OpenAI usage API returned ${response.status}`);
107
+ }
108
+
109
+ const data = (await response.json()) as {
110
+ plan_type?: string;
111
+ rate_limit?: {
112
+ allowed: boolean;
113
+ limit_reached: boolean;
114
+ primary_window?: {
115
+ used_percent: number;
116
+ limit_window_seconds: number;
117
+ reset_after_seconds: number;
118
+ reset_at: number;
119
+ };
120
+ secondary_window?: {
121
+ used_percent: number;
122
+ limit_window_seconds: number;
123
+ reset_after_seconds: number;
124
+ reset_at: number;
125
+ } | null;
126
+ };
127
+ credits?: {
128
+ has_credits: boolean;
129
+ balance: number | null;
130
+ };
131
+ };
132
+
133
+ const rl = data.rate_limit;
134
+ return {
135
+ provider: 'openai' as const,
136
+ planType: data.plan_type ?? null,
137
+ primaryWindow: rl?.primary_window
138
+ ? {
139
+ usedPercent: rl.primary_window.used_percent,
140
+ windowSeconds: rl.primary_window.limit_window_seconds,
141
+ resetsAt: new Date(rl.primary_window.reset_at * 1000).toISOString(),
142
+ resetAfterSeconds: rl.primary_window.reset_after_seconds,
143
+ }
144
+ : null,
145
+ secondaryWindow: rl?.secondary_window
146
+ ? {
147
+ usedPercent: rl.secondary_window.used_percent,
148
+ windowSeconds: rl.secondary_window.limit_window_seconds,
149
+ resetsAt: new Date(rl.secondary_window.reset_at * 1000).toISOString(),
150
+ resetAfterSeconds: rl.secondary_window.reset_after_seconds,
151
+ }
152
+ : null,
153
+ credits: data.credits ?? null,
154
+ limitReached: rl?.limit_reached ?? false,
155
+ raw: data,
156
+ };
157
+ }
158
+
159
+ export function registerProviderUsageRoutes(app: Hono) {
160
+ app.get('/v1/provider-usage/:provider', async (c) => {
161
+ try {
162
+ const provider = c.req.param('provider') as ProviderId;
163
+
164
+ if (provider !== 'anthropic' && provider !== 'openai') {
165
+ return c.json(
166
+ { error: { message: 'Usage not supported for this provider' } },
167
+ 400,
168
+ );
169
+ }
170
+
171
+ const tokenResult = await ensureValidOAuth(provider);
172
+ if (!tokenResult) {
173
+ return c.json(
174
+ {
175
+ error: {
176
+ message: `No OAuth credentials for ${provider}. Usage is only available for OAuth-authenticated providers.`,
177
+ },
178
+ },
179
+ 404,
180
+ );
181
+ }
182
+
183
+ const usage =
184
+ provider === 'anthropic'
185
+ ? await fetchAnthropicUsage(tokenResult.access)
186
+ : await fetchOpenAIUsage(
187
+ tokenResult.access,
188
+ tokenResult.oauth.accountId,
189
+ );
190
+
191
+ return c.json(usage);
192
+ } catch (error) {
193
+ logger.error('Failed to fetch provider usage', error);
194
+ const errorResponse = serializeError(error);
195
+ const status = (errorResponse.error.status || 500) as 500;
196
+ return c.json(errorResponse, status);
197
+ }
198
+ });
199
+ }
@@ -1,11 +1,12 @@
1
1
  import { hasToolCall, streamText } from 'ai';
2
- import { messageParts } from '@ottocode/database/schema';
2
+ import { messages, messageParts } from '@ottocode/database/schema';
3
3
  import { eq } from 'drizzle-orm';
4
4
  import { publish, subscribe } from '../../events/bus.ts';
5
5
  import { debugLog, time } from '../debug/index.ts';
6
6
  import { toErrorPayload } from '../errors/handling.ts';
7
7
  import {
8
8
  type RunOpts,
9
+ enqueueAssistantRun,
9
10
  setRunning,
10
11
  dequeueJob,
11
12
  cleanupSession,
@@ -293,13 +294,75 @@ async function runAssistant(opts: RunOpts) {
293
294
  await cleanupEmptyTextParts(opts, db);
294
295
  firstToolTimer.end({ seen: firstToolSeen() });
295
296
 
297
+ let streamFinishReason: string | undefined;
298
+ try {
299
+ streamFinishReason = await result.finishReason;
300
+ } catch {
301
+ streamFinishReason = undefined;
302
+ }
303
+
296
304
  debugLog(
297
- `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}`,
305
+ `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}, finishReason=${streamFinishReason}`,
298
306
  );
299
307
 
300
- if (!_finishObserved && fs) {
308
+ const wasTruncated = streamFinishReason === 'length';
309
+
310
+ const shouldContinue =
311
+ opts.provider === 'openai' &&
312
+ isOpenAIOAuth &&
313
+ !_finishObserved &&
314
+ (wasTruncated || fs);
315
+
316
+ if (shouldContinue) {
317
+ debugLog(
318
+ `[RUNNER] WARNING: Stream ended without finish. finishReason=${streamFinishReason}, firstToolSeen=${fs}. Auto-continuing.`,
319
+ );
320
+
321
+ const MAX_CONTINUATIONS = 10;
322
+ const count = opts.continuationCount ?? 0;
323
+ if (count < MAX_CONTINUATIONS) {
324
+ debugLog(
325
+ `[RUNNER] Auto-continuing (${count + 1}/${MAX_CONTINUATIONS})...`,
326
+ );
327
+
328
+ try {
329
+ await completeAssistantMessage({}, opts, db);
330
+ } catch (err) {
331
+ debugLog(
332
+ `[RUNNER] completeAssistantMessage failed before continuation: ${err instanceof Error ? err.message : String(err)}`,
333
+ );
334
+ }
335
+
336
+ const continuationMessageId = crypto.randomUUID();
337
+ await db.insert(messages).values({
338
+ id: continuationMessageId,
339
+ sessionId: opts.sessionId,
340
+ role: 'assistant',
341
+ status: 'pending',
342
+ agent: opts.agent,
343
+ provider: opts.provider,
344
+ model: opts.model,
345
+ createdAt: Date.now(),
346
+ });
347
+
348
+ publish({
349
+ type: 'message.created',
350
+ sessionId: opts.sessionId,
351
+ payload: { id: continuationMessageId, role: 'assistant' },
352
+ });
353
+
354
+ enqueueAssistantRun(
355
+ {
356
+ ...opts,
357
+ assistantMessageId: continuationMessageId,
358
+ continuationCount: count + 1,
359
+ },
360
+ runSessionLoop,
361
+ );
362
+ return;
363
+ }
301
364
  debugLog(
302
- `[RUNNER] WARNING: Stream ended without finish tool being called. Model was mid-execution (tools were used). This is likely an unclean stream termination from the provider.`,
365
+ `[RUNNER] Max continuations (${MAX_CONTINUATIONS}) reached, stopping.`,
303
366
  );
304
367
  }
305
368
  } catch (err) {
@@ -17,6 +17,7 @@ export type RunOpts = {
17
17
  compactionContext?: string;
18
18
  toolApprovalMode?: ToolApprovalMode;
19
19
  compactionRetries?: number;
20
+ continuationCount?: number;
20
21
  };
21
22
 
22
23
  export type QueuedMessage = {