@ottocode/server 0.1.224 → 0.1.226

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.224",
3
+ "version": "0.1.226",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ottocode/sdk": "0.1.224",
53
- "@ottocode/database": "0.1.224",
52
+ "@ottocode/sdk": "0.1.226",
53
+ "@ottocode/database": "0.1.226",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.3.6"
@@ -35,6 +35,8 @@ export const authPaths = {
35
35
  },
36
36
  label: { type: 'string' },
37
37
  supportsOAuth: { type: 'boolean' },
38
+ supportsToken: { type: 'boolean' },
39
+ supportsGhImport: { type: 'boolean' },
38
40
  modelCount: { type: 'integer' },
39
41
  costRange: {
40
42
  type: 'object',
@@ -473,6 +475,194 @@ export const authPaths = {
473
475
  },
474
476
  },
475
477
  },
478
+ '/v1/auth/copilot/methods': {
479
+ get: {
480
+ tags: ['auth'],
481
+ operationId: 'getCopilotAuthMethods',
482
+ summary: 'Get available Copilot auth methods',
483
+ responses: {
484
+ 200: {
485
+ description: 'OK',
486
+ content: {
487
+ 'application/json': {
488
+ schema: {
489
+ type: 'object',
490
+ properties: {
491
+ oauth: { type: 'boolean' },
492
+ token: { type: 'boolean' },
493
+ ghImport: {
494
+ type: 'object',
495
+ properties: {
496
+ available: { type: 'boolean' },
497
+ authenticated: { type: 'boolean' },
498
+ reason: { type: 'string' },
499
+ },
500
+ required: ['available', 'authenticated'],
501
+ },
502
+ },
503
+ required: ['oauth', 'token', 'ghImport'],
504
+ },
505
+ },
506
+ },
507
+ },
508
+ },
509
+ },
510
+ },
511
+ '/v1/auth/copilot/token': {
512
+ post: {
513
+ tags: ['auth'],
514
+ operationId: 'saveCopilotToken',
515
+ summary: 'Save Copilot token after validating model access',
516
+ requestBody: {
517
+ required: true,
518
+ content: {
519
+ 'application/json': {
520
+ schema: {
521
+ type: 'object',
522
+ properties: {
523
+ token: { type: 'string' },
524
+ },
525
+ required: ['token'],
526
+ },
527
+ },
528
+ },
529
+ },
530
+ responses: {
531
+ 200: {
532
+ description: 'OK',
533
+ content: {
534
+ 'application/json': {
535
+ schema: {
536
+ type: 'object',
537
+ properties: {
538
+ success: { type: 'boolean' },
539
+ provider: { type: 'string' },
540
+ source: { type: 'string', enum: ['token'] },
541
+ modelCount: { type: 'integer' },
542
+ hasGpt52Codex: { type: 'boolean' },
543
+ sampleModels: {
544
+ type: 'array',
545
+ items: { type: 'string' },
546
+ },
547
+ },
548
+ required: [
549
+ 'success',
550
+ 'provider',
551
+ 'source',
552
+ 'modelCount',
553
+ 'hasGpt52Codex',
554
+ 'sampleModels',
555
+ ],
556
+ },
557
+ },
558
+ },
559
+ },
560
+ 400: errorResponse(),
561
+ },
562
+ },
563
+ },
564
+ '/v1/auth/copilot/gh/import': {
565
+ post: {
566
+ tags: ['auth'],
567
+ operationId: 'importCopilotTokenFromGh',
568
+ summary: 'Import Copilot token from GitHub CLI (gh)',
569
+ responses: {
570
+ 200: {
571
+ description: 'OK',
572
+ content: {
573
+ 'application/json': {
574
+ schema: {
575
+ type: 'object',
576
+ properties: {
577
+ success: { type: 'boolean' },
578
+ provider: { type: 'string' },
579
+ source: { type: 'string', enum: ['gh'] },
580
+ modelCount: { type: 'integer' },
581
+ hasGpt52Codex: { type: 'boolean' },
582
+ sampleModels: {
583
+ type: 'array',
584
+ items: { type: 'string' },
585
+ },
586
+ },
587
+ required: [
588
+ 'success',
589
+ 'provider',
590
+ 'source',
591
+ 'modelCount',
592
+ 'hasGpt52Codex',
593
+ 'sampleModels',
594
+ ],
595
+ },
596
+ },
597
+ },
598
+ },
599
+ 400: errorResponse(),
600
+ },
601
+ },
602
+ },
603
+ '/v1/auth/copilot/diagnostics': {
604
+ get: {
605
+ tags: ['auth'],
606
+ operationId: 'getCopilotDiagnostics',
607
+ summary: 'Get Copilot token diagnostics and model visibility',
608
+ responses: {
609
+ 200: {
610
+ description: 'OK',
611
+ content: {
612
+ 'application/json': {
613
+ schema: {
614
+ type: 'object',
615
+ properties: {
616
+ tokenSources: {
617
+ type: 'array',
618
+ items: {
619
+ type: 'object',
620
+ properties: {
621
+ source: {
622
+ type: 'string',
623
+ enum: ['env', 'stored'],
624
+ },
625
+ configured: { type: 'boolean' },
626
+ modelCount: { type: 'integer' },
627
+ hasGpt52Codex: { type: 'boolean' },
628
+ sampleModels: {
629
+ type: 'array',
630
+ items: { type: 'string' },
631
+ },
632
+ restrictedByOrgPolicy: { type: 'boolean' },
633
+ restrictedOrg: { type: 'string' },
634
+ restrictionMessage: { type: 'string' },
635
+ error: { type: 'string' },
636
+ },
637
+ required: ['source', 'configured'],
638
+ },
639
+ },
640
+ methods: {
641
+ type: 'object',
642
+ properties: {
643
+ oauth: { type: 'boolean' },
644
+ token: { type: 'boolean' },
645
+ ghImport: {
646
+ type: 'object',
647
+ properties: {
648
+ available: { type: 'boolean' },
649
+ authenticated: { type: 'boolean' },
650
+ reason: { type: 'string' },
651
+ },
652
+ required: ['available', 'authenticated'],
653
+ },
654
+ },
655
+ required: ['oauth', 'token', 'ghImport'],
656
+ },
657
+ },
658
+ required: ['tokenSources', 'methods'],
659
+ },
660
+ },
661
+ },
662
+ },
663
+ },
664
+ },
665
+ },
476
666
  '/v1/auth/onboarding/complete': {
477
667
  post: {
478
668
  tags: ['auth'],
@@ -447,4 +447,148 @@ export const setuPaths = {
447
447
  },
448
448
  },
449
449
  },
450
+ '/v1/setu/topup/razorpay/estimate': {
451
+ get: {
452
+ tags: ['setu'],
453
+ operationId: 'getRazorpayTopupEstimate',
454
+ summary: 'Get estimated fees for a Razorpay topup',
455
+ parameters: [
456
+ {
457
+ in: 'query',
458
+ name: 'amount',
459
+ required: true,
460
+ schema: { type: 'number' },
461
+ description: 'Amount in USD',
462
+ },
463
+ ],
464
+ responses: {
465
+ 200: {
466
+ description: 'OK',
467
+ content: {
468
+ 'application/json': {
469
+ schema: {
470
+ type: 'object',
471
+ properties: {
472
+ creditAmountUsd: { type: 'number' },
473
+ chargeAmountInr: { type: 'number' },
474
+ feeAmountInr: { type: 'number' },
475
+ currency: { type: 'string' },
476
+ exchangeRate: { type: 'number' },
477
+ },
478
+ },
479
+ },
480
+ },
481
+ },
482
+ },
483
+ },
484
+ },
485
+ '/v1/setu/topup/razorpay': {
486
+ post: {
487
+ tags: ['setu'],
488
+ operationId: 'createRazorpayOrder',
489
+ summary: 'Create a Razorpay order for topping up',
490
+ requestBody: {
491
+ required: true,
492
+ content: {
493
+ 'application/json': {
494
+ schema: {
495
+ type: 'object',
496
+ properties: {
497
+ amount: { type: 'number' },
498
+ },
499
+ required: ['amount'],
500
+ },
501
+ },
502
+ },
503
+ },
504
+ responses: {
505
+ 200: {
506
+ description: 'OK',
507
+ content: {
508
+ 'application/json': {
509
+ schema: {
510
+ type: 'object',
511
+ properties: {
512
+ success: { type: 'boolean' },
513
+ orderId: { type: 'string' },
514
+ amount: { type: 'number' },
515
+ currency: { type: 'string' },
516
+ creditAmountUsd: { type: 'number' },
517
+ keyId: { type: 'string' },
518
+ },
519
+ },
520
+ },
521
+ },
522
+ },
523
+ 401: {
524
+ description: 'Wallet not configured',
525
+ content: {
526
+ 'application/json': {
527
+ schema: {
528
+ type: 'object',
529
+ properties: { error: { type: 'string' } },
530
+ required: ['error'],
531
+ },
532
+ },
533
+ },
534
+ },
535
+ },
536
+ },
537
+ },
538
+ '/v1/setu/topup/razorpay/verify': {
539
+ post: {
540
+ tags: ['setu'],
541
+ operationId: 'verifyRazorpayPayment',
542
+ summary: 'Verify Razorpay payment and credit balance',
543
+ requestBody: {
544
+ required: true,
545
+ content: {
546
+ 'application/json': {
547
+ schema: {
548
+ type: 'object',
549
+ properties: {
550
+ razorpay_order_id: { type: 'string' },
551
+ razorpay_payment_id: { type: 'string' },
552
+ razorpay_signature: { type: 'string' },
553
+ },
554
+ required: [
555
+ 'razorpay_order_id',
556
+ 'razorpay_payment_id',
557
+ 'razorpay_signature',
558
+ ],
559
+ },
560
+ },
561
+ },
562
+ },
563
+ responses: {
564
+ 200: {
565
+ description: 'OK',
566
+ content: {
567
+ 'application/json': {
568
+ schema: {
569
+ type: 'object',
570
+ properties: {
571
+ success: { type: 'boolean' },
572
+ credited: { type: 'number' },
573
+ newBalance: { type: 'number' },
574
+ },
575
+ },
576
+ },
577
+ },
578
+ },
579
+ 401: {
580
+ description: 'Wallet not configured',
581
+ content: {
582
+ 'application/json': {
583
+ schema: {
584
+ type: 'object',
585
+ properties: { error: { type: 'string' } },
586
+ required: ['error'],
587
+ },
588
+ },
589
+ },
590
+ },
591
+ },
592
+ },
593
+ },
450
594
  } as const;
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import {
3
3
  getAllAuth,
4
+ getAuth,
4
5
  setAuth,
5
6
  removeAuth,
6
7
  ensureSetuWallet,
@@ -8,6 +9,7 @@ import {
8
9
  importWallet,
9
10
  loadConfig,
10
11
  catalog,
12
+ readEnvKey,
11
13
  getOnboardingComplete,
12
14
  setOnboardingComplete,
13
15
  authorize,
@@ -20,6 +22,7 @@ import {
20
22
  pollForCopilotTokenOnce,
21
23
  type ProviderId,
22
24
  } from '@ottocode/sdk';
25
+ import { execFileSync, spawnSync } from 'node:child_process';
23
26
  import { logger } from '@ottocode/sdk';
24
27
  import { serializeError } from '../runtime/errors/api-error.ts';
25
28
 
@@ -33,6 +36,172 @@ const copilotDeviceSessions = new Map<
33
36
  { deviceCode: string; interval: number; provider: string; createdAt: number }
34
37
  >();
35
38
 
39
+ const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models';
40
+ const GH_CAPABILITY_CACHE_TTL_MS = 60 * 1000;
41
+
42
+ let ghCapabilityCache: {
43
+ expiresAt: number;
44
+ value: { available: boolean; authenticated: boolean; reason?: string };
45
+ } = {
46
+ expiresAt: 0,
47
+ value: {
48
+ available: false,
49
+ authenticated: false,
50
+ reason: 'Not checked yet',
51
+ },
52
+ };
53
+
54
+ function getGhImportCapability() {
55
+ if (ghCapabilityCache.expiresAt > Date.now()) return ghCapabilityCache.value;
56
+
57
+ const version = spawnSync('gh', ['--version'], {
58
+ encoding: 'utf8',
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ });
61
+ if (version.status !== 0) {
62
+ ghCapabilityCache = {
63
+ expiresAt: Date.now() + GH_CAPABILITY_CACHE_TTL_MS,
64
+ value: {
65
+ available: false,
66
+ authenticated: false,
67
+ reason: 'GitHub CLI (gh) is not installed',
68
+ },
69
+ };
70
+ return ghCapabilityCache.value;
71
+ }
72
+
73
+ const authStatus = spawnSync('gh', ['auth', 'status', '-h', 'github.com'], {
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ });
77
+ if (authStatus.status !== 0) {
78
+ ghCapabilityCache = {
79
+ expiresAt: Date.now() + GH_CAPABILITY_CACHE_TTL_MS,
80
+ value: {
81
+ available: true,
82
+ authenticated: false,
83
+ reason: 'Run `gh auth login` first',
84
+ },
85
+ };
86
+ return ghCapabilityCache.value;
87
+ }
88
+
89
+ ghCapabilityCache = {
90
+ expiresAt: Date.now() + GH_CAPABILITY_CACHE_TTL_MS,
91
+ value: {
92
+ available: true,
93
+ authenticated: true,
94
+ },
95
+ };
96
+ return ghCapabilityCache.value;
97
+ }
98
+
99
+ function parseErrorMessageFromBody(text: string): string | undefined {
100
+ if (!text) return undefined;
101
+ try {
102
+ const parsed = JSON.parse(text) as {
103
+ message?: string;
104
+ error?: { message?: string };
105
+ };
106
+ return parsed.error?.message ?? parsed.message;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ async function fetchCopilotModels(token: string): Promise<
113
+ | {
114
+ ok: true;
115
+ models: Set<string>;
116
+ }
117
+ | {
118
+ ok: false;
119
+ status: number;
120
+ message: string;
121
+ }
122
+ > {
123
+ try {
124
+ const response = await fetch(COPILOT_MODELS_URL, {
125
+ headers: {
126
+ Authorization: `Bearer ${token}`,
127
+ 'Openai-Intent': 'conversation-edits',
128
+ 'User-Agent': 'ottocode',
129
+ },
130
+ });
131
+ const text = await response.text();
132
+ if (!response.ok) {
133
+ return {
134
+ ok: false,
135
+ status: response.status,
136
+ message:
137
+ parseErrorMessageFromBody(text) ||
138
+ `Copilot models endpoint returned ${response.status}`,
139
+ };
140
+ }
141
+
142
+ const payload = JSON.parse(text) as {
143
+ data?: Array<{ id?: string }>;
144
+ };
145
+ const models = new Set(
146
+ (payload.data ?? [])
147
+ .map((item) => item.id)
148
+ .filter((id): id is string => Boolean(id)),
149
+ );
150
+ return { ok: true, models };
151
+ } catch (error) {
152
+ const message =
153
+ error instanceof Error ? error.message : 'Failed to fetch Copilot models';
154
+ return { ok: false, status: 0, message };
155
+ }
156
+ }
157
+
158
+ async function detectOAuthOrgRestriction(token: string): Promise<{
159
+ restricted: boolean;
160
+ org?: string;
161
+ message?: string;
162
+ }> {
163
+ try {
164
+ const orgsResponse = await fetch('https://api.github.com/user/orgs', {
165
+ headers: {
166
+ Authorization: `Bearer ${token}`,
167
+ 'User-Agent': 'ottocode',
168
+ Accept: 'application/vnd.github+json',
169
+ },
170
+ });
171
+ if (!orgsResponse.ok) {
172
+ return { restricted: false };
173
+ }
174
+
175
+ const orgs = (await orgsResponse.json()) as Array<{ login?: string }>;
176
+ for (const org of orgs) {
177
+ if (!org.login) continue;
178
+ const membershipResponse = await fetch(
179
+ `https://api.github.com/user/memberships/orgs/${org.login}`,
180
+ {
181
+ headers: {
182
+ Authorization: `Bearer ${token}`,
183
+ 'User-Agent': 'ottocode',
184
+ Accept: 'application/vnd.github+json',
185
+ },
186
+ },
187
+ );
188
+ if (membershipResponse.status !== 403) continue;
189
+
190
+ const bodyText = await membershipResponse.text();
191
+ const message = parseErrorMessageFromBody(bodyText) || bodyText;
192
+ if (message.includes('enabled OAuth App access restrictions')) {
193
+ return {
194
+ restricted: true,
195
+ org: org.login,
196
+ message,
197
+ };
198
+ }
199
+ }
200
+ } catch {}
201
+
202
+ return { restricted: false };
203
+ }
204
+
36
205
  setInterval(() => {
37
206
  const now = Date.now();
38
207
  for (const [key, value] of oauthVerifiers.entries()) {
@@ -55,6 +224,7 @@ export function registerAuthRoutes(app: Hono) {
55
224
  const cfg = await loadConfig(projectRoot);
56
225
  const onboardingComplete = await getOnboardingComplete(projectRoot);
57
226
  const setuWallet = await getSetuWallet(projectRoot);
227
+ const ghImportCapability = getGhImportCapability();
58
228
 
59
229
  const providers: Record<
60
230
  string,
@@ -63,6 +233,8 @@ export function registerAuthRoutes(app: Hono) {
63
233
  type?: 'api' | 'oauth' | 'wallet';
64
234
  label: string;
65
235
  supportsOAuth: boolean;
236
+ supportsToken?: boolean;
237
+ supportsGhImport?: boolean;
66
238
  modelCount: number;
67
239
  costRange?: { min: number; max: number };
68
240
  }
@@ -81,6 +253,9 @@ export function registerAuthRoutes(app: Hono) {
81
253
  label: entry.label || id,
82
254
  supportsOAuth:
83
255
  id === 'anthropic' || id === 'openai' || id === 'copilot',
256
+ supportsToken: id === 'copilot',
257
+ supportsGhImport:
258
+ id === 'copilot' ? ghImportCapability.available : false,
84
259
  modelCount: models.length,
85
260
  costRange:
86
261
  costs.length > 0
@@ -213,15 +388,15 @@ export function registerAuthRoutes(app: Hono) {
213
388
  app.post('/v1/auth/:provider/oauth/url', async (c) => {
214
389
  try {
215
390
  const provider = c.req.param('provider');
216
- const { mode = 'max' } = await c.req
217
- .json<{ mode?: string }>()
218
- .catch(() => ({}));
391
+ const body = await c.req.json<{ mode?: string }>().catch(() => undefined);
392
+ const mode: 'max' | 'console' =
393
+ body?.mode === 'console' ? 'console' : 'max';
219
394
 
220
395
  let url: string;
221
396
  let verifier: string;
222
397
 
223
398
  if (provider === 'anthropic') {
224
- const result = await authorize(mode as 'max' | 'console');
399
+ const result = await authorize(mode);
225
400
  url = result.url;
226
401
  verifier = result.verifier;
227
402
  } else if (provider === 'openai') {
@@ -585,6 +760,215 @@ export function registerAuthRoutes(app: Hono) {
585
760
  }
586
761
  });
587
762
 
763
+ app.get('/v1/auth/copilot/methods', async (c) => {
764
+ const ghImport = getGhImportCapability();
765
+ return c.json({
766
+ oauth: true,
767
+ token: true,
768
+ ghImport,
769
+ });
770
+ });
771
+
772
+ app.post('/v1/auth/copilot/token', async (c) => {
773
+ try {
774
+ const { token } = await c.req.json<{ token: string }>();
775
+ const sanitized = token?.trim();
776
+ if (!sanitized) {
777
+ return c.json({ error: 'Copilot token is required' }, 400);
778
+ }
779
+
780
+ const modelsResult = await fetchCopilotModels(sanitized);
781
+ if (!modelsResult.ok) {
782
+ return c.json(
783
+ {
784
+ error: `Invalid Copilot token: ${modelsResult.message}`,
785
+ },
786
+ 400,
787
+ );
788
+ }
789
+
790
+ await setAuth(
791
+ 'copilot',
792
+ {
793
+ type: 'oauth',
794
+ refresh: sanitized,
795
+ access: sanitized,
796
+ expires: 0,
797
+ },
798
+ undefined,
799
+ 'global',
800
+ );
801
+
802
+ const models = Array.from(modelsResult.models).sort();
803
+ return c.json({
804
+ success: true,
805
+ provider: 'copilot',
806
+ source: 'token',
807
+ modelCount: models.length,
808
+ hasGpt52Codex: modelsResult.models.has('gpt-5.2-codex'),
809
+ sampleModels: models.slice(0, 25),
810
+ });
811
+ } catch (error) {
812
+ const message =
813
+ error instanceof Error ? error.message : 'Failed to save Copilot token';
814
+ logger.error('Failed to save Copilot token', error);
815
+ return c.json({ error: message }, 500);
816
+ }
817
+ });
818
+
819
+ app.post('/v1/auth/copilot/gh/import', async (c) => {
820
+ try {
821
+ const ghImport = getGhImportCapability();
822
+ if (!ghImport.available) {
823
+ return c.json(
824
+ {
825
+ error: ghImport.reason || 'GitHub CLI is not available',
826
+ },
827
+ 400,
828
+ );
829
+ }
830
+ if (!ghImport.authenticated) {
831
+ return c.json(
832
+ {
833
+ error: ghImport.reason || 'GitHub CLI is not authenticated',
834
+ },
835
+ 400,
836
+ );
837
+ }
838
+
839
+ const ghToken = execFileSync('gh', ['auth', 'token'], {
840
+ encoding: 'utf8',
841
+ stdio: ['ignore', 'pipe', 'pipe'],
842
+ }).trim();
843
+ if (!ghToken) {
844
+ return c.json({ error: 'GitHub CLI returned an empty token' }, 400);
845
+ }
846
+
847
+ const modelsResult = await fetchCopilotModels(ghToken);
848
+ if (!modelsResult.ok) {
849
+ return c.json(
850
+ {
851
+ error: `Imported gh token is not valid for Copilot: ${modelsResult.message}`,
852
+ },
853
+ 400,
854
+ );
855
+ }
856
+
857
+ await setAuth(
858
+ 'copilot',
859
+ {
860
+ type: 'oauth',
861
+ refresh: ghToken,
862
+ access: ghToken,
863
+ expires: 0,
864
+ },
865
+ undefined,
866
+ 'global',
867
+ );
868
+
869
+ const models = Array.from(modelsResult.models).sort();
870
+ return c.json({
871
+ success: true,
872
+ provider: 'copilot',
873
+ source: 'gh',
874
+ modelCount: models.length,
875
+ hasGpt52Codex: modelsResult.models.has('gpt-5.2-codex'),
876
+ sampleModels: models.slice(0, 25),
877
+ });
878
+ } catch (error) {
879
+ const message =
880
+ error instanceof Error
881
+ ? error.message
882
+ : 'Failed to import GitHub CLI token';
883
+ logger.error('Failed to import Copilot token from GitHub CLI', error);
884
+ return c.json({ error: message }, 500);
885
+ }
886
+ });
887
+
888
+ app.get('/v1/auth/copilot/diagnostics', async (c) => {
889
+ try {
890
+ const projectRoot = process.cwd();
891
+ const entries: Array<{
892
+ source: 'env' | 'stored';
893
+ configured: boolean;
894
+ modelCount?: number;
895
+ hasGpt52Codex?: boolean;
896
+ sampleModels?: string[];
897
+ restrictedByOrgPolicy?: boolean;
898
+ restrictedOrg?: string;
899
+ restrictionMessage?: string;
900
+ error?: string;
901
+ }> = [];
902
+
903
+ const envToken = readEnvKey('copilot');
904
+ if (envToken) {
905
+ const modelsResult = await fetchCopilotModels(envToken);
906
+ if (modelsResult.ok) {
907
+ const models = Array.from(modelsResult.models).sort();
908
+ entries.push({
909
+ source: 'env',
910
+ configured: true,
911
+ modelCount: models.length,
912
+ hasGpt52Codex: modelsResult.models.has('gpt-5.2-codex'),
913
+ sampleModels: models.slice(0, 25),
914
+ });
915
+ } else {
916
+ entries.push({
917
+ source: 'env',
918
+ configured: true,
919
+ error: modelsResult.message,
920
+ });
921
+ }
922
+ } else {
923
+ entries.push({ source: 'env', configured: false });
924
+ }
925
+
926
+ const storedAuth = await getAuth('copilot', projectRoot);
927
+ if (storedAuth?.type === 'oauth') {
928
+ const modelsResult = await fetchCopilotModels(storedAuth.refresh);
929
+ const restriction = await detectOAuthOrgRestriction(storedAuth.refresh);
930
+ if (modelsResult.ok) {
931
+ const models = Array.from(modelsResult.models).sort();
932
+ entries.push({
933
+ source: 'stored',
934
+ configured: true,
935
+ modelCount: models.length,
936
+ hasGpt52Codex: modelsResult.models.has('gpt-5.2-codex'),
937
+ sampleModels: models.slice(0, 25),
938
+ restrictedByOrgPolicy: restriction.restricted,
939
+ restrictedOrg: restriction.org,
940
+ restrictionMessage: restriction.message,
941
+ });
942
+ } else {
943
+ entries.push({
944
+ source: 'stored',
945
+ configured: true,
946
+ error: modelsResult.message,
947
+ restrictedByOrgPolicy: restriction.restricted,
948
+ restrictedOrg: restriction.org,
949
+ restrictionMessage: restriction.message,
950
+ });
951
+ }
952
+ } else {
953
+ entries.push({ source: 'stored', configured: false });
954
+ }
955
+
956
+ return c.json({
957
+ tokenSources: entries,
958
+ methods: {
959
+ oauth: true,
960
+ token: true,
961
+ ghImport: getGhImportCapability(),
962
+ },
963
+ });
964
+ } catch (error) {
965
+ const message =
966
+ error instanceof Error ? error.message : 'Failed to inspect Copilot';
967
+ logger.error('Failed to build Copilot diagnostics', error);
968
+ return c.json({ error: message }, 500);
969
+ }
970
+ });
971
+
588
972
  app.post('/v1/auth/onboarding/complete', async (c) => {
589
973
  try {
590
974
  await setOnboardingComplete();
@@ -2,11 +2,13 @@ import type { Hono } from 'hono';
2
2
  import {
3
3
  loadConfig,
4
4
  catalog,
5
+ getAuth,
6
+ logger,
7
+ readEnvKey,
5
8
  type ProviderId,
6
9
  filterModelsForAuthType,
7
10
  } from '@ottocode/sdk';
8
11
  import type { EmbeddedAppConfig } from '../../index.ts';
9
- import { logger } from '@ottocode/sdk';
10
12
  import { serializeError } from '../../runtime/errors/api-error.ts';
11
13
  import {
12
14
  isProviderAuthorizedHybrid,
@@ -15,6 +17,68 @@ import {
15
17
  getAuthTypeForProvider,
16
18
  } from './utils.ts';
17
19
 
20
+ const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models';
21
+
22
+ function filterCopilotAvailability<T extends { id: string }>(
23
+ provider: ProviderId,
24
+ models: T[],
25
+ copilotAllowedModels: Set<string> | null,
26
+ ): T[] {
27
+ if (provider !== 'copilot') return models;
28
+ if (!copilotAllowedModels || copilotAllowedModels.size === 0) return models;
29
+ return models.filter((m) => copilotAllowedModels.has(m.id));
30
+ }
31
+
32
+ async function getCopilotAuthTokens(projectRoot: string): Promise<string[]> {
33
+ const tokens: string[] = [];
34
+
35
+ const envToken = readEnvKey('copilot');
36
+ if (envToken) tokens.push(envToken);
37
+
38
+ const auth = await getAuth('copilot', projectRoot);
39
+ if (auth?.type === 'oauth' && auth.refresh) {
40
+ if (auth.refresh !== envToken) {
41
+ tokens.push(auth.refresh);
42
+ }
43
+ }
44
+
45
+ return tokens;
46
+ }
47
+
48
+ async function getAuthorizedCopilotModels(
49
+ projectRoot: string,
50
+ ): Promise<Set<string> | null> {
51
+ const tokens = await getCopilotAuthTokens(projectRoot);
52
+ if (!tokens.length) return null;
53
+
54
+ const merged = new Set<string>();
55
+ let successful = false;
56
+
57
+ for (const token of tokens) {
58
+ try {
59
+ const response = await fetch(COPILOT_MODELS_URL, {
60
+ headers: {
61
+ Authorization: `Bearer ${token}`,
62
+ 'Openai-Intent': 'conversation-edits',
63
+ 'User-Agent': 'ottocode',
64
+ },
65
+ });
66
+ if (!response.ok) continue;
67
+
68
+ successful = true;
69
+ const payload = (await response.json()) as {
70
+ data?: Array<{ id?: string }>;
71
+ };
72
+
73
+ for (const id of (payload.data ?? []).map((item) => item.id)) {
74
+ if (id) merged.add(id);
75
+ }
76
+ } catch {}
77
+ }
78
+
79
+ return successful ? merged : null;
80
+ }
81
+
18
82
  export function registerModelsRoutes(app: Hono) {
19
83
  app.get('/v1/config/providers/:provider/models', async (c) => {
20
84
  try {
@@ -53,9 +117,19 @@ export function registerModelsRoutes(app: Hono) {
53
117
  providerCatalog.models,
54
118
  authType,
55
119
  );
120
+ const copilotAllowedModels =
121
+ provider === 'copilot'
122
+ ? await getAuthorizedCopilotModels(projectRoot)
123
+ : null;
124
+
125
+ const availableModels = filterCopilotAvailability(
126
+ provider,
127
+ filteredModels,
128
+ copilotAllowedModels,
129
+ );
56
130
 
57
131
  return c.json({
58
- models: filteredModels.map((m) => ({
132
+ models: availableModels.map((m) => ({
59
133
  id: m.id,
60
134
  label: m.label || m.id,
61
135
  toolCall: m.toolCall,
@@ -94,6 +168,7 @@ export function registerModelsRoutes(app: Hono) {
94
168
  string,
95
169
  {
96
170
  label: string;
171
+ authType?: 'api' | 'oauth' | 'wallet';
97
172
  models: Array<{
98
173
  id: string;
99
174
  label: string;
@@ -369,4 +369,120 @@ export function registerSetuRoutes(app: Hono) {
369
369
  return c.json(errorResponse, errorResponse.error.status || 500);
370
370
  }
371
371
  });
372
+
373
+ app.get('/v1/setu/topup/razorpay/estimate', async (c) => {
374
+ try {
375
+ const amount = c.req.query('amount');
376
+ if (!amount) {
377
+ return c.json({ error: 'Missing amount parameter' }, 400);
378
+ }
379
+
380
+ const baseUrl = getSetuBaseUrl();
381
+ const response = await fetch(
382
+ `${baseUrl}/v1/topup/razorpay/estimate?amount=${amount}`,
383
+ {
384
+ method: 'GET',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ },
387
+ );
388
+
389
+ const data = await response.json();
390
+ if (!response.ok) {
391
+ return c.json(data, response.status as 400 | 500);
392
+ }
393
+
394
+ return c.json(data);
395
+ } catch (error) {
396
+ logger.error('Failed to get Razorpay estimate', error);
397
+ const errorResponse = serializeError(error);
398
+ return c.json(errorResponse, errorResponse.error.status || 500);
399
+ }
400
+ });
401
+
402
+ app.post('/v1/setu/topup/razorpay', async (c) => {
403
+ try {
404
+ const privateKey = await getSetuPrivateKey();
405
+ if (!privateKey) {
406
+ return c.json({ error: 'Setu wallet not configured' }, 401);
407
+ }
408
+
409
+ const body = await c.req.json();
410
+ const { amount } = body as { amount: number };
411
+
412
+ if (!amount || typeof amount !== 'number') {
413
+ return c.json({ error: 'Invalid amount' }, 400);
414
+ }
415
+
416
+ const walletHeaders = buildWalletHeaders(privateKey);
417
+ const baseUrl = getSetuBaseUrl();
418
+
419
+ const response = await fetch(`${baseUrl}/v1/topup/razorpay`, {
420
+ method: 'POST',
421
+ headers: {
422
+ 'Content-Type': 'application/json',
423
+ ...walletHeaders,
424
+ },
425
+ body: JSON.stringify({ amount }),
426
+ });
427
+
428
+ const data = await response.json();
429
+ if (!response.ok) {
430
+ return c.json(data, response.status as 400 | 500);
431
+ }
432
+
433
+ return c.json(data);
434
+ } catch (error) {
435
+ logger.error('Failed to create Razorpay order', error);
436
+ const errorResponse = serializeError(error);
437
+ return c.json(errorResponse, errorResponse.error.status || 500);
438
+ }
439
+ });
440
+
441
+ app.post('/v1/setu/topup/razorpay/verify', async (c) => {
442
+ try {
443
+ const privateKey = await getSetuPrivateKey();
444
+ if (!privateKey) {
445
+ return c.json({ error: 'Setu wallet not configured' }, 401);
446
+ }
447
+
448
+ const body = await c.req.json();
449
+ const { razorpay_order_id, razorpay_payment_id, razorpay_signature } =
450
+ body as {
451
+ razorpay_order_id: string;
452
+ razorpay_payment_id: string;
453
+ razorpay_signature: string;
454
+ };
455
+
456
+ if (!razorpay_order_id || !razorpay_payment_id || !razorpay_signature) {
457
+ return c.json({ error: 'Missing payment details' }, 400);
458
+ }
459
+
460
+ const walletHeaders = buildWalletHeaders(privateKey);
461
+ const baseUrl = getSetuBaseUrl();
462
+
463
+ const response = await fetch(`${baseUrl}/v1/topup/razorpay/verify`, {
464
+ method: 'POST',
465
+ headers: {
466
+ 'Content-Type': 'application/json',
467
+ ...walletHeaders,
468
+ },
469
+ body: JSON.stringify({
470
+ razorpay_order_id,
471
+ razorpay_payment_id,
472
+ razorpay_signature,
473
+ }),
474
+ });
475
+
476
+ const data = await response.json();
477
+ if (!response.ok) {
478
+ return c.json(data, response.status as 400 | 500);
479
+ }
480
+
481
+ return c.json(data);
482
+ } catch (error) {
483
+ logger.error('Failed to verify Razorpay payment', error);
484
+ const errorResponse = serializeError(error);
485
+ return c.json(errorResponse, errorResponse.error.status || 500);
486
+ }
487
+ });
372
488
  }
@@ -2,11 +2,14 @@ export type OauthCodexContinuationInput = {
2
2
  provider: string;
3
3
  isOpenAIOAuth: boolean;
4
4
  finishObserved: boolean;
5
+ abortedByUser?: boolean;
5
6
  continuationCount: number;
6
7
  maxContinuations: number;
7
8
  finishReason?: string;
8
9
  rawFinishReason?: string;
9
10
  firstToolSeen: boolean;
11
+ hasTrailingAssistantText: boolean;
12
+ endedWithToolActivity?: boolean;
10
13
  droppedPseudoToolText: boolean;
11
14
  lastAssistantText: string;
12
15
  };
@@ -40,6 +43,13 @@ function isTruncatedResponse(
40
43
  return rawFinishReason === 'max_output_tokens';
41
44
  }
42
45
 
46
+ function isMissingAssistantSummary(
47
+ input: OauthCodexContinuationInput,
48
+ ): boolean {
49
+ if (!input.firstToolSeen) return false;
50
+ return !input.hasTrailingAssistantText;
51
+ }
52
+
43
53
  const MAX_UNCLEAN_EOF_RETRIES = 1;
44
54
 
45
55
  function isUncleanEof(input: OauthCodexContinuationInput): boolean {
@@ -60,6 +70,10 @@ export function decideOauthCodexContinuation(
60
70
  return { shouldContinue: false };
61
71
  }
62
72
 
73
+ if (input.abortedByUser) {
74
+ return { shouldContinue: false, reason: 'aborted-by-user' };
75
+ }
76
+
63
77
  if (input.continuationCount >= input.maxContinuations) {
64
78
  return { shouldContinue: false, reason: 'max-continuations-reached' };
65
79
  }
@@ -68,6 +82,14 @@ export function decideOauthCodexContinuation(
68
82
  return { shouldContinue: true, reason: 'truncated' };
69
83
  }
70
84
 
85
+ if (input.endedWithToolActivity) {
86
+ return { shouldContinue: true, reason: 'ended-on-tool-activity' };
87
+ }
88
+
89
+ if (isMissingAssistantSummary(input)) {
90
+ return { shouldContinue: true, reason: 'no-trailing-assistant-text' };
91
+ }
92
+
71
93
  if (
72
94
  isUncleanEof(input) &&
73
95
  input.continuationCount < MAX_UNCLEAN_EOF_RETRIES
@@ -211,6 +211,13 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
211
211
  const providerOptions = { ...adapted.providerOptions };
212
212
  let effectiveMaxOutputTokens = maxOutputTokens;
213
213
 
214
+ if (opts.provider === 'copilot') {
215
+ providerOptions.openai = {
216
+ ...((providerOptions.openai as Record<string, unknown>) || {}),
217
+ store: false,
218
+ };
219
+ }
220
+
214
221
  if (opts.reasoningText) {
215
222
  const underlyingProvider = getUnderlyingProviderKey(
216
223
  opts.provider,
@@ -180,7 +180,14 @@ async function runAssistant(opts: RunOpts) {
180
180
  );
181
181
 
182
182
  let _finishObserved = false;
183
+ let _toolActivityObserved = false;
184
+ let _trailingAssistantTextAfterTool = false;
185
+ let _abortedByUser = false;
183
186
  const unsubscribeFinish = subscribe(opts.sessionId, (evt) => {
187
+ if (evt.type === 'tool.call' || evt.type === 'tool.result') {
188
+ _toolActivityObserved = true;
189
+ _trailingAssistantTextAfterTool = false;
190
+ }
184
191
  if (evt.type !== 'tool.result') return;
185
192
  try {
186
193
  const name = (evt.payload as { name?: string } | undefined)?.name;
@@ -239,12 +246,19 @@ async function runAssistant(opts: RunOpts) {
239
246
  runSessionLoop,
240
247
  );
241
248
 
242
- const onAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
249
+ const baseOnAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
250
+ const onAbort = async (event: Parameters<typeof baseOnAbort>[0]) => {
251
+ _abortedByUser = true;
252
+ await baseOnAbort(event);
253
+ };
243
254
 
244
255
  const onFinish = createFinishHandler(opts, db, completeAssistantMessage);
245
- const stopWhenCondition = isOpenAIOAuth
246
- ? stepCountIs(20)
247
- : hasToolCall('finish');
256
+ const isCopilotResponsesApi =
257
+ opts.provider === 'copilot' && !opts.model.startsWith('gpt-5-mini');
258
+ const stopWhenCondition =
259
+ isOpenAIOAuth || isCopilotResponsesApi
260
+ ? stepCountIs(20)
261
+ : hasToolCall('finish');
248
262
 
249
263
  try {
250
264
  const result = streamText({
@@ -287,6 +301,12 @@ async function runAssistant(opts: RunOpts) {
287
301
  if (accumulated.trim()) {
288
302
  latestAssistantText = accumulated;
289
303
  }
304
+ if (
305
+ (delta.trim().length > 0 && _toolActivityObserved) ||
306
+ (delta.trim().length > 0 && firstToolSeen())
307
+ ) {
308
+ _trailingAssistantTextAfterTool = true;
309
+ }
290
310
 
291
311
  if (!currentPartId && !accumulated.trim()) {
292
312
  continue;
@@ -404,20 +424,25 @@ async function runAssistant(opts: RunOpts) {
404
424
  }
405
425
 
406
426
  debugLog(
407
- `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}, finishReason=${streamFinishReason}, rawFinishReason=${streamRawFinishReason}`,
427
+ `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}, trailingAssistantTextAfterTool=${_trailingAssistantTextAfterTool}, finishReason=${streamFinishReason}, rawFinishReason=${streamRawFinishReason}`,
408
428
  );
409
429
 
410
430
  const MAX_CONTINUATIONS = 6;
411
431
  const continuationCount = opts.continuationCount ?? 0;
432
+ const endedWithToolActivity =
433
+ _toolActivityObserved && !_trailingAssistantTextAfterTool;
412
434
  const continuationDecision = decideOauthCodexContinuation({
413
435
  provider: opts.provider,
414
436
  isOpenAIOAuth,
415
437
  finishObserved: _finishObserved,
438
+ abortedByUser: _abortedByUser,
416
439
  continuationCount,
417
440
  maxContinuations: MAX_CONTINUATIONS,
418
441
  finishReason: streamFinishReason,
419
442
  rawFinishReason: streamRawFinishReason,
420
443
  firstToolSeen: fs,
444
+ hasTrailingAssistantText: _trailingAssistantTextAfterTool,
445
+ endedWithToolActivity,
421
446
  droppedPseudoToolText: oauthTextGuard?.dropped ?? false,
422
447
  lastAssistantText: latestAssistantText,
423
448
  });
@@ -1,12 +1,123 @@
1
- import { getAuth, createCopilotModel } from '@ottocode/sdk';
2
- import type { OttoConfig } from '@ottocode/sdk';
1
+ import { getAuth, createCopilotModel, readEnvKey } from '@ottocode/sdk';
2
+ import type { OttoConfig, OAuth } from '@ottocode/sdk';
3
3
 
4
- export async function resolveCopilotModel(model: string, cfg: OttoConfig) {
5
- const auth = await getAuth('copilot', cfg.projectRoot);
4
+ const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models';
5
+ const COPILOT_MODELS_CACHE_TTL_MS = 5 * 60 * 1000;
6
+
7
+ type CachedCopilotModels = {
8
+ expiresAt: number;
9
+ models: Set<string>;
10
+ };
11
+
12
+ const copilotModelsCache = new Map<string, CachedCopilotModels>();
13
+
14
+ type CopilotTokenCandidate = {
15
+ source: 'env' | 'oauth';
16
+ token: string;
17
+ oauth: OAuth;
18
+ };
19
+
20
+ async function getCopilotTokenCandidates(
21
+ projectRoot: string,
22
+ ): Promise<CopilotTokenCandidate[]> {
23
+ const candidates: CopilotTokenCandidate[] = [];
24
+
25
+ const envToken = readEnvKey('copilot');
26
+ if (envToken) {
27
+ candidates.push({
28
+ source: 'env',
29
+ token: envToken,
30
+ oauth: {
31
+ type: 'oauth',
32
+ access: envToken,
33
+ refresh: envToken,
34
+ expires: 0,
35
+ },
36
+ });
37
+ }
38
+
39
+ const auth = await getAuth('copilot', projectRoot);
6
40
  if (auth?.type === 'oauth') {
7
- return createCopilotModel(model, { oauth: auth });
41
+ if (auth.refresh !== envToken) {
42
+ candidates.push({ source: 'oauth', token: auth.refresh, oauth: auth });
43
+ }
44
+ }
45
+
46
+ return candidates;
47
+ }
48
+
49
+ async function getCopilotAvailableModels(
50
+ token: string,
51
+ ): Promise<Set<string> | null> {
52
+ const cached = copilotModelsCache.get(token);
53
+ if (cached && cached.expiresAt > Date.now()) {
54
+ return cached.models;
55
+ }
56
+
57
+ try {
58
+ const response = await fetch(COPILOT_MODELS_URL, {
59
+ headers: {
60
+ Authorization: `Bearer ${token}`,
61
+ 'Openai-Intent': 'conversation-edits',
62
+ 'User-Agent': 'ottocode',
63
+ },
64
+ });
65
+
66
+ if (!response.ok) return null;
67
+
68
+ const payload = (await response.json()) as {
69
+ data?: Array<{ id?: string }>;
70
+ };
71
+ const models = new Set(
72
+ (payload.data ?? [])
73
+ .map((item) => item.id)
74
+ .filter((id): id is string => Boolean(id)),
75
+ );
76
+
77
+ copilotModelsCache.set(token, {
78
+ expiresAt: Date.now() + COPILOT_MODELS_CACHE_TTL_MS,
79
+ models,
80
+ });
81
+
82
+ return models;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export async function resolveCopilotModel(model: string, cfg: OttoConfig) {
89
+ const candidates = await getCopilotTokenCandidates(cfg.projectRoot);
90
+ if (!candidates.length) {
91
+ throw new Error(
92
+ 'Copilot provider requires OAuth or GITHUB_TOKEN. Run `otto auth login copilot` or set GITHUB_TOKEN.',
93
+ );
94
+ }
95
+
96
+ let selected: CopilotTokenCandidate | null = null;
97
+ const unionAvailableModels = new Set<string>();
98
+
99
+ for (const candidate of candidates) {
100
+ const availableModels = await getCopilotAvailableModels(candidate.token);
101
+ if (!availableModels || availableModels.size === 0) continue;
102
+
103
+ for (const availableModel of availableModels) {
104
+ unionAvailableModels.add(availableModel);
105
+ }
106
+
107
+ if (!selected && availableModels.has(model)) {
108
+ selected = candidate;
109
+ }
110
+ }
111
+
112
+ if (selected) {
113
+ return createCopilotModel(model, { oauth: selected.oauth });
114
+ }
115
+
116
+ if (unionAvailableModels.size > 0) {
117
+ throw new Error(
118
+ `Copilot model '${model}' is not available for this account/organization token. Available models: ${Array.from(unionAvailableModels).join(', ')}`,
119
+ );
8
120
  }
9
- throw new Error(
10
- 'Copilot provider requires OAuth. Run `otto auth login copilot`.',
11
- );
121
+
122
+ return createCopilotModel(model, { oauth: candidates[0].oauth });
12
123
  }
@@ -69,11 +69,10 @@ export function detectOAuth(
69
69
  ): OAuthContext {
70
70
  const isOAuth = auth?.type === 'oauth';
71
71
  const needsSpoof = !!isOAuth && provider === 'anthropic';
72
- const isCopilot = provider === 'copilot';
73
72
  return {
74
- isOAuth: !!isOAuth || isCopilot,
73
+ isOAuth: !!isOAuth,
75
74
  needsSpoof,
76
- isOpenAIOAuth: (!!isOAuth && provider === 'openai') || isCopilot,
75
+ isOpenAIOAuth: !!isOAuth && provider === 'openai',
77
76
  spoofPrompt: needsSpoof ? getProviderSpoofPrompt(provider) : undefined,
78
77
  };
79
78
  }
@@ -10,6 +10,9 @@ export function getMaxOutputTokens(
10
10
  provider: ProviderName,
11
11
  modelId: string,
12
12
  ): number | undefined {
13
+ if (provider === 'copilot') {
14
+ return undefined;
15
+ }
13
16
  try {
14
17
  const providerCatalog = catalog[provider];
15
18
  if (!providerCatalog) {