btca-server 1.0.89 → 1.0.91

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
@@ -102,13 +102,14 @@ Clear all locally cloned resources.
102
102
  POST /question
103
103
  ```
104
104
 
105
- Ask a question (non-streaming response).
105
+ Ask a question (non-streaming response). The `resources` field accepts configured resource names or HTTPS Git URLs.
106
106
 
107
107
  ```
108
108
  POST /question/stream
109
109
  ```
110
110
 
111
- Ask a question with streaming SSE response.
111
+ Ask a question with streaming SSE response. The `resources` field accepts configured resource names or HTTPS Git URLs.
112
+ The final `done` SSE event may include optional usage/metrics (tokens, timing, throughput, and best-effort pricing).
112
113
 
113
114
  ### Model Configuration
114
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "btca-server",
3
- "version": "1.0.89",
3
+ "version": "1.0.91",
4
4
  "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
5
  "author": "Ben Davis",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "hono": "^4.7.11",
62
62
  "just-bash": "^2.7.0",
63
63
  "opencode-ai": "^1.1.36",
64
+ "vercel-minimax-ai-provider": "^0.0.2",
64
65
  "zod": "^3.25.76"
65
66
  }
66
67
  }
package/src/agent/loop.ts CHANGED
@@ -18,7 +18,12 @@ export namespace AgentLoop {
18
18
  | {
19
19
  type: 'finish';
20
20
  finishReason: string;
21
- usage?: { inputTokens?: number; outputTokens?: number };
21
+ usage?: {
22
+ inputTokens?: number;
23
+ outputTokens?: number;
24
+ reasoningTokens?: number;
25
+ totalTokens?: number;
26
+ };
22
27
  }
23
28
  | { type: 'error'; error: Error };
24
29
 
@@ -213,7 +218,11 @@ export namespace AgentLoop {
213
218
  finishReason: part.finishReason ?? 'unknown',
214
219
  usage: {
215
220
  inputTokens: part.totalUsage?.inputTokens,
216
- outputTokens: part.totalUsage?.outputTokens
221
+ outputTokens: part.totalUsage?.outputTokens,
222
+ reasoningTokens:
223
+ part.totalUsage?.outputTokenDetails?.reasoningTokens ??
224
+ part.totalUsage?.reasoningTokens,
225
+ totalTokens: part.totalUsage?.totalTokens
217
226
  }
218
227
  });
219
228
  break;
@@ -322,7 +331,11 @@ export namespace AgentLoop {
322
331
  finishReason: part.finishReason ?? 'unknown',
323
332
  usage: {
324
333
  inputTokens: part.totalUsage?.inputTokens,
325
- outputTokens: part.totalUsage?.outputTokens
334
+ outputTokens: part.totalUsage?.outputTokens,
335
+ reasoningTokens:
336
+ part.totalUsage?.outputTokenDetails?.reasoningTokens ??
337
+ part.totalUsage?.reasoningTokens,
338
+ totalTokens: part.totalUsage?.totalTokens
326
339
  }
327
340
  };
328
341
  break;
@@ -119,10 +119,16 @@ export namespace Agent {
119
119
  questionLength: question.length
120
120
  });
121
121
 
122
- const cleanup = () => {
123
- if (!collection.vfsId) return;
124
- VirtualFs.dispose(collection.vfsId);
125
- clearVirtualCollectionMetadata(collection.vfsId);
122
+ const cleanup = async () => {
123
+ if (collection.vfsId) {
124
+ VirtualFs.dispose(collection.vfsId);
125
+ clearVirtualCollectionMetadata(collection.vfsId);
126
+ }
127
+ try {
128
+ await collection.cleanup?.();
129
+ } catch {
130
+ // cleanup should never fail user-visible operations
131
+ }
126
132
  };
127
133
 
128
134
  // Validate provider is authenticated
@@ -130,7 +136,7 @@ export namespace Agent {
130
136
  const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
131
137
  if (!isAuthed && requiresAuth) {
132
138
  const authenticated = await Auth.getAuthenticatedProviders();
133
- cleanup();
139
+ await cleanup();
134
140
  throw new ProviderNotConnectedError({
135
141
  providerId: config.provider,
136
142
  connectedProviders: authenticated
@@ -153,7 +159,7 @@ export namespace Agent {
153
159
  yield event;
154
160
  }
155
161
  } finally {
156
- cleanup();
162
+ await cleanup();
157
163
  }
158
164
  })();
159
165
 
@@ -173,10 +179,16 @@ export namespace Agent {
173
179
  questionLength: question.length
174
180
  });
175
181
 
176
- const cleanup = () => {
177
- if (!collection.vfsId) return;
178
- VirtualFs.dispose(collection.vfsId);
179
- clearVirtualCollectionMetadata(collection.vfsId);
182
+ const cleanup = async () => {
183
+ if (collection.vfsId) {
184
+ VirtualFs.dispose(collection.vfsId);
185
+ clearVirtualCollectionMetadata(collection.vfsId);
186
+ }
187
+ try {
188
+ await collection.cleanup?.();
189
+ } catch {
190
+ // cleanup should never fail user-visible operations
191
+ }
180
192
  };
181
193
 
182
194
  // Validate provider is authenticated
@@ -184,7 +196,7 @@ export namespace Agent {
184
196
  const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
185
197
  if (!isAuthed && requiresAuth) {
186
198
  const authenticated = await Auth.getAuthenticatedProviders();
187
- cleanup();
199
+ await cleanup();
188
200
  throw new ProviderNotConnectedError({
189
201
  providerId: config.provider,
190
202
  connectedProviders: authenticated
@@ -203,7 +215,7 @@ export namespace Agent {
203
215
  })
204
216
  );
205
217
 
206
- cleanup();
218
+ await cleanup();
207
219
 
208
220
  return runResult.match({
209
221
  ok: (result) => {
@@ -135,13 +135,37 @@ export namespace Collections {
135
135
  });
136
136
  };
137
137
 
138
+ const getGitHeadBranch = async (resourcePath: string) => {
139
+ const result = await Result.tryPromise(async () => {
140
+ const proc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
141
+ cwd: resourcePath,
142
+ stdout: 'pipe',
143
+ stderr: 'pipe'
144
+ });
145
+ const stdout = await new Response(proc.stdout).text();
146
+ const exitCode = await proc.exited;
147
+ if (exitCode !== 0) return undefined;
148
+ const trimmed = stdout.trim();
149
+ if (!trimmed || trimmed === 'HEAD') return undefined;
150
+ return trimmed;
151
+ });
152
+
153
+ return result.match({
154
+ ok: (value) => value,
155
+ err: () => undefined
156
+ });
157
+ };
158
+
159
+ const ANON_PREFIX = 'anonymous:';
160
+ const getAnonymousUrlFromName = (name: string) =>
161
+ name.startsWith(ANON_PREFIX) ? name.slice(ANON_PREFIX.length) : undefined;
162
+
138
163
  const buildVirtualMetadata = async (args: {
139
164
  resource: BtcaFsResource;
140
165
  resourcePath: string;
141
166
  loadedAt: string;
142
167
  definition?: ReturnType<Config.Service['getResource']>;
143
168
  }) => {
144
- if (!args.definition) return null;
145
169
  const base = {
146
170
  name: args.resource.name,
147
171
  fsName: args.resource.fsName,
@@ -150,13 +174,19 @@ export namespace Collections {
150
174
  repoSubPaths: args.resource.repoSubPaths,
151
175
  loadedAt: args.loadedAt
152
176
  };
153
- if (!isGitResource(args.definition)) return base;
177
+ if (args.resource.type !== 'git') return base;
178
+
179
+ const configuredDefinition =
180
+ args.definition && isGitResource(args.definition) ? args.definition : null;
181
+ const url = configuredDefinition?.url ?? getAnonymousUrlFromName(args.resource.name);
182
+ const branch = configuredDefinition?.branch ?? (await getGitHeadBranch(args.resourcePath));
154
183
  const commit = await getGitHeadHash(args.resourcePath);
184
+
155
185
  return {
156
186
  ...base,
157
- url: args.definition.url,
158
- branch: args.definition.branch,
159
- commit
187
+ ...(url ? { url } : {}),
188
+ ...(branch ? { branch } : {}),
189
+ ...(commit ? { commit } : {})
160
190
  };
161
191
  };
162
192
 
@@ -184,11 +214,18 @@ export namespace Collections {
184
214
  VirtualFs.dispose(vfsId);
185
215
  clearVirtualCollectionMetadata(vfsId);
186
216
  };
217
+ const cleanupResources = (resources: BtcaFsResource[]) =>
218
+ Promise.all(
219
+ resources.map(async (resource) => {
220
+ if (!resource.cleanup) return;
221
+ await ignoreErrors(() => resource.cleanup!());
222
+ })
223
+ );
187
224
 
225
+ const loadedResources: BtcaFsResource[] = [];
188
226
  const result = await Result.gen(async function* () {
189
227
  yield* Result.await(initVirtualRoot(collectionPath, vfsId));
190
228
 
191
- const loadedResources: BtcaFsResource[] = [];
192
229
  for (const name of sortedNames) {
193
230
  const resource = yield* Result.await(loadResource(args.resources, name, quiet));
194
231
  loadedResources.push(resource);
@@ -235,17 +272,19 @@ export namespace Collections {
235
272
  return Result.ok({
236
273
  path: collectionPath,
237
274
  agentInstructions: instructionBlocks.join('\n\n'),
238
- vfsId
275
+ vfsId,
276
+ cleanup: async () => {
277
+ await cleanupResources(loadedResources);
278
+ }
239
279
  });
240
280
  });
241
281
 
242
- return result.match({
243
- ok: (value) => value,
244
- err: (error) => {
245
- cleanupVirtual();
246
- throw error;
247
- }
248
- });
282
+ if (!Result.isOk(result)) {
283
+ cleanupVirtual();
284
+ await cleanupResources(loadedResources);
285
+ throw result.error;
286
+ }
287
+ return result.value;
249
288
  })
250
289
  };
251
290
  };
@@ -5,6 +5,7 @@ export type CollectionResult = {
5
5
  path: string;
6
6
  agentInstructions: string;
7
7
  vfsId?: string;
8
+ cleanup?: () => Promise<void>;
8
9
  };
9
10
 
10
11
  export class CollectionError extends Error {
@@ -443,15 +443,17 @@ describe('Config', () => {
443
443
  const config = await Config.load();
444
444
 
445
445
  // Update the model
446
- await config.updateModel('new-provider', 'new-model');
446
+ const nextProvider = 'openrouter';
447
+ const nextModel = 'openai/gpt-4o-mini';
448
+ await config.updateModel(nextProvider, nextModel);
447
449
 
448
- expect(config.provider).toBe('new-provider');
449
- expect(config.model).toBe('new-model');
450
+ expect(config.provider).toBe(nextProvider);
451
+ expect(config.model).toBe(nextModel);
450
452
 
451
453
  // CRITICAL: Verify project config was updated
452
454
  const savedProjectConfig = JSON.parse(await fs.readFile(projectConfigPath, 'utf-8'));
453
- expect(savedProjectConfig.provider).toBe('new-provider');
454
- expect(savedProjectConfig.model).toBe('new-model');
455
+ expect(savedProjectConfig.provider).toBe(nextProvider);
456
+ expect(savedProjectConfig.model).toBe(nextModel);
455
457
  // Global resources should NOT have leaked into project config
456
458
  expect(savedProjectConfig.resources.length).toBe(0);
457
459
 
@@ -464,12 +464,8 @@ export namespace Config {
464
464
  return Result.ok(migrated);
465
465
  });
466
466
 
467
- const saved = result.match({
468
- ok: (value) => value,
469
- err: (error) => {
470
- throw error;
471
- }
472
- });
467
+ if (!Result.isOk(result)) throw result.error;
468
+ const saved = result.value;
473
469
 
474
470
  Metrics.info('config.legacy.migrated', {
475
471
  newPath: newConfigPath,
@@ -498,12 +494,8 @@ export namespace Config {
498
494
  return Result.ok(stored);
499
495
  });
500
496
 
501
- return result.match({
502
- ok: (value) => value,
503
- err: (error) => {
504
- throw error;
505
- }
506
- });
497
+ if (!Result.isOk(result)) throw result.error;
498
+ return result.value;
507
499
  };
508
500
 
509
501
  const createDefaultConfig = async (configPath: string): Promise<StoredConfig> => {
@@ -540,12 +532,8 @@ export namespace Config {
540
532
  return Result.ok(defaultStored);
541
533
  });
542
534
 
543
- return result.match({
544
- ok: (value) => value,
545
- err: (error) => {
546
- throw error;
547
- }
548
- });
535
+ if (!Result.isOk(result)) throw result.error;
536
+ return result.value;
549
537
  };
550
538
 
551
539
  const saveConfig = async (configPath: string, stored: StoredConfig): Promise<void> => {
@@ -555,12 +543,7 @@ export namespace Config {
555
543
  `Failed to save config to: "${configPath}"`,
556
544
  'Check that you have write permissions and the disk is not full.'
557
545
  );
558
- result.match({
559
- ok: () => undefined,
560
- err: (error) => {
561
- throw error;
562
- }
563
- });
546
+ if (!Result.isOk(result)) throw result.error;
564
547
  };
565
548
 
566
549
  /**
@@ -308,12 +308,8 @@ export namespace RemoteConfigService {
308
308
  return Result.ok(undefined);
309
309
  });
310
310
 
311
- result.match({
312
- ok: () => Metrics.info('remote.auth.saved', { path: authPath }),
313
- err: (error) => {
314
- throw error;
315
- }
316
- });
311
+ if (!Result.isOk(result)) throw result.error;
312
+ Metrics.info('remote.auth.saved', { path: authPath });
317
313
  }
318
314
 
319
315
  /**
@@ -394,16 +390,11 @@ export namespace RemoteConfigService {
394
390
  })
395
391
  });
396
392
 
397
- result.match({
398
- ok: () =>
399
- Metrics.info('remote.config.saved', {
400
- path: configPath,
401
- project: config.project,
402
- resourceCount: config.resources.length
403
- }),
404
- err: (error) => {
405
- throw error;
406
- }
393
+ if (!Result.isOk(result)) throw result.error;
394
+ Metrics.info('remote.config.saved', {
395
+ path: configPath,
396
+ project: config.project,
397
+ resourceCount: config.resources.length
407
398
  });
408
399
  }
409
400
 
package/src/errors.ts CHANGED
@@ -61,7 +61,7 @@ export const CommonHints = {
61
61
  CHECK_NETWORK: 'Check your internet connection and try again.',
62
62
  CHECK_URL: 'Verify the URL is correct and the repository exists.',
63
63
  CHECK_BRANCH:
64
- 'Verify the branch name exists in the repository. Common branches are "main", "master", or "dev".',
64
+ 'Verify the branch name exists in the repository. Common branches are "main", "master", "trunk", or "dev".',
65
65
  CHECK_CONFIG: 'Check your btca config file for errors.',
66
66
  CHECK_PERMISSIONS:
67
67
  'Ensure you have access to the repository. Private repos require authentication.',
package/src/index.ts CHANGED
@@ -10,11 +10,17 @@ import { Config } from './config/index.ts';
10
10
  import { Context } from './context/index.ts';
11
11
  import { getErrorMessage, getErrorTag, getErrorHint } from './errors.ts';
12
12
  import { Metrics } from './metrics/index.ts';
13
+ import { ModelsDevPricing } from './pricing/models-dev.ts';
13
14
  import { Resources } from './resources/service.ts';
14
15
  import { GitResourceSchema, LocalResourceSchema } from './resources/schema.ts';
15
16
  import { StreamService } from './stream/service.ts';
16
17
  import type { BtcaStreamMetaEvent } from './stream/types.ts';
17
- import { LIMITS, normalizeGitHubUrl } from './validation/index.ts';
18
+ import {
19
+ LIMITS,
20
+ normalizeGitHubUrl,
21
+ validateGitUrl,
22
+ validateResourceReference
23
+ } from './validation/index.ts';
18
24
  import { clearAllVirtualCollectionMetadata } from './collections/virtual-metadata.ts';
19
25
  import { VirtualFs } from './vfs/virtual-fs.ts';
20
26
 
@@ -37,6 +43,8 @@ import { VirtualFs } from './vfs/virtual-fs.ts';
37
43
  const DEFAULT_PORT = 8080;
38
44
  const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
39
45
 
46
+ const modelsDevPricing = ModelsDevPricing.create();
47
+
40
48
  // ─────────────────────────────────────────────────────────────────────────────
41
49
  // Request Schemas
42
50
  // ─────────────────────────────────────────────────────────────────────────────
@@ -63,6 +71,22 @@ const ResourceNameField = z
63
71
  .refine((name) => !name.includes('//'), 'Resource name must not contain "//"')
64
72
  .refine((name) => !name.endsWith('/'), 'Resource name must not end with "/"');
65
73
 
74
+ const ResourceReferenceField = z.string().superRefine((value, ctx) => {
75
+ const result = validateResourceReference(value);
76
+ if (!result.valid) {
77
+ ctx.addIssue({
78
+ code: 'custom',
79
+ message: result.error
80
+ });
81
+ }
82
+ });
83
+
84
+ const normalizeQuestionResourceReference = (reference: string): string => {
85
+ const gitUrlResult = validateGitUrl(reference);
86
+ if (gitUrlResult.valid) return gitUrlResult.value;
87
+ return reference;
88
+ };
89
+
66
90
  const QuestionRequestSchema = z.object({
67
91
  question: z
68
92
  .string()
@@ -72,7 +96,7 @@ const QuestionRequestSchema = z.object({
72
96
  `Question too long (max ${LIMITS.QUESTION_MAX.toLocaleString()} chars). This includes conversation history - try starting a new thread or clearing the chat.`
73
97
  ),
74
98
  resources: z
75
- .array(ResourceNameField)
99
+ .array(ResourceReferenceField)
76
100
  .max(
77
101
  LIMITS.MAX_RESOURCES_PER_REQUEST,
78
102
  `Too many resources (max ${LIMITS.MAX_RESOURCES_PER_REQUEST})`
@@ -295,7 +319,7 @@ const createApp = (deps: {
295
319
  const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
296
320
  const resourceNames =
297
321
  decoded.resources && decoded.resources.length > 0
298
- ? decoded.resources
322
+ ? Array.from(new Set(decoded.resources.map(normalizeQuestionResourceReference)))
299
323
  : config.resources.map((r) => r.name);
300
324
 
301
325
  const collectionKey = getCollectionKey(resourceNames);
@@ -327,10 +351,11 @@ const createApp = (deps: {
327
351
 
328
352
  // POST /question/stream
329
353
  .post('/question/stream', async (c: HonoContext) => {
354
+ const requestStartMs = performance.now();
330
355
  const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
331
356
  const resourceNames =
332
357
  decoded.resources && decoded.resources.length > 0
333
- ? decoded.resources
358
+ ? Array.from(new Set(decoded.resources.map(normalizeQuestionResourceReference)))
334
359
  : config.resources.map((r) => r.name);
335
360
 
336
361
  const collectionKey = getCollectionKey(resourceNames);
@@ -361,10 +386,13 @@ const createApp = (deps: {
361
386
  } satisfies BtcaStreamMetaEvent;
362
387
 
363
388
  Metrics.info('question.stream.start', { collectionKey });
389
+ modelsDevPricing.prefetch();
364
390
  const stream = StreamService.createSseStream({
365
391
  meta,
366
392
  eventStream,
367
- question: decoded.question
393
+ question: decoded.question,
394
+ requestStartMs,
395
+ pricing: modelsDevPricing
368
396
  });
369
397
 
370
398
  return new Response(stream, {
@@ -46,20 +46,12 @@ export namespace Metrics {
46
46
  ): Promise<T> => {
47
47
  const start = performance.now();
48
48
  const result = await Result.tryPromise(fn);
49
- return result.match({
50
- ok: (value) => {
51
- info('span.ok', { name, ms: Math.round(performance.now() - start), ...fields });
52
- return value;
53
- },
54
- err: (cause) => {
55
- error('span.err', {
56
- name,
57
- ms: Math.round(performance.now() - start),
58
- ...fields,
59
- error: errorInfo(cause)
60
- });
61
- throw cause;
62
- }
63
- });
49
+ const ms = Math.round(performance.now() - start);
50
+ if (!Result.isOk(result)) {
51
+ error('span.err', { name, ms, ...fields, error: errorInfo(result.error) });
52
+ throw result.error;
53
+ }
54
+ info('span.ok', { name, ms, ...fields });
55
+ return result.value;
64
56
  };
65
57
  }