btca-server 1.0.82 → 1.0.84

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": "btca-server",
3
- "version": "1.0.82",
3
+ "version": "1.0.84",
4
4
  "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
5
  "author": "Ben Davis",
6
6
  "license": "MIT",
package/src/agent/loop.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { streamText, tool, stepCountIs, type ModelMessage } from 'ai';
6
6
 
7
7
  import { Model } from '../providers/index.ts';
8
+ import type { ProviderOptions } from '../providers/registry.ts';
8
9
  import { ReadTool, GrepTool, GlobTool, ListTool } from '../tools/index.ts';
9
10
 
10
11
  export namespace AgentLoop {
@@ -30,6 +31,7 @@ export namespace AgentLoop {
30
31
  agentInstructions: string;
31
32
  question: string;
32
33
  maxSteps?: number;
34
+ providerOptions?: Partial<ProviderOptions>;
33
35
  };
34
36
 
35
37
  // Result type
@@ -135,10 +137,15 @@ export namespace AgentLoop {
135
137
  const systemPrompt = buildSystemPrompt(agentInstructions);
136
138
  const sessionId = crypto.randomUUID();
137
139
 
140
+ const mergedProviderOptions =
141
+ providerId === 'openai'
142
+ ? { ...options.providerOptions, instructions: systemPrompt, sessionId }
143
+ : options.providerOptions;
144
+
138
145
  // Get the model
139
146
  const model = await Model.getModel(providerId, modelId, {
140
- providerOptions:
141
- providerId === 'openai' ? { instructions: systemPrompt, sessionId } : undefined
147
+ providerOptions: mergedProviderOptions,
148
+ allowMissingAuth: providerId === 'openai-compat'
142
149
  });
143
150
 
144
151
  // Get initial context
@@ -244,10 +251,15 @@ export namespace AgentLoop {
244
251
  const systemPrompt = buildSystemPrompt(agentInstructions);
245
252
  const sessionId = crypto.randomUUID();
246
253
 
254
+ const mergedProviderOptions =
255
+ providerId === 'openai'
256
+ ? { ...options.providerOptions, instructions: systemPrompt, sessionId }
257
+ : options.providerOptions;
258
+
247
259
  // Get the model
248
260
  const model = await Model.getModel(providerId, modelId, {
249
- providerOptions:
250
- providerId === 'openai' ? { instructions: systemPrompt, sessionId } : undefined
261
+ providerOptions: mergedProviderOptions,
262
+ allowMissingAuth: providerId === 'openai-compat'
251
263
  });
252
264
 
253
265
  // Get initial context
@@ -127,7 +127,8 @@ export namespace Agent {
127
127
 
128
128
  // Validate provider is authenticated
129
129
  const isAuthed = await Auth.isAuthenticated(config.provider);
130
- if (!isAuthed && config.provider !== 'opencode') {
130
+ const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
131
+ if (!isAuthed && requiresAuth) {
131
132
  const authenticated = await Auth.getAuthenticatedProviders();
132
133
  cleanup();
133
134
  throw new ProviderNotConnectedError({
@@ -145,7 +146,8 @@ export namespace Agent {
145
146
  collectionPath: collection.path,
146
147
  vfsId: collection.vfsId,
147
148
  agentInstructions: collection.agentInstructions,
148
- question
149
+ question,
150
+ providerOptions: config.getProviderOptions(config.provider)
149
151
  });
150
152
  for await (const event of stream) {
151
153
  yield event;
@@ -179,7 +181,8 @@ export namespace Agent {
179
181
 
180
182
  // Validate provider is authenticated
181
183
  const isAuthed = await Auth.isAuthenticated(config.provider);
182
- if (!isAuthed && config.provider !== 'opencode') {
184
+ const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
185
+ if (!isAuthed && requiresAuth) {
183
186
  const authenticated = await Auth.getAuthenticatedProviders();
184
187
  cleanup();
185
188
  throw new ProviderNotConnectedError({
@@ -195,7 +198,8 @@ export namespace Agent {
195
198
  collectionPath: collection.path,
196
199
  vfsId: collection.vfsId,
197
200
  agentInstructions: collection.agentInstructions,
198
- question
201
+ question,
202
+ providerOptions: config.getProviderOptions(config.provider)
199
203
  })
200
204
  );
201
205
 
@@ -50,6 +50,13 @@ export const DEFAULT_RESOURCES: ResourceDefinition[] = [
50
50
  }
51
51
  ];
52
52
 
53
+ const ProviderOptionsSchema = z.object({
54
+ baseURL: z.string().optional(),
55
+ name: z.string().optional()
56
+ });
57
+
58
+ const ProviderOptionsMapSchema = z.record(z.string(), ProviderOptionsSchema);
59
+
53
60
  const StoredConfigSchema = z.object({
54
61
  $schema: z.string().optional(),
55
62
  dataDirectory: z.string().optional(),
@@ -57,10 +64,13 @@ const StoredConfigSchema = z.object({
57
64
  resources: z.array(ResourceDefinitionSchema),
58
65
  // Provider and model are optional - defaults are applied when loading
59
66
  model: z.string().optional(),
60
- provider: z.string().optional()
67
+ provider: z.string().optional(),
68
+ providerOptions: ProviderOptionsMapSchema.optional()
61
69
  });
62
70
 
63
71
  type StoredConfig = z.infer<typeof StoredConfigSchema>;
72
+ type ProviderOptionsConfig = z.infer<typeof ProviderOptionsSchema>;
73
+ type ProviderOptionsMap = z.infer<typeof ProviderOptionsMapSchema>;
64
74
 
65
75
  // Legacy config schemas (btca.json format from old CLI)
66
76
  // There are two legacy formats:
@@ -121,8 +131,13 @@ export namespace Config {
121
131
  provider: string;
122
132
  providerTimeoutMs?: number;
123
133
  configPath: string;
134
+ getProviderOptions: (providerId: string) => ProviderOptionsConfig | undefined;
124
135
  getResource: (name: string) => ResourceDefinition | undefined;
125
- updateModel: (provider: string, model: string) => Promise<{ provider: string; model: string }>;
136
+ updateModel: (
137
+ provider: string,
138
+ model: string,
139
+ providerOptions?: ProviderOptionsConfig
140
+ ) => Promise<{ provider: string; model: string }>;
126
141
  addResource: (resource: ResourceDefinition) => Promise<ResourceDefinition>;
127
142
  removeResource: (name: string) => Promise<void>;
128
143
  clearResources: () => Promise<{ cleared: number }>;
@@ -585,6 +600,28 @@ export namespace Config {
585
600
  return Array.from(resourceMap.values());
586
601
  };
587
602
 
603
+ const mergeProviderOptions = (
604
+ globalConfigValue: StoredConfig,
605
+ projectConfigValue: StoredConfig | null
606
+ ): ProviderOptionsMap => {
607
+ const merged: ProviderOptionsMap = {};
608
+ const globalOptions = globalConfigValue.providerOptions ?? {};
609
+ const projectOptions = projectConfigValue?.providerOptions ?? {};
610
+
611
+ for (const [providerId, options] of Object.entries(globalOptions)) {
612
+ merged[providerId] = { ...options };
613
+ }
614
+
615
+ for (const [providerId, options] of Object.entries(projectOptions)) {
616
+ merged[providerId] = { ...(merged[providerId] ?? {}), ...options };
617
+ }
618
+
619
+ return merged;
620
+ };
621
+
622
+ const getMergedProviderOptions = (): ProviderOptionsMap =>
623
+ mergeProviderOptions(currentGlobalConfig, currentProjectConfig);
624
+
588
625
  // Get the config that should be used for model/provider
589
626
  const getActiveConfig = (): StoredConfig => {
590
627
  return currentProjectConfig ?? currentGlobalConfig;
@@ -619,9 +656,14 @@ export namespace Config {
619
656
  get providerTimeoutMs() {
620
657
  return getActiveConfig().providerTimeoutMs;
621
658
  },
659
+ getProviderOptions: (providerId: string) => getMergedProviderOptions()[providerId],
622
660
  getResource: (name: string) => getMergedResources().find((r) => r.name === name),
623
661
 
624
- updateModel: async (provider: string, model: string) => {
662
+ updateModel: async (
663
+ provider: string,
664
+ model: string,
665
+ providerOptions?: ProviderOptionsConfig
666
+ ) => {
625
667
  if (!isProviderSupported(provider)) {
626
668
  const available = getSupportedProviders();
627
669
  throw new ConfigError({
@@ -630,7 +672,37 @@ export namespace Config {
630
672
  });
631
673
  }
632
674
  const mutableConfig = getMutableConfig();
633
- const updated = { ...mutableConfig, provider, model };
675
+ const existingProviderOptions = mutableConfig.providerOptions ?? {};
676
+ const nextProviderOptions = providerOptions
677
+ ? {
678
+ ...existingProviderOptions,
679
+ [provider]: {
680
+ ...(existingProviderOptions[provider] ?? {}),
681
+ ...providerOptions
682
+ }
683
+ }
684
+ : existingProviderOptions;
685
+ const updated = {
686
+ ...mutableConfig,
687
+ provider,
688
+ model,
689
+ ...(providerOptions ? { providerOptions: nextProviderOptions } : {})
690
+ };
691
+
692
+ if (provider === 'openai-compat') {
693
+ const merged = currentProjectConfig
694
+ ? mergeProviderOptions(currentGlobalConfig, updated)
695
+ : mergeProviderOptions(updated, null);
696
+ const compat = merged['openai-compat'];
697
+ const baseURL = compat?.baseURL?.trim();
698
+ const name = compat?.name?.trim();
699
+ if (!baseURL || !name) {
700
+ throw new ConfigError({
701
+ message: 'openai-compat requires baseURL and name',
702
+ hint: 'Run "btca connect -p openai-compat" to configure baseURL and name.'
703
+ });
704
+ }
705
+ }
634
706
  setMutableConfig(updated);
635
707
  await saveConfig(configPath, updated);
636
708
  Metrics.info('config.model.updated', { provider, model });
package/src/index.ts CHANGED
@@ -91,7 +91,13 @@ const UpdateModelRequestSchema = z.object({
91
91
  .string()
92
92
  .min(1, 'Model name cannot be empty')
93
93
  .max(LIMITS.MODEL_NAME_MAX)
94
- .regex(SAFE_NAME_REGEX, 'Invalid model name format')
94
+ .regex(SAFE_NAME_REGEX, 'Invalid model name format'),
95
+ providerOptions: z
96
+ .object({
97
+ baseURL: z.string().optional(),
98
+ name: z.string().optional()
99
+ })
100
+ .optional()
95
101
  });
96
102
 
97
103
  /**
@@ -212,7 +218,8 @@ const createApp = (deps: {
212
218
  tag === 'ConfigError' ||
213
219
  tag === 'InvalidProviderError' ||
214
220
  tag === 'InvalidModelError' ||
215
- tag === 'ProviderNotConnectedError'
221
+ tag === 'ProviderNotConnectedError' ||
222
+ tag === 'ProviderOptionsError'
216
223
  ? 400
217
224
  : 500;
218
225
  return c.json({ error: message, tag, ...(hint && { hint }) }, status);
@@ -372,7 +379,11 @@ const createApp = (deps: {
372
379
  // PUT /config/model - Update model configuration
373
380
  .put('/config/model', async (c: HonoContext) => {
374
381
  const decoded = await decodeJson(c.req.raw, UpdateModelRequestSchema);
375
- const result = await config.updateModel(decoded.provider, decoded.model);
382
+ const result = await config.updateModel(
383
+ decoded.provider,
384
+ decoded.model,
385
+ decoded.providerOptions
386
+ );
376
387
  return c.json(result);
377
388
  })
378
389
 
@@ -25,6 +25,7 @@ export namespace Auth {
25
25
  'github-copilot': ['oauth'],
26
26
  openrouter: ['api'],
27
27
  openai: ['oauth'],
28
+ 'openai-compat': ['api'],
28
29
  anthropic: ['api'],
29
30
  google: ['api', 'oauth']
30
31
  };
@@ -164,6 +165,8 @@ export namespace Auth {
164
165
  return 'Run "btca connect -p github-copilot" and complete device flow OAuth.';
165
166
  case 'openai':
166
167
  return 'Run "opencode auth --provider openai" and complete OAuth.';
168
+ case 'openai-compat':
169
+ return 'Set baseURL + name via "btca connect" and optionally add an API key.';
167
170
  case 'anthropic':
168
171
  return 'Run "opencode auth --provider anthropic" and enter an API key.';
169
172
  case 'google':
@@ -52,11 +52,25 @@ export namespace Model {
52
52
  }
53
53
  }
54
54
 
55
+ export class ProviderOptionsError extends Error {
56
+ readonly _tag = 'ProviderOptionsError';
57
+ readonly providerId: string;
58
+ readonly hint: string;
59
+
60
+ constructor(args: { providerId: string; message: string; hint: string }) {
61
+ super(args.message);
62
+ this.providerId = args.providerId;
63
+ this.hint = args.hint;
64
+ }
65
+ }
66
+
55
67
  export type ModelOptions = {
56
68
  /** Additional provider options */
57
69
  providerOptions?: Partial<ProviderOptions>;
58
70
  /** Skip authentication check (useful for providers with wellknown auth) */
59
71
  skipAuth?: boolean;
72
+ /** Allow missing auth for providers that can be used without credentials */
73
+ allowMissingAuth?: boolean;
60
74
  };
61
75
 
62
76
  /**
@@ -92,13 +106,17 @@ export namespace Model {
92
106
  if (!options.skipAuth) {
93
107
  const status = await Auth.getAuthStatus(normalizedProviderId);
94
108
  if (status.status === 'missing') {
95
- throw new ProviderNotAuthenticatedError(providerId);
109
+ if (!options.allowMissingAuth) {
110
+ throw new ProviderNotAuthenticatedError(providerId);
111
+ }
96
112
  }
97
113
  if (status.status === 'invalid') {
98
114
  throw new ProviderAuthTypeError({ providerId, authType: status.authType });
99
115
  }
100
- apiKey = status.apiKey;
101
- accountId = status.accountId;
116
+ if (status.status === 'ok') {
117
+ apiKey = status.apiKey;
118
+ accountId = status.accountId;
119
+ }
102
120
  }
103
121
 
104
122
  // Build provider options
@@ -111,6 +129,20 @@ export namespace Model {
111
129
  providerOptions.apiKey = apiKey;
112
130
  }
113
131
 
132
+ if (normalizedProviderId === 'openai-compat') {
133
+ const baseURL = providerOptions.baseURL?.trim();
134
+ const name = providerOptions.name?.trim();
135
+ if (!baseURL || !name) {
136
+ throw new ProviderOptionsError({
137
+ providerId: normalizedProviderId,
138
+ message: 'openai-compat requires baseURL and name',
139
+ hint: 'Run "btca connect -p openai-compat" to configure baseURL and name.'
140
+ });
141
+ }
142
+ providerOptions.baseURL = baseURL;
143
+ providerOptions.name = name;
144
+ }
145
+
114
146
  // Create the provider and get the model
115
147
  const provider = factory(providerOptions);
116
148
  const model = provider(modelId);
@@ -0,0 +1,24 @@
1
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
2
+
3
+ export const createOpenAICompat = (
4
+ options: {
5
+ apiKey?: string;
6
+ baseURL?: string;
7
+ name?: string;
8
+ } = {}
9
+ ) => {
10
+ const baseURL = options.baseURL?.trim();
11
+ const name = options.name?.trim();
12
+
13
+ if (!baseURL || !name) {
14
+ throw new Error('openai-compat requires baseURL and name');
15
+ }
16
+
17
+ const provider = createOpenAICompatible({
18
+ apiKey: options.apiKey,
19
+ baseURL,
20
+ name
21
+ });
22
+
23
+ return (modelId: string) => provider.chatModel(modelId);
24
+ };
@@ -8,6 +8,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
8
8
  import { createCopilotProvider } from './copilot.ts';
9
9
  import { createOpenCodeZen } from './opencode.ts';
10
10
  import { createOpenAICodex } from './openai.ts';
11
+ import { createOpenAICompat } from './openai-compat.ts';
11
12
  import { createOpenRouter } from './openrouter.ts';
12
13
 
13
14
  // Type for provider factory options
@@ -37,6 +38,8 @@ export const PROVIDER_REGISTRY: Record<string, ProviderFactory> = {
37
38
 
38
39
  // OpenAI
39
40
  openai: createOpenAICodex as ProviderFactory,
41
+ // OpenAI-compatible
42
+ 'openai-compat': createOpenAICompat as ProviderFactory,
40
43
  // GitHub Copilot
41
44
  'github-copilot': createCopilotProvider as ProviderFactory,
42
45
  // Google