@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 +3 -3
- package/src/openapi/paths/auth.ts +190 -0
- package/src/openapi/paths/setu.ts +144 -0
- package/src/routes/auth.ts +388 -4
- package/src/routes/config/models.ts +77 -2
- package/src/routes/setu.ts +116 -0
- package/src/runtime/agent/oauth-codex-continuation.ts +22 -0
- package/src/runtime/agent/runner-setup.ts +7 -0
- package/src/runtime/agent/runner.ts +30 -5
- package/src/runtime/provider/copilot.ts +119 -8
- package/src/runtime/provider/oauth-adapter.ts +2 -3
- package/src/runtime/utils/token.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/server",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
53
|
-
"@ottocode/database": "0.1.
|
|
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;
|
package/src/routes/auth.ts
CHANGED
|
@@ -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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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:
|
|
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;
|
package/src/routes/setu.ts
CHANGED
|
@@ -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
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
73
|
+
isOAuth: !!isOAuth,
|
|
75
74
|
needsSpoof,
|
|
76
|
-
isOpenAIOAuth:
|
|
75
|
+
isOpenAIOAuth: !!isOAuth && provider === 'openai',
|
|
77
76
|
spoofPrompt: needsSpoof ? getProviderSpoofPrompt(provider) : undefined,
|
|
78
77
|
};
|
|
79
78
|
}
|