@ottocode/server 0.1.193 → 0.1.195

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.193",
3
+ "version": "0.1.195",
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.193",
33
- "@ottocode/database": "0.1.193",
32
+ "@ottocode/sdk": "0.1.195",
33
+ "@ottocode/database": "0.1.195",
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'],
@@ -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
+ }
@@ -307,7 +307,11 @@ async function runAssistant(opts: RunOpts) {
307
307
 
308
308
  const wasTruncated = streamFinishReason === 'length';
309
309
 
310
- const shouldContinue = !_finishObserved && (wasTruncated || fs);
310
+ const shouldContinue =
311
+ opts.provider === 'openai' &&
312
+ isOpenAIOAuth &&
313
+ !_finishObserved &&
314
+ (wasTruncated || fs);
311
315
 
312
316
  if (shouldContinue) {
313
317
  debugLog(