@ottocode/server 0.1.225 → 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.225",
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.225",
53
- "@ottocode/database": "0.1.225",
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'],
@@ -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;
@@ -2,12 +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;
10
11
  hasTrailingAssistantText: boolean;
12
+ endedWithToolActivity?: boolean;
11
13
  droppedPseudoToolText: boolean;
12
14
  lastAssistantText: string;
13
15
  };
@@ -68,6 +70,10 @@ export function decideOauthCodexContinuation(
68
70
  return { shouldContinue: false };
69
71
  }
70
72
 
73
+ if (input.abortedByUser) {
74
+ return { shouldContinue: false, reason: 'aborted-by-user' };
75
+ }
76
+
71
77
  if (input.continuationCount >= input.maxContinuations) {
72
78
  return { shouldContinue: false, reason: 'max-continuations-reached' };
73
79
  }
@@ -76,6 +82,10 @@ export function decideOauthCodexContinuation(
76
82
  return { shouldContinue: true, reason: 'truncated' };
77
83
  }
78
84
 
85
+ if (input.endedWithToolActivity) {
86
+ return { shouldContinue: true, reason: 'ended-on-tool-activity' };
87
+ }
88
+
79
89
  if (isMissingAssistantSummary(input)) {
80
90
  return { shouldContinue: true, reason: 'no-trailing-assistant-text' };
81
91
  }
@@ -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,
@@ -182,6 +182,7 @@ async function runAssistant(opts: RunOpts) {
182
182
  let _finishObserved = false;
183
183
  let _toolActivityObserved = false;
184
184
  let _trailingAssistantTextAfterTool = false;
185
+ let _abortedByUser = false;
185
186
  const unsubscribeFinish = subscribe(opts.sessionId, (evt) => {
186
187
  if (evt.type === 'tool.call' || evt.type === 'tool.result') {
187
188
  _toolActivityObserved = true;
@@ -245,12 +246,19 @@ async function runAssistant(opts: RunOpts) {
245
246
  runSessionLoop,
246
247
  );
247
248
 
248
- 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
+ };
249
254
 
250
255
  const onFinish = createFinishHandler(opts, db, completeAssistantMessage);
251
- const stopWhenCondition = isOpenAIOAuth
252
- ? stepCountIs(20)
253
- : 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');
254
262
 
255
263
  try {
256
264
  const result = streamText({
@@ -421,16 +429,20 @@ async function runAssistant(opts: RunOpts) {
421
429
 
422
430
  const MAX_CONTINUATIONS = 6;
423
431
  const continuationCount = opts.continuationCount ?? 0;
432
+ const endedWithToolActivity =
433
+ _toolActivityObserved && !_trailingAssistantTextAfterTool;
424
434
  const continuationDecision = decideOauthCodexContinuation({
425
435
  provider: opts.provider,
426
436
  isOpenAIOAuth,
427
437
  finishObserved: _finishObserved,
438
+ abortedByUser: _abortedByUser,
428
439
  continuationCount,
429
440
  maxContinuations: MAX_CONTINUATIONS,
430
441
  finishReason: streamFinishReason,
431
442
  rawFinishReason: streamRawFinishReason,
432
443
  firstToolSeen: fs,
433
444
  hasTrailingAssistantText: _trailingAssistantTextAfterTool,
445
+ endedWithToolActivity,
434
446
  droppedPseudoToolText: oauthTextGuard?.dropped ?? false,
435
447
  lastAssistantText: latestAssistantText,
436
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) {