btca-server 1.0.71 → 1.0.72

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 CHANGED
@@ -130,7 +130,7 @@ Update the AI provider and model configuration.
130
130
 
131
131
  The server reads configuration from `~/.btca/config.toml` or your local project's `.btca/config.toml`. You'll need to configure:
132
132
 
133
- - **AI Provider**: OpenCode AI provider (e.g., "anthropic")
133
+ - **AI Provider**: OpenCode AI provider (e.g., "opencode", "anthropic")
134
134
  - **Model**: AI model to use (e.g., "claude-3-7-sonnet-20250219")
135
135
  - **Resources**: Local directories or git repositories to query
136
136
 
@@ -153,10 +153,33 @@ url = "https://github.com/user/repo"
153
153
  branch = "main"
154
154
  ```
155
155
 
156
+ ## Supported Providers
157
+
158
+ BTCA supports the following providers only:
159
+
160
+ - `opencode` — API key required
161
+ - `openrouter` — API key required
162
+ - `openai` — OAuth only
163
+ - `google` — API key or OAuth
164
+ - `anthropic` — API key required
165
+
166
+ Authenticate providers via OpenCode:
167
+
168
+ ```bash
169
+ opencode auth --provider <provider>
170
+ ```
171
+
172
+ ## Authentication Notes
173
+
174
+ - OpenCode and OpenRouter can use environment variables or OpenCode auth.
175
+ - OpenAI requires OAuth (API keys are not supported).
176
+ - Anthropic requires an API key.
177
+ - Google supports API key or OAuth.
178
+
156
179
  ## Environment Variables
157
180
 
158
181
  - `PORT`: Server port (default: 8080)
159
- - `OPENCODE_API_KEY`: OpenCode AI API key (required)
182
+ - `OPENCODE_API_KEY`: OpenCode API key (required when provider is `opencode`)
160
183
  - `OPENROUTER_API_KEY`: OpenRouter API key (required when provider is `openrouter`)
161
184
  - `OPENROUTER_BASE_URL`: Override OpenRouter base URL (optional)
162
185
  - `OPENROUTER_HTTP_REFERER`: Optional OpenRouter header for rankings
@@ -184,7 +207,7 @@ import type { BtcaStreamEvent, BtcaStreamMetaEvent } from 'btca-server/stream/ty
184
207
  ## Requirements
185
208
 
186
209
  - **Bun**: >= 1.1.0 (this package is designed specifically for Bun runtime)
187
- - **OpenCode AI API Key**: Required for AI functionality
210
+ - **OpenCode API Key**: Required when using the `opencode` provider
188
211
 
189
212
  ## License
190
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "btca-server",
3
- "version": "1.0.71",
3
+ "version": "1.0.72",
4
4
  "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
5
  "author": "Ben Davis",
6
6
  "license": "MIT",
@@ -51,21 +51,10 @@
51
51
  "prettier": "^3.7.4"
52
52
  },
53
53
  "dependencies": {
54
- "@ai-sdk/amazon-bedrock": "^4.0.30",
55
54
  "@ai-sdk/anthropic": "^3.0.23",
56
- "@ai-sdk/azure": "^3.0.18",
57
- "@ai-sdk/cerebras": "^2.0.20",
58
- "@ai-sdk/cohere": "^3.0.11",
59
- "@ai-sdk/deepinfra": "^2.0.19",
60
55
  "@ai-sdk/google": "^3.0.13",
61
- "@ai-sdk/google-vertex": "^4.0.28",
62
- "@ai-sdk/groq": "^3.0.15",
63
- "@ai-sdk/mistral": "^3.0.12",
64
56
  "@ai-sdk/openai": "^3.0.18",
65
57
  "@ai-sdk/openai-compatible": "^2.0.18",
66
- "@ai-sdk/perplexity": "^3.0.11",
67
- "@ai-sdk/togetherai": "^2.0.20",
68
- "@ai-sdk/xai": "^3.0.34",
69
58
  "@btca/shared": "workspace:*",
70
59
  "@opencode-ai/sdk": "^1.1.28",
71
60
  "ai": "^6.0.49",
package/src/agent/loop.ts CHANGED
@@ -11,6 +11,7 @@ export namespace AgentLoop {
11
11
  // Event types for streaming
12
12
  export type AgentEvent =
13
13
  | { type: 'text-delta'; text: string }
14
+ | { type: 'reasoning-delta'; text: string }
14
15
  | { type: 'tool-call'; toolName: string; input: unknown }
15
16
  | { type: 'tool-result'; toolName: string; output: string }
16
17
  | {
@@ -131,8 +132,14 @@ export namespace AgentLoop {
131
132
  maxSteps = 40
132
133
  } = options;
133
134
 
135
+ const systemPrompt = buildSystemPrompt(agentInstructions);
136
+ const sessionId = crypto.randomUUID();
137
+
134
138
  // Get the model
135
- const model = await Model.getModel(providerId, modelId);
139
+ const model = await Model.getModel(providerId, modelId, {
140
+ providerOptions:
141
+ providerId === 'openai' ? { instructions: systemPrompt, sessionId } : undefined
142
+ });
136
143
 
137
144
  // Get initial context
138
145
  const initialContext = await getInitialContext(collectionPath, vfsId);
@@ -155,9 +162,13 @@ export namespace AgentLoop {
155
162
  // Run streamText with tool execution
156
163
  const result = streamText({
157
164
  model,
158
- system: buildSystemPrompt(agentInstructions),
165
+ system: systemPrompt,
159
166
  messages,
160
167
  tools,
168
+ providerOptions:
169
+ providerId === 'openai'
170
+ ? { openai: { instructions: systemPrompt, store: false } }
171
+ : undefined,
161
172
  stopWhen: stepCountIs(maxSteps)
162
173
  });
163
174
 
@@ -169,6 +180,10 @@ export namespace AgentLoop {
169
180
  events.push({ type: 'text-delta', text: part.text });
170
181
  break;
171
182
 
183
+ case 'reasoning-delta':
184
+ events.push({ type: 'reasoning-delta', text: part.text });
185
+ break;
186
+
172
187
  case 'tool-call':
173
188
  events.push({
174
189
  type: 'tool-call',
@@ -226,8 +241,14 @@ export namespace AgentLoop {
226
241
  maxSteps = 40
227
242
  } = options;
228
243
 
244
+ const systemPrompt = buildSystemPrompt(agentInstructions);
245
+ const sessionId = crypto.randomUUID();
246
+
229
247
  // Get the model
230
- const model = await Model.getModel(providerId, modelId);
248
+ const model = await Model.getModel(providerId, modelId, {
249
+ providerOptions:
250
+ providerId === 'openai' ? { instructions: systemPrompt, sessionId } : undefined
251
+ });
231
252
 
232
253
  // Get initial context
233
254
  const initialContext = await getInitialContext(collectionPath, vfsId);
@@ -246,9 +267,13 @@ export namespace AgentLoop {
246
267
  // Run streamText with tool execution
247
268
  const result = streamText({
248
269
  model,
249
- system: buildSystemPrompt(agentInstructions),
270
+ system: systemPrompt,
250
271
  messages,
251
272
  tools,
273
+ providerOptions:
274
+ providerId === 'openai'
275
+ ? { openai: { instructions: systemPrompt, store: false } }
276
+ : undefined,
252
277
  stopWhen: stepCountIs(maxSteps)
253
278
  });
254
279
 
@@ -259,6 +284,10 @@ export namespace AgentLoop {
259
284
  yield { type: 'text-delta', text: part.text };
260
285
  break;
261
286
 
287
+ case 'reasoning-delta':
288
+ yield { type: 'reasoning-delta', text: part.text };
289
+ break;
290
+
262
291
  case 'tool-call':
263
292
  yield {
264
293
  type: 'tool-call',
@@ -11,7 +11,7 @@ import {
11
11
  import { Result } from 'better-result';
12
12
 
13
13
  import { Config } from '../config/index.ts';
14
- import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
14
+ import { type TaggedErrorOptions } from '../errors.ts';
15
15
  import { Metrics } from '../metrics/index.ts';
16
16
  import { Auth, getSupportedProviders } from '../providers/index.ts';
17
17
  import type { CollectionResult } from '../collections/types.ts';
@@ -79,7 +79,9 @@ export namespace Agent {
79
79
  super(`Invalid provider: "${args.providerId}"`);
80
80
  this.providerId = args.providerId;
81
81
  this.availableProviders = args.availableProviders;
82
- this.hint = `Available providers: ${args.availableProviders.join(', ')}. Update your config with a valid provider.`;
82
+ this.hint = `Available providers: ${args.availableProviders.join(
83
+ ', '
84
+ )}. Update your config with a valid provider. Open an issue to request this provider: https://github.com/davis7dotsh/better-context/issues.`;
83
85
  }
84
86
  }
85
87
 
@@ -113,10 +115,7 @@ export namespace Agent {
113
115
  super(`Provider "${args.providerId}" is not connected`);
114
116
  this.providerId = args.providerId;
115
117
  this.connectedProviders = args.connectedProviders;
116
- const baseHint =
117
- args.providerId === 'openrouter'
118
- ? 'Set OPENROUTER_API_KEY to authenticate OpenRouter.'
119
- : CommonHints.RUN_AUTH;
118
+ const baseHint = Auth.getProviderAuthHint(args.providerId);
120
119
  if (args.connectedProviders.length > 0) {
121
120
  this.hint = `${baseHint} Connected providers: ${args.connectedProviders.join(', ')}.`;
122
121
  } else {
@@ -6,6 +6,7 @@ import { z } from 'zod';
6
6
 
7
7
  import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
8
8
  import { Metrics } from '../metrics/index.ts';
9
+ import { getSupportedProviders, isProviderSupported } from '../providers/index.ts';
9
10
  import { ResourceDefinitionSchema, type ResourceDefinition } from '../resources/schema.ts';
10
11
 
11
12
  export const GLOBAL_CONFIG_DIR = '~/.config/btca';
@@ -621,6 +622,13 @@ export namespace Config {
621
622
  getResource: (name: string) => getMergedResources().find((r) => r.name === name),
622
623
 
623
624
  updateModel: async (provider: string, model: string) => {
625
+ if (!isProviderSupported(provider)) {
626
+ const available = getSupportedProviders();
627
+ throw new ConfigError({
628
+ message: `Provider "${provider}" is not supported`,
629
+ hint: `Available providers: ${available.join(', ')}. Open an issue to request this provider: https://github.com/davis7dotsh/better-context/issues.`
630
+ });
631
+ }
624
632
  const mutableConfig = getMutableConfig();
625
633
  const updated = { ...mutableConfig, provider, model };
626
634
  setMutableConfig(updated);
package/src/index.ts CHANGED
@@ -120,10 +120,30 @@ const AddGitResourceRequestSchema = z.object({
120
120
  specialNotes: GitResourceSchema.shape.specialNotes
121
121
  });
122
122
 
123
+ const isWsl = () =>
124
+ process.platform === 'linux' &&
125
+ (Boolean(process.env.WSL_DISTRO_NAME) ||
126
+ Boolean(process.env.WSL_INTEROP) ||
127
+ Boolean(process.env.WSLENV));
128
+
129
+ const normalizeWslPath = (value: string) => {
130
+ if (!isWsl()) return value;
131
+ const match = value.match(/^([a-zA-Z]):\\(.*)$/);
132
+ if (!match) return value;
133
+ const drive = match[1]!.toLowerCase();
134
+ const rest = match[2]!.replace(/\\/g, '/');
135
+ return `/mnt/${drive}/${rest}`;
136
+ };
137
+
138
+ const LocalPathRequestSchema = z.preprocess(
139
+ (value) => (typeof value === 'string' ? normalizeWslPath(value) : value),
140
+ LocalResourceSchema.shape.path
141
+ ) as z.ZodType<string>;
142
+
123
143
  const AddLocalResourceRequestSchema = z.object({
124
144
  type: z.literal('local'),
125
145
  name: LocalResourceSchema.shape.name,
126
- path: LocalResourceSchema.shape.path,
146
+ path: LocalPathRequestSchema,
127
147
  specialNotes: LocalResourceSchema.shape.specialNotes
128
148
  });
129
149
 
@@ -13,9 +13,30 @@ import { z } from 'zod';
13
13
  import { Result } from 'better-result';
14
14
 
15
15
  export namespace Auth {
16
- const getOpenRouterApiKey = () => {
17
- const apiKey = process.env.OPENROUTER_API_KEY;
18
- return apiKey && apiKey.trim().length > 0 ? apiKey.trim() : undefined;
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
- if (providerId === 'openrouter' && getOpenRouterApiKey()) return true;
114
- const auth = await getCredentials(providerId);
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
- if (providerId === 'openrouter') {
124
- const envKey = getOpenRouterApiKey();
125
- if (envKey) return envKey;
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 authData = await readAuthFile();
155
- const providers = new Set(Object.keys(authData));
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
  }
@@ -6,7 +6,6 @@ export { Auth } from './auth.ts';
6
6
  export { Model } from './model.ts';
7
7
  export {
8
8
  PROVIDER_REGISTRY,
9
- PROVIDER_ALIASES,
10
9
  isProviderSupported,
11
10
  normalizeProviderId,
12
11
  getProviderFactory,
@@ -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
- // Special handling for 'opencode' provider - check env var first
78
- if (normalizedProviderId === 'opencode') {
79
- apiKey = process.env.OPENCODE_API_KEY || (await Auth.getApiKey(normalizedProviderId));
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
+ }
@@ -2,31 +2,22 @@
2
2
  * Provider Registry
3
3
  * Maps provider IDs to their AI SDK factory functions
4
4
  */
5
- import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
6
5
  import { createAnthropic } from '@ai-sdk/anthropic';
7
- import { createAzure } from '@ai-sdk/azure';
8
- import { createCerebras } from '@ai-sdk/cerebras';
9
- import { createCohere } from '@ai-sdk/cohere';
10
- import { createDeepInfra } from '@ai-sdk/deepinfra';
11
6
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
12
- import { createVertex } from '@ai-sdk/google-vertex';
13
- import { createGroq } from '@ai-sdk/groq';
14
- import { createMistral } from '@ai-sdk/mistral';
15
- import { createOpenAI } from '@ai-sdk/openai';
16
- import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
17
- import { createPerplexity } from '@ai-sdk/perplexity';
18
- import { createTogetherAI } from '@ai-sdk/togetherai';
19
- import { createXai } from '@ai-sdk/xai';
20
7
 
21
8
  import { createOpenCodeZen } from './opencode.ts';
9
+ import { createOpenAICodex } from './openai.ts';
22
10
  import { createOpenRouter } from './openrouter.ts';
23
11
 
24
12
  // Type for provider factory options
25
13
  export type ProviderOptions = {
26
14
  apiKey?: string;
15
+ accountId?: string;
27
16
  baseURL?: string;
28
17
  headers?: Record<string, string>;
29
18
  name?: string; // Required for openai-compatible
19
+ instructions?: string;
20
+ sessionId?: string;
30
21
  };
31
22
 
32
23
  // Type for a provider factory function
@@ -44,58 +35,26 @@ export const PROVIDER_REGISTRY: Record<string, ProviderFactory> = {
44
35
  anthropic: createAnthropic as ProviderFactory,
45
36
 
46
37
  // OpenAI
47
- openai: createOpenAI as ProviderFactory,
48
-
38
+ openai: createOpenAICodex as ProviderFactory,
49
39
  // Google
50
40
  google: createGoogleGenerativeAI as ProviderFactory,
51
- 'google-vertex': createVertex as ProviderFactory,
52
-
53
- // Amazon
54
- 'amazon-bedrock': createAmazonBedrock as ProviderFactory,
55
-
56
- // Azure
57
- azure: createAzure as ProviderFactory,
58
-
59
- // Other providers
60
- groq: createGroq as ProviderFactory,
61
- mistral: createMistral as ProviderFactory,
62
- xai: createXai as ProviderFactory,
63
- cohere: createCohere as ProviderFactory,
64
- deepinfra: createDeepInfra as ProviderFactory,
65
- cerebras: createCerebras as ProviderFactory,
66
- perplexity: createPerplexity as ProviderFactory,
67
- togetherai: createTogetherAI as ProviderFactory,
68
-
69
- // OpenAI-compatible providers (for custom endpoints)
70
- openrouter: createOpenRouter as ProviderFactory,
71
- 'openai-compatible': createOpenAICompatible as ProviderFactory
72
- };
73
41
 
74
- // Provider aliases for common naming variations
75
- export const PROVIDER_ALIASES: Record<string, string> = {
76
- claude: 'anthropic',
77
- 'gpt-4': 'openai',
78
- 'gpt-4o': 'openai',
79
- gemini: 'google',
80
- vertex: 'google-vertex',
81
- bedrock: 'amazon-bedrock',
82
- grok: 'xai',
83
- together: 'togetherai'
42
+ // OpenRouter (OpenAI-compatible gateway)
43
+ openrouter: createOpenRouter as ProviderFactory
84
44
  };
85
45
 
86
46
  /**
87
47
  * Check if a provider is supported
88
48
  */
89
49
  export function isProviderSupported(providerId: string): boolean {
90
- const normalized = PROVIDER_ALIASES[providerId] || providerId;
91
- return normalized in PROVIDER_REGISTRY;
50
+ return providerId in PROVIDER_REGISTRY;
92
51
  }
93
52
 
94
53
  /**
95
54
  * Get the normalized provider ID
96
55
  */
97
56
  export function normalizeProviderId(providerId: string): string {
98
- return PROVIDER_ALIASES[providerId] || providerId;
57
+ return providerId;
99
58
  }
100
59
 
101
60
  /**
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+
3
+ import { StreamService } from './service.ts';
4
+ import type { BtcaStreamEvent } from './types.ts';
5
+
6
+ const readStream = async (stream: ReadableStream<Uint8Array>) => {
7
+ const decoder = new TextDecoder();
8
+ let output = '';
9
+ for await (const chunk of stream) {
10
+ output += decoder.decode(chunk, { stream: true });
11
+ }
12
+ output += decoder.decode();
13
+ return output;
14
+ };
15
+
16
+ const parseSseEvents = (payload: string) =>
17
+ payload
18
+ .split('\n\n')
19
+ .map((chunk) => chunk.trim())
20
+ .filter(Boolean)
21
+ .map((chunk) => chunk.split('\n').find((line) => line.startsWith('data: ')))
22
+ .filter((line): line is string => Boolean(line))
23
+ .map((line) => JSON.parse(line.slice(6)) as BtcaStreamEvent);
24
+
25
+ describe('StreamService.createSseStream', () => {
26
+ it('streams reasoning deltas and includes final reasoning in done', async () => {
27
+ const eventStream = (async function* () {
28
+ yield { type: 'reasoning-delta', text: 'First ' } as const;
29
+ yield { type: 'reasoning-delta', text: 'Second' } as const;
30
+ yield { type: 'text-delta', text: 'Answer' } as const;
31
+ yield { type: 'finish', finishReason: 'stop' } as const;
32
+ })();
33
+
34
+ const stream = StreamService.createSseStream({
35
+ meta: {
36
+ type: 'meta',
37
+ model: { provider: 'test', model: 'test-model' },
38
+ resources: ['svelte'],
39
+ collection: { key: 'test', path: '/tmp' }
40
+ },
41
+ eventStream,
42
+ question: 'What?'
43
+ });
44
+
45
+ const payload = await readStream(stream);
46
+ const events = parseSseEvents(payload);
47
+
48
+ const reasoningDeltaText = events
49
+ .filter((event) => event.type === 'reasoning.delta')
50
+ .map((event) => event.delta)
51
+ .join('');
52
+ expect(reasoningDeltaText).toBe('First Second');
53
+
54
+ const doneEvent = events.find((event) => event.type === 'done');
55
+ expect(doneEvent?.reasoning).toBe('First Second');
56
+ });
57
+ });
@@ -10,6 +10,7 @@ import type {
10
10
  BtcaStreamErrorEvent,
11
11
  BtcaStreamEvent,
12
12
  BtcaStreamMetaEvent,
13
+ BtcaStreamReasoningDeltaEvent,
13
14
  BtcaStreamTextDeltaEvent,
14
15
  BtcaStreamToolUpdatedEvent
15
16
  } from './types.ts';
@@ -36,9 +37,12 @@ export namespace StreamService {
36
37
 
37
38
  // Track accumulated text and tool state
38
39
  let accumulatedText = '';
40
+ let emittedText = '';
41
+ let accumulatedReasoning = '';
39
42
  const toolsByCallId = new Map<string, Omit<BtcaStreamToolUpdatedEvent, 'type'>>();
40
43
  let textEvents = 0;
41
44
  let toolEvents = 0;
45
+ let reasoningEvents = 0;
42
46
 
43
47
  // Extract the core question for stripping echoed user message from final response
44
48
  const coreQuestion = extractCoreQuestion(args.question);
@@ -61,8 +65,24 @@ export namespace StreamService {
61
65
  textEvents += 1;
62
66
  accumulatedText += event.text;
63
67
 
64
- const msg: BtcaStreamTextDeltaEvent = {
65
- type: 'text.delta',
68
+ const nextText = stripUserQuestionFromStart(accumulatedText, coreQuestion);
69
+ const delta = nextText.slice(emittedText.length);
70
+ if (delta) {
71
+ emittedText = nextText;
72
+ const msg: BtcaStreamTextDeltaEvent = {
73
+ type: 'text.delta',
74
+ delta
75
+ };
76
+ emit(controller, msg);
77
+ }
78
+ break;
79
+ }
80
+
81
+ case 'reasoning-delta': {
82
+ reasoningEvents += 1;
83
+ accumulatedReasoning += event.text;
84
+ const msg: BtcaStreamReasoningDeltaEvent = {
85
+ type: 'reasoning.delta',
66
86
  delta: event.text
67
87
  };
68
88
  emit(controller, msg);
@@ -123,21 +143,24 @@ export namespace StreamService {
123
143
  const tools = Array.from(toolsByCallId.values());
124
144
 
125
145
  // Strip the echoed user question from the final text
126
- let finalText = stripUserQuestionFromStart(accumulatedText, coreQuestion);
146
+ const finalText = stripUserQuestionFromStart(accumulatedText, coreQuestion);
147
+ emittedText = finalText;
127
148
 
128
149
  Metrics.info('stream.done', {
129
150
  collectionKey: args.meta.collection.key,
130
151
  textLength: finalText.length,
152
+ reasoningLength: accumulatedReasoning.length,
131
153
  toolCount: tools.length,
132
154
  textEvents,
133
155
  toolEvents,
156
+ reasoningEvents,
134
157
  finishReason: event.finishReason
135
158
  });
136
159
 
137
160
  const done: BtcaStreamDoneEvent = {
138
161
  type: 'done',
139
162
  text: finalText,
140
- reasoning: '', // We don't have reasoning in the new format
163
+ reasoning: accumulatedReasoning,
141
164
  tools
142
165
  };
143
166
  emit(controller, done);
@@ -72,6 +72,19 @@ const okWithValue = <T>(value: T): ValidationResultWithValue<T> => ({ valid: tru
72
72
  const fail = (error: string): ValidationResult => ({ valid: false, error });
73
73
  const failWithValue = <T>(error: string): ValidationResultWithValue<T> => ({ valid: false, error });
74
74
  const parseUrl = (value: string) => Result.try(() => new URL(value));
75
+ const isWsl = () =>
76
+ process.platform === 'linux' &&
77
+ (Boolean(process.env.WSL_DISTRO_NAME) ||
78
+ Boolean(process.env.WSL_INTEROP) ||
79
+ Boolean(process.env.WSLENV));
80
+ const normalizeWslPath = (value: string) => {
81
+ if (!isWsl()) return value;
82
+ const match = value.match(/^([a-zA-Z]):\\(.*)$/);
83
+ if (!match) return value;
84
+ const drive = match[1]!.toLowerCase();
85
+ const rest = match[2]!.replace(/\\/g, '/');
86
+ return `/mnt/${drive}/${rest}`;
87
+ };
75
88
 
76
89
  // ─────────────────────────────────────────────────────────────────────────────
77
90
  // Validators
@@ -301,17 +314,19 @@ export const validateSearchPaths = (searchPaths: string[] | undefined): Validati
301
314
  * - Must be absolute path
302
315
  */
303
316
  export const validateLocalPath = (path: string): ValidationResult => {
304
- if (!path || path.trim().length === 0) {
317
+ const normalizedPath = normalizeWslPath(path);
318
+
319
+ if (!normalizedPath || normalizedPath.trim().length === 0) {
305
320
  return fail('Local path cannot be empty');
306
321
  }
307
322
 
308
323
  // Reject null bytes
309
- if (path.includes('\0')) {
324
+ if (normalizedPath.includes('\0')) {
310
325
  return fail('Path must not contain null bytes');
311
326
  }
312
327
 
313
328
  // Must be absolute path (starts with / on Unix or drive letter on Windows)
314
- if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:\\/)) {
329
+ if (!normalizedPath.startsWith('/') && !normalizedPath.match(/^[a-zA-Z]:\\/)) {
315
330
  return fail('Local path must be an absolute path');
316
331
  }
317
332
 
@@ -502,7 +517,8 @@ export const validateLocalResource = (resource: {
502
517
  const nameResult = validateResourceName(resource.name);
503
518
  if (!nameResult.valid) return failWithValue(nameResult.error);
504
519
 
505
- const pathResult = validateLocalPath(resource.path);
520
+ const normalizedPath = normalizeWslPath(resource.path);
521
+ const pathResult = validateLocalPath(normalizedPath);
506
522
  if (!pathResult.valid) return failWithValue(pathResult.error);
507
523
 
508
524
  const notesResult = validateNotes(resource.specialNotes);
@@ -510,7 +526,7 @@ export const validateLocalResource = (resource: {
510
526
 
511
527
  return okWithValue({
512
528
  name: resource.name,
513
- path: resource.path,
529
+ path: normalizedPath,
514
530
  ...(resource.specialNotes && { specialNotes: resource.specialNotes })
515
531
  });
516
532
  };