btca-server 1.0.71 → 1.0.80
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/README.md +26 -11
- package/package.json +1 -13
- package/src/agent/index.ts +1 -1
- package/src/agent/loop.ts +33 -4
- package/src/agent/service.ts +7 -260
- package/src/agent/types.ts +0 -30
- package/src/config/index.ts +9 -1
- package/src/errors.ts +2 -3
- package/src/index.ts +21 -66
- package/src/providers/auth.ts +84 -36
- package/src/providers/index.ts +0 -1
- package/src/providers/model.ts +31 -19
- package/src/providers/openai.ts +220 -0
- package/src/providers/registry.ts +9 -50
- package/src/stream/service.test.ts +57 -0
- package/src/stream/service.ts +27 -4
- package/src/validation/index.ts +21 -5
package/src/index.ts
CHANGED
|
@@ -28,7 +28,6 @@ import { VirtualFs } from './vfs/virtual-fs.ts';
|
|
|
28
28
|
* GET /resources - Lists all configured resources
|
|
29
29
|
* POST /question - Ask a question (non-streaming)
|
|
30
30
|
* POST /question/stream - Ask a question (streaming SSE response)
|
|
31
|
-
* POST /opencode - Get OpenCode instance URL for a collection
|
|
32
31
|
*/
|
|
33
32
|
|
|
34
33
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -82,17 +81,6 @@ const QuestionRequestSchema = z.object({
|
|
|
82
81
|
quiet: z.boolean().optional()
|
|
83
82
|
});
|
|
84
83
|
|
|
85
|
-
const OpencodeRequestSchema = z.object({
|
|
86
|
-
resources: z
|
|
87
|
-
.array(ResourceNameField)
|
|
88
|
-
.max(
|
|
89
|
-
LIMITS.MAX_RESOURCES_PER_REQUEST,
|
|
90
|
-
`Too many resources (max ${LIMITS.MAX_RESOURCES_PER_REQUEST})`
|
|
91
|
-
)
|
|
92
|
-
.optional(),
|
|
93
|
-
quiet: z.boolean().optional()
|
|
94
|
-
});
|
|
95
|
-
|
|
96
84
|
const UpdateModelRequestSchema = z.object({
|
|
97
85
|
provider: z
|
|
98
86
|
.string()
|
|
@@ -120,10 +108,30 @@ const AddGitResourceRequestSchema = z.object({
|
|
|
120
108
|
specialNotes: GitResourceSchema.shape.specialNotes
|
|
121
109
|
});
|
|
122
110
|
|
|
111
|
+
const isWsl = () =>
|
|
112
|
+
process.platform === 'linux' &&
|
|
113
|
+
(Boolean(process.env.WSL_DISTRO_NAME) ||
|
|
114
|
+
Boolean(process.env.WSL_INTEROP) ||
|
|
115
|
+
Boolean(process.env.WSLENV));
|
|
116
|
+
|
|
117
|
+
const normalizeWslPath = (value: string) => {
|
|
118
|
+
if (!isWsl()) return value;
|
|
119
|
+
const match = value.match(/^([a-zA-Z]):\\(.*)$/);
|
|
120
|
+
if (!match) return value;
|
|
121
|
+
const drive = match[1]!.toLowerCase();
|
|
122
|
+
const rest = match[2]!.replace(/\\/g, '/');
|
|
123
|
+
return `/mnt/${drive}/${rest}`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const LocalPathRequestSchema = z.preprocess(
|
|
127
|
+
(value) => (typeof value === 'string' ? normalizeWslPath(value) : value),
|
|
128
|
+
LocalResourceSchema.shape.path
|
|
129
|
+
) as z.ZodType<string>;
|
|
130
|
+
|
|
123
131
|
const AddLocalResourceRequestSchema = z.object({
|
|
124
132
|
type: z.literal('local'),
|
|
125
133
|
name: LocalResourceSchema.shape.name,
|
|
126
|
-
path:
|
|
134
|
+
path: LocalPathRequestSchema,
|
|
127
135
|
specialNotes: LocalResourceSchema.shape.specialNotes
|
|
128
136
|
});
|
|
129
137
|
|
|
@@ -361,58 +369,6 @@ const createApp = (deps: {
|
|
|
361
369
|
});
|
|
362
370
|
})
|
|
363
371
|
|
|
364
|
-
// POST /opencode - Get OpenCode instance URL for a collection
|
|
365
|
-
.post('/opencode', async (c: HonoContext) => {
|
|
366
|
-
const decoded = await decodeJson(c.req.raw, OpencodeRequestSchema);
|
|
367
|
-
const resourceNames =
|
|
368
|
-
decoded.resources && decoded.resources.length > 0
|
|
369
|
-
? decoded.resources
|
|
370
|
-
: config.resources.map((r) => r.name);
|
|
371
|
-
|
|
372
|
-
const collectionKey = getCollectionKey(resourceNames);
|
|
373
|
-
Metrics.info('opencode.requested', {
|
|
374
|
-
quiet: decoded.quiet ?? false,
|
|
375
|
-
resources: resourceNames,
|
|
376
|
-
collectionKey
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
const collection = await collections.load({ resourceNames, quiet: decoded.quiet });
|
|
380
|
-
Metrics.info('collection.ready', { collectionKey, path: collection.path });
|
|
381
|
-
|
|
382
|
-
const { url, model, instanceId } = await agent.getOpencodeInstance({ collection });
|
|
383
|
-
Metrics.info('opencode.ready', { collectionKey, url, instanceId });
|
|
384
|
-
|
|
385
|
-
return c.json({
|
|
386
|
-
url,
|
|
387
|
-
model,
|
|
388
|
-
instanceId,
|
|
389
|
-
resources: resourceNames,
|
|
390
|
-
collection: { key: collectionKey, path: collection.path }
|
|
391
|
-
});
|
|
392
|
-
})
|
|
393
|
-
|
|
394
|
-
// GET /opencode/instances - List all active OpenCode instances
|
|
395
|
-
.get('/opencode/instances', (c: HonoContext) => {
|
|
396
|
-
const instances = agent.listInstances();
|
|
397
|
-
return c.json({ instances, count: instances.length });
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
// DELETE /opencode/instances - Close all OpenCode instances
|
|
401
|
-
.delete('/opencode/instances', async (c: HonoContext) => {
|
|
402
|
-
const result = await agent.closeAllInstances();
|
|
403
|
-
return c.json(result);
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
// DELETE /opencode/:id - Close a specific OpenCode instance
|
|
407
|
-
.delete('/opencode/:id', async (c: HonoContext) => {
|
|
408
|
-
const instanceId = c.req.param('id');
|
|
409
|
-
const result = await agent.closeInstance(instanceId);
|
|
410
|
-
if (!result.closed) {
|
|
411
|
-
return c.json({ error: 'Instance not found', instanceId }, 404);
|
|
412
|
-
}
|
|
413
|
-
return c.json({ closed: true, instanceId });
|
|
414
|
-
})
|
|
415
|
-
|
|
416
372
|
// PUT /config/model - Update model configuration
|
|
417
373
|
.put('/config/model', async (c: HonoContext) => {
|
|
418
374
|
const decoded = await decodeJson(c.req.raw, UpdateModelRequestSchema);
|
|
@@ -529,7 +485,6 @@ export const startServer = async (options: StartServerOptions = {}): Promise<Ser
|
|
|
529
485
|
port: actualPort,
|
|
530
486
|
url: `http://localhost:${actualPort}`,
|
|
531
487
|
stop: () => {
|
|
532
|
-
void agent.closeAllInstances();
|
|
533
488
|
VirtualFs.disposeAll();
|
|
534
489
|
clearAllVirtualCollectionMetadata();
|
|
535
490
|
server.stop();
|
package/src/providers/auth.ts
CHANGED
|
@@ -13,9 +13,30 @@ import { z } from 'zod';
|
|
|
13
13
|
import { Result } from 'better-result';
|
|
14
14
|
|
|
15
15
|
export namespace Auth {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export type AuthType = 'api' | 'oauth' | 'wellknown';
|
|
17
|
+
|
|
18
|
+
export type AuthStatus =
|
|
19
|
+
| { status: 'ok'; authType: AuthType; apiKey?: string; accountId?: string }
|
|
20
|
+
| { status: 'missing' }
|
|
21
|
+
| { status: 'invalid'; authType: AuthType };
|
|
22
|
+
|
|
23
|
+
const PROVIDER_AUTH_TYPES: Record<string, readonly AuthType[]> = {
|
|
24
|
+
opencode: ['api'],
|
|
25
|
+
openrouter: ['api'],
|
|
26
|
+
openai: ['oauth'],
|
|
27
|
+
anthropic: ['api'],
|
|
28
|
+
google: ['api', 'oauth']
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const readEnv = (key: string) => {
|
|
32
|
+
const value = process.env[key];
|
|
33
|
+
return value && value.trim().length > 0 ? value.trim() : undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getEnvApiKey = (providerId: string) => {
|
|
37
|
+
if (providerId === 'openrouter') return readEnv('OPENROUTER_API_KEY');
|
|
38
|
+
if (providerId === 'opencode') return readEnv('OPENCODE_API_KEY');
|
|
39
|
+
return undefined;
|
|
19
40
|
};
|
|
20
41
|
|
|
21
42
|
// Auth schema matching OpenCode's format
|
|
@@ -28,7 +49,8 @@ export namespace Auth {
|
|
|
28
49
|
type: z.literal('oauth'),
|
|
29
50
|
access: z.string(),
|
|
30
51
|
refresh: z.string(),
|
|
31
|
-
expires: z.number()
|
|
52
|
+
expires: z.number(),
|
|
53
|
+
accountId: z.string().optional()
|
|
32
54
|
});
|
|
33
55
|
|
|
34
56
|
const WellKnownAuthSchema = z.object({
|
|
@@ -106,13 +128,52 @@ export namespace Auth {
|
|
|
106
128
|
return authData[providerId];
|
|
107
129
|
}
|
|
108
130
|
|
|
131
|
+
export async function getAuthStatus(providerId: string): Promise<AuthStatus> {
|
|
132
|
+
const allowedTypes = PROVIDER_AUTH_TYPES[providerId];
|
|
133
|
+
if (!allowedTypes) return { status: 'missing' };
|
|
134
|
+
|
|
135
|
+
const envKey = getEnvApiKey(providerId);
|
|
136
|
+
if (envKey) {
|
|
137
|
+
return allowedTypes.includes('api')
|
|
138
|
+
? { status: 'ok', authType: 'api', apiKey: envKey }
|
|
139
|
+
: { status: 'invalid', authType: 'api' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const auth = await getCredentials(providerId);
|
|
143
|
+
if (!auth) return { status: 'missing' };
|
|
144
|
+
|
|
145
|
+
if (!allowedTypes.includes(auth.type)) {
|
|
146
|
+
return { status: 'invalid', authType: auth.type };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const apiKey = auth.type === 'api' ? auth.key : auth.type === 'oauth' ? auth.access : undefined;
|
|
150
|
+
const accountId = auth.type === 'oauth' ? auth.accountId : undefined;
|
|
151
|
+
return { status: 'ok', authType: auth.type, apiKey, accountId };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const getProviderAuthHint = (providerId: string) => {
|
|
155
|
+
switch (providerId) {
|
|
156
|
+
case 'openai':
|
|
157
|
+
return 'Run "opencode auth --provider openai" and complete OAuth.';
|
|
158
|
+
case 'anthropic':
|
|
159
|
+
return 'Run "opencode auth --provider anthropic" and enter an API key.';
|
|
160
|
+
case 'google':
|
|
161
|
+
return 'Run "opencode auth --provider google" and enter an API key or OAuth.';
|
|
162
|
+
case 'openrouter':
|
|
163
|
+
return 'Set OPENROUTER_API_KEY or run "opencode auth --provider openrouter".';
|
|
164
|
+
case 'opencode':
|
|
165
|
+
return 'Set OPENCODE_API_KEY or run "opencode auth --provider opencode".';
|
|
166
|
+
default:
|
|
167
|
+
return 'Run "opencode auth --provider <provider>" to configure credentials.';
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
109
171
|
/**
|
|
110
172
|
* Check if a provider is authenticated
|
|
111
173
|
*/
|
|
112
174
|
export async function isAuthenticated(providerId: string): Promise<boolean> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return auth !== undefined;
|
|
175
|
+
const status = await getAuthStatus(providerId);
|
|
176
|
+
return status.status === 'ok';
|
|
116
177
|
}
|
|
117
178
|
|
|
118
179
|
/**
|
|
@@ -120,24 +181,9 @@ export namespace Auth {
|
|
|
120
181
|
* Returns undefined if not authenticated or no key available
|
|
121
182
|
*/
|
|
122
183
|
export async function getApiKey(providerId: string): Promise<string | undefined> {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const auth = await getCredentials(providerId);
|
|
129
|
-
if (!auth) return undefined;
|
|
130
|
-
|
|
131
|
-
if (auth.type === 'api') {
|
|
132
|
-
return auth.key;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (auth.type === 'oauth') {
|
|
136
|
-
return auth.access;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// wellknown auth doesn't have an API key
|
|
140
|
-
return undefined;
|
|
184
|
+
const status = await getAuthStatus(providerId);
|
|
185
|
+
if (status.status !== 'ok') return undefined;
|
|
186
|
+
return status.apiKey;
|
|
141
187
|
}
|
|
142
188
|
|
|
143
189
|
/**
|
|
@@ -147,20 +193,22 @@ export namespace Auth {
|
|
|
147
193
|
return readAuthFile();
|
|
148
194
|
}
|
|
149
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Update stored credentials for a provider
|
|
198
|
+
*/
|
|
199
|
+
export async function setCredentials(providerId: string, info: AuthInfo): Promise<void> {
|
|
200
|
+
const filepath = getAuthFilePath();
|
|
201
|
+
const existing = await readAuthFile();
|
|
202
|
+
const next = { ...existing, [providerId]: info };
|
|
203
|
+
await Bun.write(filepath, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
204
|
+
}
|
|
205
|
+
|
|
150
206
|
/**
|
|
151
207
|
* Get the list of all authenticated provider IDs
|
|
152
208
|
*/
|
|
153
209
|
export async function getAuthenticatedProviders(): Promise<string[]> {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
if (getOpenRouterApiKey()) {
|
|
158
|
-
providers.add('openrouter');
|
|
159
|
-
}
|
|
160
|
-
if (authData['openrouter.ai'] || authData['openrouter-ai']) {
|
|
161
|
-
providers.add('openrouter');
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return Array.from(providers);
|
|
210
|
+
const providers = Object.keys(PROVIDER_AUTH_TYPES);
|
|
211
|
+
const statuses = await Promise.all(providers.map((provider) => getAuthStatus(provider)));
|
|
212
|
+
return providers.filter((_, index) => statuses[index]?.status === 'ok');
|
|
165
213
|
}
|
|
166
214
|
}
|
package/src/providers/index.ts
CHANGED
package/src/providers/model.ts
CHANGED
|
@@ -16,24 +16,39 @@ export namespace Model {
|
|
|
16
16
|
export class ProviderNotFoundError extends Error {
|
|
17
17
|
readonly _tag = 'ProviderNotFoundError';
|
|
18
18
|
readonly providerId: string;
|
|
19
|
+
readonly hint: string;
|
|
19
20
|
|
|
20
21
|
constructor(providerId: string) {
|
|
21
22
|
super(`Provider "${providerId}" is not supported`);
|
|
22
23
|
this.providerId = providerId;
|
|
24
|
+
this.hint =
|
|
25
|
+
'Open an issue to request this provider: https://github.com/davis7dotsh/better-context/issues.';
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export class ProviderNotAuthenticatedError extends Error {
|
|
27
30
|
readonly _tag = 'ProviderNotAuthenticatedError';
|
|
28
31
|
readonly providerId: string;
|
|
32
|
+
readonly hint: string;
|
|
29
33
|
|
|
30
34
|
constructor(providerId: string) {
|
|
31
|
-
super(
|
|
32
|
-
providerId === 'openrouter'
|
|
33
|
-
? `Provider "${providerId}" is not authenticated. Set OPENROUTER_API_KEY to authenticate.`
|
|
34
|
-
: `Provider "${providerId}" is not authenticated. Run 'opencode auth login' to authenticate.`
|
|
35
|
-
);
|
|
35
|
+
super(`Provider "${providerId}" is not authenticated.`);
|
|
36
36
|
this.providerId = providerId;
|
|
37
|
+
this.hint = Auth.getProviderAuthHint(providerId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ProviderAuthTypeError extends Error {
|
|
42
|
+
readonly _tag = 'ProviderAuthTypeError';
|
|
43
|
+
readonly providerId: string;
|
|
44
|
+
readonly authType: string;
|
|
45
|
+
readonly hint: string;
|
|
46
|
+
|
|
47
|
+
constructor(args: { providerId: string; authType: string }) {
|
|
48
|
+
super(`Provider "${args.providerId}" does not support "${args.authType}" auth.`);
|
|
49
|
+
this.providerId = args.providerId;
|
|
50
|
+
this.authType = args.authType;
|
|
51
|
+
this.hint = Auth.getProviderAuthHint(args.providerId);
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
|
|
@@ -72,22 +87,24 @@ export namespace Model {
|
|
|
72
87
|
|
|
73
88
|
// Get authentication
|
|
74
89
|
let apiKey: string | undefined;
|
|
90
|
+
let accountId: string | undefined;
|
|
75
91
|
|
|
76
92
|
if (!options.skipAuth) {
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
} else {
|
|
81
|
-
apiKey = await Auth.getApiKey(normalizedProviderId);
|
|
82
|
-
if (!apiKey) {
|
|
83
|
-
throw new ProviderNotAuthenticatedError(providerId);
|
|
84
|
-
}
|
|
93
|
+
const status = await Auth.getAuthStatus(normalizedProviderId);
|
|
94
|
+
if (status.status === 'missing') {
|
|
95
|
+
throw new ProviderNotAuthenticatedError(providerId);
|
|
85
96
|
}
|
|
97
|
+
if (status.status === 'invalid') {
|
|
98
|
+
throw new ProviderAuthTypeError({ providerId, authType: status.authType });
|
|
99
|
+
}
|
|
100
|
+
apiKey = status.apiKey;
|
|
101
|
+
accountId = status.accountId;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
// Build provider options
|
|
89
105
|
const providerOptions: ProviderOptions = {
|
|
90
|
-
...options.providerOptions
|
|
106
|
+
...options.providerOptions,
|
|
107
|
+
...(accountId ? { accountId } : {})
|
|
91
108
|
};
|
|
92
109
|
|
|
93
110
|
if (apiKey) {
|
|
@@ -111,11 +128,6 @@ export namespace Model {
|
|
|
111
128
|
return false;
|
|
112
129
|
}
|
|
113
130
|
|
|
114
|
-
// Special case: opencode gateway is always available
|
|
115
|
-
if (normalizedProviderId === 'opencode') {
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
131
|
return Auth.isAuthenticated(normalizedProviderId);
|
|
120
132
|
}
|
|
121
133
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import { Auth } from './auth.ts';
|
|
4
|
+
|
|
5
|
+
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
6
|
+
const ISSUER = 'https://auth.openai.com';
|
|
7
|
+
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
|
|
8
|
+
const USER_AGENT = `btca/${process.env.npm_package_version ?? 'dev'} (${os.platform()} ${os.release()}; ${os.arch()})`;
|
|
9
|
+
|
|
10
|
+
type TokenResponse = {
|
|
11
|
+
id_token?: string;
|
|
12
|
+
access_token: string;
|
|
13
|
+
refresh_token?: string;
|
|
14
|
+
expires_in?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type IdTokenClaims = {
|
|
18
|
+
chatgpt_account_id?: string;
|
|
19
|
+
organizations?: Array<{ id: string }>;
|
|
20
|
+
email?: string;
|
|
21
|
+
'https://api.openai.com/auth'?: {
|
|
22
|
+
chatgpt_account_id?: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const parseJwtClaims = (token: string): IdTokenClaims | undefined => {
|
|
27
|
+
const parts = token.split('.');
|
|
28
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as IdTokenClaims;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const extractAccountIdFromClaims = (claims: IdTokenClaims): string | undefined =>
|
|
37
|
+
claims.chatgpt_account_id ||
|
|
38
|
+
claims['https://api.openai.com/auth']?.chatgpt_account_id ||
|
|
39
|
+
claims.organizations?.[0]?.id;
|
|
40
|
+
|
|
41
|
+
const extractAccountId = (tokens: TokenResponse): string | undefined => {
|
|
42
|
+
if (tokens.id_token) {
|
|
43
|
+
const claims = parseJwtClaims(tokens.id_token);
|
|
44
|
+
const accountId = claims && extractAccountIdFromClaims(claims);
|
|
45
|
+
if (accountId) return accountId;
|
|
46
|
+
}
|
|
47
|
+
if (tokens.access_token) {
|
|
48
|
+
const claims = parseJwtClaims(tokens.access_token);
|
|
49
|
+
return claims ? extractAccountIdFromClaims(claims) : undefined;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const refreshAccessToken = async (refreshToken: string): Promise<TokenResponse> => {
|
|
55
|
+
const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
58
|
+
body: new URLSearchParams({
|
|
59
|
+
grant_type: 'refresh_token',
|
|
60
|
+
refresh_token: refreshToken,
|
|
61
|
+
client_id: CLIENT_ID
|
|
62
|
+
}).toString()
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
66
|
+
}
|
|
67
|
+
return response.json() as Promise<TokenResponse>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const buildHeaders = (initHeaders: unknown, accountId?: string): Headers => {
|
|
71
|
+
const headers = new Headers();
|
|
72
|
+
|
|
73
|
+
if (initHeaders instanceof Headers) {
|
|
74
|
+
initHeaders.forEach((value, key) => headers.set(key, value));
|
|
75
|
+
} else if (Array.isArray(initHeaders)) {
|
|
76
|
+
for (const [key, value] of initHeaders) {
|
|
77
|
+
if (value !== undefined) headers.set(key, String(value));
|
|
78
|
+
}
|
|
79
|
+
} else if (initHeaders && typeof initHeaders === 'object') {
|
|
80
|
+
for (const [key, value] of Object.entries(initHeaders as Record<string, unknown>)) {
|
|
81
|
+
if (value !== undefined) headers.set(key, String(value));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (accountId) {
|
|
86
|
+
headers.set('ChatGPT-Account-Id', accountId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return headers;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const rewriteUrl = (requestInput: unknown) => {
|
|
93
|
+
const parsed =
|
|
94
|
+
requestInput instanceof URL
|
|
95
|
+
? requestInput
|
|
96
|
+
: new URL(
|
|
97
|
+
typeof requestInput === 'string'
|
|
98
|
+
? requestInput
|
|
99
|
+
: requestInput instanceof Request
|
|
100
|
+
? requestInput.url
|
|
101
|
+
: String(requestInput)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return parsed.pathname.includes('/v1/responses') || parsed.pathname.includes('/chat/completions')
|
|
105
|
+
? new URL(CODEX_API_ENDPOINT)
|
|
106
|
+
: parsed;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const normalizeBody = (body: unknown): string | undefined => {
|
|
110
|
+
if (typeof body === 'string') return body;
|
|
111
|
+
if (body instanceof Uint8Array) return new TextDecoder().decode(body);
|
|
112
|
+
if (body instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(body));
|
|
113
|
+
return undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const sanitizeCodexPayload = (parsed: Record<string, unknown>) => {
|
|
117
|
+
parsed.store = false;
|
|
118
|
+
if ('previous_response_id' in parsed) {
|
|
119
|
+
delete parsed.previous_response_id;
|
|
120
|
+
}
|
|
121
|
+
const input = parsed.input;
|
|
122
|
+
if (!Array.isArray(input)) return;
|
|
123
|
+
parsed.input = input
|
|
124
|
+
.filter(
|
|
125
|
+
(item) =>
|
|
126
|
+
!(item && typeof item === 'object' && 'type' in item && item.type === 'item_reference')
|
|
127
|
+
)
|
|
128
|
+
.map((item) => {
|
|
129
|
+
if (!item || typeof item !== 'object') return item;
|
|
130
|
+
// Strip item IDs to avoid referencing non-persisted items when store=false.
|
|
131
|
+
const { id: _unused, ...rest } = item as Record<string, unknown>;
|
|
132
|
+
return rest;
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const injectCodexDefaults = (
|
|
137
|
+
init: RequestInit | undefined,
|
|
138
|
+
instructions?: string
|
|
139
|
+
): RequestInit | undefined => {
|
|
140
|
+
if (!instructions) return init;
|
|
141
|
+
const bodyText = normalizeBody(init?.body);
|
|
142
|
+
if (!bodyText) return init;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(bodyText) as Record<string, unknown>;
|
|
146
|
+
if (parsed.instructions == null) {
|
|
147
|
+
parsed.instructions = instructions;
|
|
148
|
+
}
|
|
149
|
+
sanitizeCodexPayload(parsed);
|
|
150
|
+
return { ...init, body: JSON.stringify(parsed) };
|
|
151
|
+
} catch {
|
|
152
|
+
return init;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function createOpenAICodex(
|
|
157
|
+
options: {
|
|
158
|
+
apiKey?: string;
|
|
159
|
+
accountId?: string;
|
|
160
|
+
baseURL?: string;
|
|
161
|
+
headers?: Record<string, string>;
|
|
162
|
+
name?: string;
|
|
163
|
+
instructions?: string;
|
|
164
|
+
sessionId?: string;
|
|
165
|
+
} = {}
|
|
166
|
+
) {
|
|
167
|
+
const customFetch = (async (requestInput, init) => {
|
|
168
|
+
const storedAuth = await Auth.getCredentials('openai');
|
|
169
|
+
let accessToken = options.apiKey;
|
|
170
|
+
let accountId = options.accountId;
|
|
171
|
+
|
|
172
|
+
if (storedAuth?.type === 'oauth') {
|
|
173
|
+
accessToken = storedAuth.access;
|
|
174
|
+
accountId = storedAuth.accountId ?? accountId;
|
|
175
|
+
|
|
176
|
+
if (!storedAuth.access || storedAuth.expires < Date.now()) {
|
|
177
|
+
const tokens = await refreshAccessToken(storedAuth.refresh);
|
|
178
|
+
const refreshedAccountId = extractAccountId(tokens) ?? accountId;
|
|
179
|
+
accessToken = tokens.access_token;
|
|
180
|
+
accountId = refreshedAccountId;
|
|
181
|
+
await Auth.setCredentials('openai', {
|
|
182
|
+
type: 'oauth',
|
|
183
|
+
refresh: tokens.refresh_token ?? storedAuth.refresh,
|
|
184
|
+
access: tokens.access_token,
|
|
185
|
+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
186
|
+
...(refreshedAccountId ? { accountId: refreshedAccountId } : {})
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const url = rewriteUrl(requestInput);
|
|
192
|
+
const headerSource =
|
|
193
|
+
init?.headers ?? (requestInput instanceof Request ? requestInput.headers : undefined);
|
|
194
|
+
const headers = buildHeaders(headerSource, accountId);
|
|
195
|
+
const fallbackInstructions =
|
|
196
|
+
options.instructions ?? 'You are btca, an expert documentation search agent.';
|
|
197
|
+
const nextInit = injectCodexDefaults(init, fallbackInstructions);
|
|
198
|
+
headers.set('originator', 'opencode');
|
|
199
|
+
headers.set('User-Agent', USER_AGENT);
|
|
200
|
+
if (options.sessionId) {
|
|
201
|
+
headers.set('session_id', options.sessionId);
|
|
202
|
+
}
|
|
203
|
+
if (accessToken) {
|
|
204
|
+
headers.set('authorization', `Bearer ${accessToken}`);
|
|
205
|
+
}
|
|
206
|
+
return fetch(url, { ...nextInit, headers });
|
|
207
|
+
}) as typeof fetch;
|
|
208
|
+
|
|
209
|
+
if (fetch.preconnect) {
|
|
210
|
+
customFetch.preconnect = fetch.preconnect.bind(fetch);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return createOpenAI({
|
|
214
|
+
apiKey: options.apiKey,
|
|
215
|
+
baseURL: options.baseURL,
|
|
216
|
+
headers: options.headers,
|
|
217
|
+
name: options.name,
|
|
218
|
+
fetch: customFetch
|
|
219
|
+
});
|
|
220
|
+
}
|