wraptc 1.0.2 → 1.0.4
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/bin/wraptc +4 -4
- package/package.json +2 -2
- package/src/cli/__tests__/cli.test.ts +337 -0
- package/src/cli/index.ts +149 -0
- package/src/core/__tests__/fixtures/configs/project-config.json +14 -0
- package/src/core/__tests__/fixtures/configs/system-config.json +14 -0
- package/src/core/__tests__/fixtures/configs/user-config.json +15 -0
- package/src/core/__tests__/integration/integration.test.ts +241 -0
- package/src/core/__tests__/integration/mock-coder-adapter.test.ts +243 -0
- package/src/core/__tests__/test-utils.ts +136 -0
- package/src/core/__tests__/unit/adapters/runner.test.ts +302 -0
- package/src/core/__tests__/unit/basic-test.test.ts +44 -0
- package/src/core/__tests__/unit/basic.test.ts +12 -0
- package/src/core/__tests__/unit/config.test.ts +244 -0
- package/src/core/__tests__/unit/error-patterns.test.ts +181 -0
- package/src/core/__tests__/unit/memory-monitor.test.ts +354 -0
- package/src/core/__tests__/unit/plugin/registry.test.ts +356 -0
- package/src/core/__tests__/unit/providers/codex.test.ts +173 -0
- package/src/core/__tests__/unit/providers/configurable.test.ts +429 -0
- package/src/core/__tests__/unit/providers/gemini.test.ts +251 -0
- package/src/core/__tests__/unit/providers/opencode.test.ts +258 -0
- package/src/core/__tests__/unit/providers/qwen-code.test.ts +195 -0
- package/src/core/__tests__/unit/providers/simple-codex.test.ts +18 -0
- package/src/core/__tests__/unit/router.test.ts +967 -0
- package/src/core/__tests__/unit/state.test.ts +1079 -0
- package/src/core/__tests__/unit/unified/capabilities.test.ts +186 -0
- package/src/core/__tests__/unit/wrap-terminalcoder.test.ts +32 -0
- package/src/core/adapters/builtin/codex.ts +35 -0
- package/src/core/adapters/builtin/gemini.ts +34 -0
- package/src/core/adapters/builtin/index.ts +31 -0
- package/src/core/adapters/builtin/mock-coder.ts +148 -0
- package/src/core/adapters/builtin/qwen.ts +34 -0
- package/src/core/adapters/define.ts +48 -0
- package/src/core/adapters/index.ts +43 -0
- package/src/core/adapters/loader.ts +143 -0
- package/src/core/adapters/provider-bridge.ts +190 -0
- package/src/core/adapters/runner.ts +437 -0
- package/src/core/adapters/types.ts +172 -0
- package/src/core/config.ts +290 -0
- package/src/core/define-provider.ts +212 -0
- package/src/core/error-patterns.ts +147 -0
- package/src/core/index.ts +130 -0
- package/src/core/memory-monitor.ts +171 -0
- package/src/core/plugin/builtin.ts +87 -0
- package/src/core/plugin/index.ts +34 -0
- package/src/core/plugin/registry.ts +350 -0
- package/src/core/plugin/types.ts +209 -0
- package/src/core/provider-factory.ts +397 -0
- package/src/core/provider-loader.ts +171 -0
- package/src/core/providers/codex.ts +56 -0
- package/src/core/providers/configurable.ts +637 -0
- package/src/core/providers/custom.ts +261 -0
- package/src/core/providers/gemini.ts +41 -0
- package/src/core/providers/index.ts +383 -0
- package/src/core/providers/opencode.ts +168 -0
- package/src/core/providers/qwen-code.ts +41 -0
- package/src/core/router.ts +370 -0
- package/src/core/state.ts +258 -0
- package/src/core/types.ts +206 -0
- package/src/core/unified/capabilities.ts +184 -0
- package/src/core/unified/errors.ts +141 -0
- package/src/core/unified/index.ts +29 -0
- package/src/core/unified/output.ts +189 -0
- package/src/core/wrap-terminalcoder.ts +245 -0
- package/src/mcp/__tests__/server.test.ts +295 -0
- package/src/mcp/server.ts +284 -0
- package/src/test-fixtures/mock-coder.sh +194 -0
- package/dist/cli/index.js +0 -16501
- package/dist/core/index.js +0 -7531
- package/dist/mcp/server.js +0 -14568
- package/dist/wraptc-1.0.2.tgz +0 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConfigurableProvider - Provider implementation driven by ProviderDefinition
|
|
3
|
+
*
|
|
4
|
+
* This is the core implementation that enables config-only providers.
|
|
5
|
+
* Users can add new CLI tools by creating a .ts file with defineProvider().
|
|
6
|
+
*
|
|
7
|
+
* Phase 2 features:
|
|
8
|
+
* - Environment variables with ${VAR} interpolation
|
|
9
|
+
* - Timeout configuration with AbortController
|
|
10
|
+
* - System prompt support (flag or combined method)
|
|
11
|
+
* - Request parameters (maxTokens, temperature, language)
|
|
12
|
+
* - Subcommand support
|
|
13
|
+
* - Non-zero exit code handling (allowedExitCodes)
|
|
14
|
+
* - Retry logic with exponential backoff
|
|
15
|
+
* - Default working directory
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DEFAULT_ERROR_PATTERNS, type ProviderDefinition } from "../define-provider";
|
|
19
|
+
import type {
|
|
20
|
+
CodingEvent,
|
|
21
|
+
CodingRequest,
|
|
22
|
+
ProviderErrorContext,
|
|
23
|
+
ProviderErrorKind,
|
|
24
|
+
ProviderInfo,
|
|
25
|
+
TokenUsage,
|
|
26
|
+
} from "../types";
|
|
27
|
+
import type { Provider, ProviderInvokeOptions } from "./index";
|
|
28
|
+
|
|
29
|
+
// Default timeout: 60 seconds
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
export class ConfigurableProvider implements Provider {
|
|
33
|
+
public readonly id: string;
|
|
34
|
+
public readonly displayName: string;
|
|
35
|
+
public readonly supportsStreaming: boolean;
|
|
36
|
+
public readonly prefersJson: boolean;
|
|
37
|
+
public readonly capabilities?: string[];
|
|
38
|
+
|
|
39
|
+
private readonly definition: ProviderDefinition;
|
|
40
|
+
private readonly maxOutputSize: number = 100 * 1024 * 1024; // 100MB
|
|
41
|
+
|
|
42
|
+
constructor(definition: ProviderDefinition) {
|
|
43
|
+
this.definition = definition;
|
|
44
|
+
this.id = definition.id;
|
|
45
|
+
this.displayName = definition.displayName || definition.id;
|
|
46
|
+
this.supportsStreaming = definition.streaming.mode !== "none";
|
|
47
|
+
this.prefersJson = definition.output.format !== "text";
|
|
48
|
+
this.capabilities = definition.capabilities;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Interpolate environment variables in a value.
|
|
53
|
+
* Supports ${VAR} syntax, falling back to process.env.
|
|
54
|
+
*/
|
|
55
|
+
private interpolateEnvValue(value: string): string {
|
|
56
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
57
|
+
return process.env[varName] || "";
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build environment variables object.
|
|
63
|
+
* Merges process.env + provider defaults + invocation overrides.
|
|
64
|
+
* Interpolates ${VAR} syntax in provider-defined values.
|
|
65
|
+
*/
|
|
66
|
+
private buildEnv(opts?: ProviderInvokeOptions): Record<string, string> {
|
|
67
|
+
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
|
68
|
+
|
|
69
|
+
// Add provider-defined environment variables with interpolation
|
|
70
|
+
if (this.definition.env) {
|
|
71
|
+
for (const [key, value] of Object.entries(this.definition.env)) {
|
|
72
|
+
env[key] = this.interpolateEnvValue(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Override with invocation-specific environment variables
|
|
77
|
+
if (opts?.env) {
|
|
78
|
+
for (const [key, value] of Object.entries(opts.env)) {
|
|
79
|
+
env[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return env;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the effective prompt, handling system prompt combination if needed.
|
|
88
|
+
*/
|
|
89
|
+
private getEffectivePrompt(req: CodingRequest): string {
|
|
90
|
+
// If system prompt should be combined with the main prompt
|
|
91
|
+
if (req.systemPrompt && this.definition.args.systemPromptMethod === "combined") {
|
|
92
|
+
return `${req.systemPrompt}\n\n${req.prompt}`;
|
|
93
|
+
}
|
|
94
|
+
return req.prompt;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build command line arguments based on config
|
|
99
|
+
*/
|
|
100
|
+
protected buildArgs(req: CodingRequest): string[] {
|
|
101
|
+
// Use custom buildArgs if provided
|
|
102
|
+
if (this.definition.buildArgs) {
|
|
103
|
+
return this.definition.buildArgs(req);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const args: string[] = [];
|
|
107
|
+
const config = this.definition;
|
|
108
|
+
|
|
109
|
+
// Add subcommand first if configured (e.g., "exec", "run", "chat")
|
|
110
|
+
if (config.subcommand) {
|
|
111
|
+
args.push(config.subcommand);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Add base arguments
|
|
115
|
+
args.push(...config.args.base);
|
|
116
|
+
|
|
117
|
+
// Add JSON flag if configured
|
|
118
|
+
if (config.args.jsonFlag && config.output.format === "json") {
|
|
119
|
+
// Handle flags like "-o json" (split on space)
|
|
120
|
+
const parts = config.args.jsonFlag.split(" ");
|
|
121
|
+
args.push(...parts);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Add model flag if configured and model provided
|
|
125
|
+
if (config.args.modelFlag && req.model) {
|
|
126
|
+
args.push(config.args.modelFlag, req.model);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Add system prompt flag if configured and system prompt provided
|
|
130
|
+
if (
|
|
131
|
+
config.args.systemPromptFlag &&
|
|
132
|
+
req.systemPrompt &&
|
|
133
|
+
config.args.systemPromptMethod !== "combined"
|
|
134
|
+
) {
|
|
135
|
+
args.push(config.args.systemPromptFlag, req.systemPrompt);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add request parameter flags
|
|
139
|
+
if (config.args.maxTokensFlag && req.maxTokens !== undefined) {
|
|
140
|
+
args.push(config.args.maxTokensFlag, String(req.maxTokens));
|
|
141
|
+
}
|
|
142
|
+
if (config.args.temperatureFlag && req.temperature !== undefined) {
|
|
143
|
+
args.push(config.args.temperatureFlag, String(req.temperature));
|
|
144
|
+
}
|
|
145
|
+
if (config.args.languageFlag && req.language) {
|
|
146
|
+
args.push(config.args.languageFlag, req.language);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Add file flags if configured
|
|
150
|
+
if (config.args.fileFlag && req.files?.length) {
|
|
151
|
+
for (const file of req.files) {
|
|
152
|
+
const path = typeof file === "string" ? file : file.path;
|
|
153
|
+
args.push(config.args.fileFlag, path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Get effective prompt (may include combined system prompt)
|
|
158
|
+
const effectivePrompt = this.getEffectivePrompt(req);
|
|
159
|
+
|
|
160
|
+
// Add prompt based on input method
|
|
161
|
+
if (config.input.method === "positional") {
|
|
162
|
+
if (config.input.position === "first") {
|
|
163
|
+
// Insert after subcommand if present
|
|
164
|
+
const insertIndex = config.subcommand ? 1 : 0;
|
|
165
|
+
args.splice(insertIndex, 0, effectivePrompt);
|
|
166
|
+
} else {
|
|
167
|
+
args.push(effectivePrompt);
|
|
168
|
+
}
|
|
169
|
+
} else if (config.input.method === "flag" && config.input.flag) {
|
|
170
|
+
args.push(config.input.flag, effectivePrompt);
|
|
171
|
+
}
|
|
172
|
+
// stdin method: prompt is piped, not added to args
|
|
173
|
+
|
|
174
|
+
return args;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get stdin input based on config
|
|
179
|
+
*/
|
|
180
|
+
protected getStdinInput(req: CodingRequest): string | undefined {
|
|
181
|
+
// Use custom getStdinInput if provided
|
|
182
|
+
if (this.definition.getStdinInput) {
|
|
183
|
+
return this.definition.getStdinInput(req);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Only pipe to stdin if method is stdin
|
|
187
|
+
if (this.definition.input.method === "stdin") {
|
|
188
|
+
// Use effective prompt which may include combined system prompt
|
|
189
|
+
return this.getEffectivePrompt(req);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if an exit code is considered successful
|
|
197
|
+
*/
|
|
198
|
+
private isSuccessExitCode(exitCode: number | null): boolean {
|
|
199
|
+
if (exitCode === null) return false;
|
|
200
|
+
const allowedCodes = this.definition.allowedExitCodes || [0];
|
|
201
|
+
return allowedCodes.includes(exitCode);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get the effective timeout in milliseconds
|
|
206
|
+
*/
|
|
207
|
+
private getTimeoutMs(opts?: ProviderInvokeOptions): number {
|
|
208
|
+
return opts?.timeoutMs ?? this.definition.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get the effective working directory
|
|
213
|
+
*/
|
|
214
|
+
private getCwd(opts?: ProviderInvokeOptions): string | undefined {
|
|
215
|
+
return opts?.cwd ?? this.definition.defaultCwd;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse output based on config
|
|
220
|
+
*/
|
|
221
|
+
protected parseOutput(stdout: string): { text: string; usage?: TokenUsage } {
|
|
222
|
+
// Use custom parseOutput if provided
|
|
223
|
+
if (this.definition.parseOutput) {
|
|
224
|
+
return this.definition.parseOutput(stdout);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const config = this.definition.output;
|
|
228
|
+
|
|
229
|
+
if (config.format === "text") {
|
|
230
|
+
return { text: stdout.trim() };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(stdout);
|
|
235
|
+
const text = config.textField
|
|
236
|
+
? this.getNestedField(parsed, config.textField)
|
|
237
|
+
: parsed.text || parsed.response || parsed.output || stdout;
|
|
238
|
+
const usage = config.usageField
|
|
239
|
+
? this.getNestedField(parsed, config.usageField)
|
|
240
|
+
: parsed.usage;
|
|
241
|
+
|
|
242
|
+
return { text: String(text), usage };
|
|
243
|
+
} catch {
|
|
244
|
+
// Fall back to raw output if JSON parsing fails
|
|
245
|
+
return { text: stdout.trim() };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get a nested field from an object using dot notation
|
|
251
|
+
*/
|
|
252
|
+
private getNestedField(obj: unknown, path: string): unknown {
|
|
253
|
+
const parts = path.split(".");
|
|
254
|
+
let current: unknown = obj;
|
|
255
|
+
|
|
256
|
+
for (const part of parts) {
|
|
257
|
+
if (current === null || current === undefined) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
current = (current as Record<string, unknown>)[part];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return current;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Classify an error based on patterns
|
|
268
|
+
*/
|
|
269
|
+
classifyError(error: ProviderErrorContext): ProviderErrorKind {
|
|
270
|
+
// Use custom classifyError if provided
|
|
271
|
+
if (this.definition.classifyError) {
|
|
272
|
+
return this.definition.classifyError(error);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const combined = ((error.stderr || "") + (error.stdout || "")).toLowerCase();
|
|
276
|
+
|
|
277
|
+
// Check provider-specific patterns first
|
|
278
|
+
const providerPatterns = this.definition.errors || {};
|
|
279
|
+
for (const [kind, patterns] of Object.entries(providerPatterns)) {
|
|
280
|
+
if (patterns.some((p) => combined.includes(p.toLowerCase()))) {
|
|
281
|
+
return kind as ProviderErrorKind;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Then check default patterns
|
|
286
|
+
for (const [kind, patterns] of Object.entries(DEFAULT_ERROR_PATTERNS)) {
|
|
287
|
+
if (patterns.some((p) => combined.includes(p.toLowerCase()))) {
|
|
288
|
+
return kind as ProviderErrorKind;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return "UNKNOWN";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Execute a single request (internal implementation without retry)
|
|
297
|
+
*/
|
|
298
|
+
private async executeOnce(
|
|
299
|
+
req: CodingRequest,
|
|
300
|
+
opts: ProviderInvokeOptions,
|
|
301
|
+
): Promise<{ text: string; usage?: TokenUsage }> {
|
|
302
|
+
const args = this.buildArgs(req);
|
|
303
|
+
const stdin = this.getStdinInput(req);
|
|
304
|
+
const env = this.buildEnv(opts);
|
|
305
|
+
const cwd = this.getCwd(opts);
|
|
306
|
+
const timeoutMs = this.getTimeoutMs(opts);
|
|
307
|
+
|
|
308
|
+
// Create AbortController for timeout
|
|
309
|
+
const timeoutController = new AbortController();
|
|
310
|
+
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
311
|
+
|
|
312
|
+
// Combine with user-provided signal if any
|
|
313
|
+
const combinedSignal = opts?.signal
|
|
314
|
+
? AbortSignal.any([opts.signal, timeoutController.signal])
|
|
315
|
+
: timeoutController.signal;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const proc = Bun.spawn([this.definition.binary, ...args], {
|
|
319
|
+
cwd,
|
|
320
|
+
env,
|
|
321
|
+
stdin: stdin ? "pipe" : "ignore",
|
|
322
|
+
stdout: "pipe",
|
|
323
|
+
stderr: "pipe",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Write to stdin if needed
|
|
327
|
+
if (stdin && proc.stdin) {
|
|
328
|
+
proc.stdin.write(stdin);
|
|
329
|
+
proc.stdin.end();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Set up abort handler
|
|
333
|
+
combinedSignal.addEventListener("abort", () => {
|
|
334
|
+
proc.kill();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Read output with size limit
|
|
338
|
+
const stdoutChunks: string[] = [];
|
|
339
|
+
const stderrChunks: string[] = [];
|
|
340
|
+
let totalSize = 0;
|
|
341
|
+
|
|
342
|
+
if (proc.stdout) {
|
|
343
|
+
const reader = proc.stdout.getReader();
|
|
344
|
+
const decoder = new TextDecoder();
|
|
345
|
+
try {
|
|
346
|
+
while (true) {
|
|
347
|
+
const { done, value } = await reader.read();
|
|
348
|
+
if (done) break;
|
|
349
|
+
const text = decoder.decode(value, { stream: true });
|
|
350
|
+
stdoutChunks.push(text);
|
|
351
|
+
totalSize += text.length;
|
|
352
|
+
if (totalSize > this.maxOutputSize) {
|
|
353
|
+
proc.kill();
|
|
354
|
+
throw new Error("Output exceeded maximum size limit");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} finally {
|
|
358
|
+
reader.releaseLock();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (proc.stderr) {
|
|
363
|
+
const reader = proc.stderr.getReader();
|
|
364
|
+
const decoder = new TextDecoder();
|
|
365
|
+
try {
|
|
366
|
+
while (true) {
|
|
367
|
+
const { done, value } = await reader.read();
|
|
368
|
+
if (done) break;
|
|
369
|
+
stderrChunks.push(decoder.decode(value, { stream: true }));
|
|
370
|
+
}
|
|
371
|
+
} finally {
|
|
372
|
+
reader.releaseLock();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const exitCode = await proc.exited;
|
|
377
|
+
const stdout = stdoutChunks.join("");
|
|
378
|
+
const stderr = stderrChunks.join("");
|
|
379
|
+
|
|
380
|
+
// Check if timed out
|
|
381
|
+
if (timeoutController.signal.aborted) {
|
|
382
|
+
const error = new Error(`${this.displayName} timed out after ${timeoutMs}ms`);
|
|
383
|
+
(error as Error & { code: string }).code = "TIMEOUT";
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check if user aborted
|
|
388
|
+
if (opts?.signal?.aborted) {
|
|
389
|
+
throw new Error("Aborted");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Check exit code against allowed codes
|
|
393
|
+
if (!this.isSuccessExitCode(exitCode)) {
|
|
394
|
+
const error = new Error(`${this.displayName} failed with code ${exitCode}: ${stderr}`);
|
|
395
|
+
(error as Error & { exitCode: number; stderr: string }).exitCode = exitCode ?? -1;
|
|
396
|
+
(error as Error & { exitCode: number; stderr: string }).stderr = stderr;
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return this.parseOutput(stdout);
|
|
401
|
+
} finally {
|
|
402
|
+
clearTimeout(timeoutId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Execute a single request with retry logic
|
|
408
|
+
*/
|
|
409
|
+
async runOnce(
|
|
410
|
+
req: CodingRequest,
|
|
411
|
+
opts: ProviderInvokeOptions,
|
|
412
|
+
): Promise<{ text: string; usage?: TokenUsage }> {
|
|
413
|
+
const retryConfig = this.definition.retry;
|
|
414
|
+
const maxAttempts = retryConfig?.maxAttempts ?? 1;
|
|
415
|
+
const delayMs = retryConfig?.delayMs ?? 1000;
|
|
416
|
+
const backoffMultiplier = retryConfig?.backoffMultiplier ?? 2;
|
|
417
|
+
const retryOn = retryConfig?.retryOn ?? ["TRANSIENT"];
|
|
418
|
+
|
|
419
|
+
let lastError: Error | null = null;
|
|
420
|
+
|
|
421
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
422
|
+
try {
|
|
423
|
+
return await this.executeOnce(req, opts);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
lastError = err as Error;
|
|
426
|
+
|
|
427
|
+
// Check if user aborted - don't retry
|
|
428
|
+
if (opts?.signal?.aborted) {
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Classify the error
|
|
433
|
+
const errorKind = this.classifyError({
|
|
434
|
+
stderr: (err as Error & { stderr?: string }).stderr || (err as Error).message,
|
|
435
|
+
exitCode: (err as Error & { exitCode?: number }).exitCode,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Check if this error kind should trigger a retry
|
|
439
|
+
if (!retryOn.includes(errorKind)) {
|
|
440
|
+
throw err; // Not retryable
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Don't retry on last attempt
|
|
444
|
+
if (attempt >= maxAttempts) {
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Wait before retrying with exponential backoff
|
|
449
|
+
const waitTime = delayMs * backoffMultiplier ** (attempt - 1);
|
|
450
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Should never reach here, but TypeScript needs this
|
|
455
|
+
throw lastError || new Error("Unknown error");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Execute a streaming request
|
|
460
|
+
*/
|
|
461
|
+
async *runStream(req: CodingRequest, opts: ProviderInvokeOptions): AsyncGenerator<CodingEvent> {
|
|
462
|
+
const requestId = crypto.randomUUID();
|
|
463
|
+
yield { type: "start", provider: this.id, requestId };
|
|
464
|
+
|
|
465
|
+
const args = this.buildArgs(req);
|
|
466
|
+
const stdin = this.getStdinInput(req);
|
|
467
|
+
const env = this.buildEnv(opts);
|
|
468
|
+
const cwd = this.getCwd(opts);
|
|
469
|
+
const timeoutMs = this.getTimeoutMs(opts);
|
|
470
|
+
|
|
471
|
+
// Create AbortController for timeout
|
|
472
|
+
const timeoutController = new AbortController();
|
|
473
|
+
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
474
|
+
|
|
475
|
+
// Combine with user-provided signal if any
|
|
476
|
+
const combinedSignal = opts?.signal
|
|
477
|
+
? AbortSignal.any([opts.signal, timeoutController.signal])
|
|
478
|
+
: timeoutController.signal;
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const proc = Bun.spawn([this.definition.binary, ...args], {
|
|
482
|
+
cwd,
|
|
483
|
+
env,
|
|
484
|
+
stdin: stdin ? "pipe" : "ignore",
|
|
485
|
+
stdout: "pipe",
|
|
486
|
+
stderr: "pipe",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Write to stdin if needed
|
|
490
|
+
if (stdin && proc.stdin) {
|
|
491
|
+
proc.stdin.write(stdin);
|
|
492
|
+
proc.stdin.end();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Set up abort handler
|
|
496
|
+
combinedSignal.addEventListener("abort", () => {
|
|
497
|
+
proc.kill();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const fullTextChunks: string[] = [];
|
|
501
|
+
let lineBuffer = "";
|
|
502
|
+
|
|
503
|
+
// Stream stdout
|
|
504
|
+
if (proc.stdout) {
|
|
505
|
+
const reader = proc.stdout.getReader();
|
|
506
|
+
const decoder = new TextDecoder();
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
while (true) {
|
|
510
|
+
const { done, value } = await reader.read();
|
|
511
|
+
if (done) break;
|
|
512
|
+
|
|
513
|
+
const text = decoder.decode(value, { stream: true });
|
|
514
|
+
fullTextChunks.push(text);
|
|
515
|
+
|
|
516
|
+
const streamMode = this.definition.streaming.mode;
|
|
517
|
+
|
|
518
|
+
if (streamMode === "jsonl") {
|
|
519
|
+
// Buffer partial lines
|
|
520
|
+
lineBuffer += text;
|
|
521
|
+
const lines = lineBuffer.split("\n");
|
|
522
|
+
lineBuffer = lines.pop() ?? "";
|
|
523
|
+
|
|
524
|
+
for (const line of lines.filter(Boolean)) {
|
|
525
|
+
try {
|
|
526
|
+
const parsed = JSON.parse(line);
|
|
527
|
+
yield { type: "chunk", data: parsed };
|
|
528
|
+
} catch {
|
|
529
|
+
yield { type: "text_delta", text: line };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else if (streamMode === "line") {
|
|
533
|
+
for (const line of text.split("\n")) {
|
|
534
|
+
if (line.trim()) {
|
|
535
|
+
yield { type: "text_delta", text: `${line}\n` };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
yield { type: "text_delta", text };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
reader.releaseLock();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Emit remaining buffer
|
|
548
|
+
if (lineBuffer.trim()) {
|
|
549
|
+
try {
|
|
550
|
+
const parsed = JSON.parse(lineBuffer);
|
|
551
|
+
yield { type: "chunk", data: parsed };
|
|
552
|
+
} catch {
|
|
553
|
+
yield { type: "text_delta", text: lineBuffer };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Collect stderr
|
|
558
|
+
const stderrChunks: string[] = [];
|
|
559
|
+
if (proc.stderr) {
|
|
560
|
+
const reader = proc.stderr.getReader();
|
|
561
|
+
const decoder = new TextDecoder();
|
|
562
|
+
try {
|
|
563
|
+
while (true) {
|
|
564
|
+
const { done, value } = await reader.read();
|
|
565
|
+
if (done) break;
|
|
566
|
+
stderrChunks.push(decoder.decode(value, { stream: true }));
|
|
567
|
+
}
|
|
568
|
+
} finally {
|
|
569
|
+
reader.releaseLock();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const exitCode = await proc.exited;
|
|
574
|
+
const fullText = fullTextChunks.join("");
|
|
575
|
+
const stderr = stderrChunks.join("");
|
|
576
|
+
|
|
577
|
+
// Check if timed out
|
|
578
|
+
if (timeoutController.signal.aborted) {
|
|
579
|
+
yield {
|
|
580
|
+
type: "error",
|
|
581
|
+
provider: this.id,
|
|
582
|
+
code: "TIMEOUT",
|
|
583
|
+
message: `${this.displayName} timed out after ${timeoutMs}ms`,
|
|
584
|
+
};
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Check if user aborted
|
|
589
|
+
if (opts?.signal?.aborted) {
|
|
590
|
+
yield {
|
|
591
|
+
type: "error",
|
|
592
|
+
provider: this.id,
|
|
593
|
+
code: "UNKNOWN",
|
|
594
|
+
message: "Aborted by user",
|
|
595
|
+
};
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Check exit code against allowed codes
|
|
600
|
+
if (this.isSuccessExitCode(exitCode)) {
|
|
601
|
+
const result = this.parseOutput(fullText);
|
|
602
|
+
yield {
|
|
603
|
+
type: "complete",
|
|
604
|
+
provider: this.id,
|
|
605
|
+
text: result.text,
|
|
606
|
+
usage: result.usage,
|
|
607
|
+
};
|
|
608
|
+
} else {
|
|
609
|
+
yield {
|
|
610
|
+
type: "error",
|
|
611
|
+
provider: this.id,
|
|
612
|
+
code: this.classifyError({ stderr, exitCode: exitCode ?? undefined }),
|
|
613
|
+
message: stderr || `Process exited with code ${exitCode}`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
} finally {
|
|
617
|
+
clearTimeout(timeoutId);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
getInfo(): ProviderInfo {
|
|
622
|
+
return {
|
|
623
|
+
id: this.id,
|
|
624
|
+
displayName: this.displayName,
|
|
625
|
+
supportsStreaming: this.supportsStreaming,
|
|
626
|
+
prefersJson: this.prefersJson,
|
|
627
|
+
capabilities: this.capabilities,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Create a ConfigurableProvider from a ProviderDefinition
|
|
634
|
+
*/
|
|
635
|
+
export function createConfigurableProvider(definition: ProviderDefinition): ConfigurableProvider {
|
|
636
|
+
return new ConfigurableProvider(definition);
|
|
637
|
+
}
|