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 +26 -3
- package/package.json +1 -12
- package/src/agent/loop.ts +33 -4
- package/src/agent/service.ts +5 -6
- package/src/config/index.ts +8 -0
- package/src/index.ts +21 -1
- 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/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
|
|
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
|
|
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.
|
|
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:
|
|
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:
|
|
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',
|
package/src/agent/service.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { Result } from 'better-result';
|
|
12
12
|
|
|
13
13
|
import { Config } from '../config/index.ts';
|
|
14
|
-
import {
|
|
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(
|
|
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 {
|
package/src/config/index.ts
CHANGED
|
@@ -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:
|
|
146
|
+
path: LocalPathRequestSchema,
|
|
127
147
|
specialNotes: LocalResourceSchema.shape.specialNotes
|
|
128
148
|
});
|
|
129
149
|
|
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
|
+
}
|
|
@@ -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:
|
|
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
|
-
//
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
package/src/stream/service.ts
CHANGED
|
@@ -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
|
|
65
|
-
|
|
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
|
-
|
|
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:
|
|
163
|
+
reasoning: accumulatedReasoning,
|
|
141
164
|
tools
|
|
142
165
|
};
|
|
143
166
|
emit(controller, done);
|
package/src/validation/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
|
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:
|
|
529
|
+
path: normalizedPath,
|
|
514
530
|
...(resource.specialNotes && { specialNotes: resource.specialNotes })
|
|
515
531
|
});
|
|
516
532
|
};
|