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,168 @@
|
|
|
1
|
+
import { createErrorClassifier } from "../error-patterns";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCodeProvider - Provider for OpenCode agent delegation
|
|
4
|
+
*
|
|
5
|
+
* Uses 'opencode agent run' command with JSON output.
|
|
6
|
+
* Provider ID: 'opencode' (also aliased as 'delegate' for compatibility)
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Runs via `opencode agent run <prompt> -f json -q`
|
|
10
|
+
* - Supports file context via prompt injection
|
|
11
|
+
* - JSON output parsing with summary extraction
|
|
12
|
+
* - Files changed detection from output
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
CodingRequest,
|
|
16
|
+
ProviderConfig,
|
|
17
|
+
ProviderErrorContext,
|
|
18
|
+
ProviderErrorKind,
|
|
19
|
+
ProviderInvokeOptions,
|
|
20
|
+
TokenUsage,
|
|
21
|
+
} from "../types";
|
|
22
|
+
import { ProcessProvider } from "./index";
|
|
23
|
+
|
|
24
|
+
// OpenCode-specific error patterns (extend defaults)
|
|
25
|
+
const OPENCODE_SPECIFIC_PATTERNS: Partial<Record<ProviderErrorKind, string[]>> = {
|
|
26
|
+
RATE_LIMIT: ["api limit exceeded"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Create classifier with OpenCode-specific patterns merged with defaults
|
|
30
|
+
const classifyOpenCodeError = createErrorClassifier(OPENCODE_SPECIFIC_PATTERNS);
|
|
31
|
+
|
|
32
|
+
export class OpenCodeProvider extends ProcessProvider {
|
|
33
|
+
constructor(config: ProviderConfig) {
|
|
34
|
+
super("opencode", "OpenCode Agent", {
|
|
35
|
+
...config,
|
|
36
|
+
binary: config.binary || "opencode",
|
|
37
|
+
// OpenCode uses -f json for JSON output
|
|
38
|
+
jsonMode: "flag",
|
|
39
|
+
jsonFlag: "-f",
|
|
40
|
+
streamingMode: "none", // OpenCode doesn't support streaming
|
|
41
|
+
capabilities: config.capabilities || ["generate", "edit", "explain", "test", "refactor"],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* OpenCode uses positional prompt with args, not stdin
|
|
47
|
+
*/
|
|
48
|
+
protected getStdinInput(): string | undefined {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build args: opencode agent run <prompt> -f json -q
|
|
54
|
+
*/
|
|
55
|
+
protected buildArgs(req: CodingRequest, _opts: ProviderInvokeOptions): string[] {
|
|
56
|
+
const args: string[] = ["agent", "run"];
|
|
57
|
+
|
|
58
|
+
// Build prompt with file context if provided
|
|
59
|
+
let prompt = req.prompt;
|
|
60
|
+
if (req.files && req.files.length > 0) {
|
|
61
|
+
const fileList = req.files.map((f) => (typeof f === "string" ? f : f.path)).join("\n");
|
|
62
|
+
prompt = `${prompt}\n\nFiles to consider:\n${fileList}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
args.push(prompt);
|
|
66
|
+
|
|
67
|
+
// Add JSON output flag and quiet mode
|
|
68
|
+
args.push("-f", "json", "-q");
|
|
69
|
+
|
|
70
|
+
// Add any additional args from config
|
|
71
|
+
args.push(...this.config.args);
|
|
72
|
+
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse OpenCode JSON output to extract text and usage
|
|
78
|
+
*/
|
|
79
|
+
protected parseOutput(stdout: string): { text: string; usage?: TokenUsage } {
|
|
80
|
+
try {
|
|
81
|
+
const output = JSON.parse(stdout);
|
|
82
|
+
|
|
83
|
+
// Extract text from various possible fields
|
|
84
|
+
const text =
|
|
85
|
+
output.summary ||
|
|
86
|
+
output.response ||
|
|
87
|
+
output.message ||
|
|
88
|
+
output.output ||
|
|
89
|
+
output.text ||
|
|
90
|
+
this.extractSummaryFromText(stdout);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
text,
|
|
94
|
+
usage: output.usage,
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
// Not JSON, extract summary from plain text
|
|
98
|
+
return {
|
|
99
|
+
text: this.extractSummaryFromText(stdout),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract a summary from plain text output
|
|
106
|
+
*/
|
|
107
|
+
private extractSummaryFromText(stdout: string): string {
|
|
108
|
+
const lines = stdout.split("\n").filter((l) => l.trim());
|
|
109
|
+
|
|
110
|
+
// Look for summary-like patterns
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
if (line.match(/completed|fixed|added|updated|created|implemented/i)) {
|
|
113
|
+
return line.substring(0, 500);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fallback: first non-empty line or truncated output
|
|
118
|
+
return lines[0]?.substring(0, 500) || stdout.substring(0, 500);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Classify errors with OpenCode-specific patterns merged with defaults
|
|
123
|
+
*/
|
|
124
|
+
classifyError(error: ProviderErrorContext): ProviderErrorKind {
|
|
125
|
+
return classifyOpenCodeError(error.stderr, error.stdout, error.exitCode);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract files changed from OpenCode output (for result enrichment)
|
|
130
|
+
*/
|
|
131
|
+
extractFilesChanged(stdout: string): string[] {
|
|
132
|
+
const files: string[] = [];
|
|
133
|
+
|
|
134
|
+
const filePatterns = [
|
|
135
|
+
/(?:modified|changed|updated|created|edited):\s*([^\s]+)/gi,
|
|
136
|
+
/File:\s*([^\s]+)/gi,
|
|
137
|
+
/→\s*([^\s]+\.[a-z]{2,4})/gi,
|
|
138
|
+
/```[\w]*\s*([^\s]+)/gi,
|
|
139
|
+
/\[.*?\]\(([^)]+\.[a-z]{2,4})\)/gi,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
for (const pattern of filePatterns) {
|
|
143
|
+
const matches = stdout.matchAll(pattern);
|
|
144
|
+
for (const match of matches) {
|
|
145
|
+
if (match[1]) {
|
|
146
|
+
files.push(match[1]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return [...new Set(files)];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create an OpenCode provider with default or custom config
|
|
157
|
+
*/
|
|
158
|
+
export function createOpenCodeProvider(config?: Partial<ProviderConfig>): OpenCodeProvider {
|
|
159
|
+
return new OpenCodeProvider({
|
|
160
|
+
binary: "opencode",
|
|
161
|
+
args: [],
|
|
162
|
+
jsonMode: "flag",
|
|
163
|
+
jsonFlag: "-f",
|
|
164
|
+
streamingMode: "none",
|
|
165
|
+
capabilities: ["generate", "edit", "explain", "test", "refactor"],
|
|
166
|
+
...config,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QwenCodeProvider - Simplified provider using ProcessProvider defaults
|
|
3
|
+
*
|
|
4
|
+
* Qwen Code CLI uses:
|
|
5
|
+
* - Positional prompt: qwen [options] <prompt>
|
|
6
|
+
* - JSON output flag: -o json
|
|
7
|
+
*/
|
|
8
|
+
import type { CodingRequest, ProviderConfig, ProviderInvokeOptions } from "../types";
|
|
9
|
+
import { ProcessProvider } from "./index";
|
|
10
|
+
|
|
11
|
+
export class QwenCodeProvider extends ProcessProvider {
|
|
12
|
+
constructor(config: ProviderConfig) {
|
|
13
|
+
super("qwen-code", "Qwen Code CLI", {
|
|
14
|
+
...config,
|
|
15
|
+
binary: config.binary || "qwen",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Qwen uses positional prompt (not stdin), so override getStdinInput
|
|
21
|
+
*/
|
|
22
|
+
protected getStdinInput(): string | undefined {
|
|
23
|
+
return undefined; // Qwen uses positional prompt
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build args: qwen -o json [...args] <prompt>
|
|
28
|
+
*/
|
|
29
|
+
protected buildArgs(req: CodingRequest, _opts: ProviderInvokeOptions): string[] {
|
|
30
|
+
const args: string[] = [];
|
|
31
|
+
|
|
32
|
+
if (this.config.jsonMode === "flag") {
|
|
33
|
+
args.push("-o", "json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
args.push(...this.config.args);
|
|
37
|
+
args.push(req.prompt);
|
|
38
|
+
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import type { Provider } from "./providers/index";
|
|
2
|
+
import type { StateManager } from "./state";
|
|
3
|
+
import type {
|
|
4
|
+
CodingRequest,
|
|
5
|
+
CodingResponse,
|
|
6
|
+
ProviderErrorContext,
|
|
7
|
+
ProviderErrorKind,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import type { Config } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface RouterOptions {
|
|
12
|
+
config: Config;
|
|
13
|
+
stateManager: StateManager;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extended Map interface that supports async provider loading
|
|
18
|
+
*/
|
|
19
|
+
interface AsyncProviderMap extends Map<string, Provider> {
|
|
20
|
+
getAsync?(key: string): Promise<Provider | null>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Router {
|
|
24
|
+
private providers: AsyncProviderMap;
|
|
25
|
+
private config: Config;
|
|
26
|
+
private stateManager: StateManager;
|
|
27
|
+
|
|
28
|
+
constructor(providers: AsyncProviderMap, options: RouterOptions) {
|
|
29
|
+
this.providers = providers;
|
|
30
|
+
this.config = options.config;
|
|
31
|
+
this.stateManager = options.stateManager;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get a provider, supporting both sync and async loading
|
|
36
|
+
*/
|
|
37
|
+
private async getProvider(id: string): Promise<Provider | null> {
|
|
38
|
+
// Try async loading first (for lazy-loaded providers)
|
|
39
|
+
if (this.providers.getAsync) {
|
|
40
|
+
return this.providers.getAsync(id);
|
|
41
|
+
}
|
|
42
|
+
// Fall back to sync get
|
|
43
|
+
return this.providers.get(id) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async route(
|
|
47
|
+
req: CodingRequest,
|
|
48
|
+
opts: { signal?: AbortSignal; cwd?: string } = {},
|
|
49
|
+
): Promise<CodingResponse> {
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
|
|
52
|
+
// Build candidate list
|
|
53
|
+
const candidates = this.getCandidateProviders(req);
|
|
54
|
+
if (candidates.length === 0) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"No providers configured. Add providers to your config or check your routing.defaultOrder setting.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Try each provider in order
|
|
61
|
+
const errors: Array<{ provider: string; error: Error; kind: ProviderErrorKind }> = [];
|
|
62
|
+
|
|
63
|
+
for (const providerId of candidates) {
|
|
64
|
+
const provider = await this.getProvider(providerId);
|
|
65
|
+
if (!provider) {
|
|
66
|
+
errors.push({
|
|
67
|
+
provider: providerId,
|
|
68
|
+
error: new Error(`Provider ${providerId} not found or unavailable`),
|
|
69
|
+
kind: "NOT_FOUND",
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if provider is marked as out of credits
|
|
75
|
+
const state = await this.stateManager.getProviderState(providerId);
|
|
76
|
+
if (state.outOfCreditsUntil && new Date(state.outOfCreditsUntil) > new Date()) {
|
|
77
|
+
errors.push({
|
|
78
|
+
provider: providerId,
|
|
79
|
+
error: new Error(
|
|
80
|
+
`Provider ${providerId} is out of credits until ${state.outOfCreditsUntil}`,
|
|
81
|
+
),
|
|
82
|
+
kind: "OUT_OF_CREDITS",
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check daily limit
|
|
88
|
+
const creditConfig = this.config.credits.providers[providerId];
|
|
89
|
+
if (
|
|
90
|
+
creditConfig &&
|
|
91
|
+
"dailyRequestLimit" in creditConfig &&
|
|
92
|
+
state.requestsToday >= creditConfig.dailyRequestLimit
|
|
93
|
+
) {
|
|
94
|
+
// Mark as out of credits for rest of day
|
|
95
|
+
const tomorrow = new Date();
|
|
96
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
97
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
98
|
+
await this.stateManager.markOutOfCredits(providerId, tomorrow);
|
|
99
|
+
|
|
100
|
+
errors.push({
|
|
101
|
+
provider: providerId,
|
|
102
|
+
error: new Error(`Daily request limit exceeded for ${providerId}`),
|
|
103
|
+
kind: "OUT_OF_CREDITS",
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Execute request
|
|
110
|
+
const result = await provider.runOnce(req, opts);
|
|
111
|
+
|
|
112
|
+
// Update state on success, including token usage
|
|
113
|
+
const tokensSaved = result.usage?.totalTokens ?? result.usage?.outputTokens ?? 0;
|
|
114
|
+
await this.stateManager.recordSuccess(providerId, tokensSaved);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
provider: providerId,
|
|
118
|
+
text: result.text,
|
|
119
|
+
usage: result.usage,
|
|
120
|
+
meta: {
|
|
121
|
+
elapsedMs: Date.now() - startTime,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const err = error as Error;
|
|
126
|
+
const context: ProviderErrorContext = {
|
|
127
|
+
exitCode: "exitCode" in err ? (err as any).exitCode : undefined,
|
|
128
|
+
stderr: err.message,
|
|
129
|
+
stdout: "stdout" in err ? (err as any).stdout : undefined,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const errorKind = provider.classifyError(context);
|
|
133
|
+
|
|
134
|
+
// Update state based on error
|
|
135
|
+
await this.stateManager.recordError(providerId, errorKind, err.message);
|
|
136
|
+
|
|
137
|
+
// For OUT_OF_CREDITS or RATE_LIMIT, mark provider as unavailable for cooldown
|
|
138
|
+
if (errorKind === "OUT_OF_CREDITS" || errorKind === "RATE_LIMIT") {
|
|
139
|
+
const cooldownTime = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
140
|
+
await this.stateManager.markOutOfCredits(providerId, cooldownTime);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Errors that should fail immediately (user needs to fix request/config)
|
|
144
|
+
if (errorKind === "BAD_REQUEST") {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Bad request to ${providerId}: ${err.message}. Check your prompt and options.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (errorKind === "UNAUTHORIZED") {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Unauthorized for ${providerId}: ${err.message}. Check your API key or authentication.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (errorKind === "FORBIDDEN") {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Access denied for ${providerId}: ${err.message}. Check your permissions or subscription.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (errorKind === "CONTEXT_LENGTH") {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Context too long for ${providerId}: ${err.message}. Try a shorter prompt or fewer files.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (errorKind === "CONTENT_FILTER") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Content filtered by ${providerId}: ${err.message}. Rephrase your prompt to avoid policy violations.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// For other errors, log and continue to next provider
|
|
171
|
+
errors.push({ provider: providerId, error: err, kind: errorKind });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// All providers failed
|
|
176
|
+
const errorSummary = errors
|
|
177
|
+
.map((e) => `${e.provider} (${e.kind}): ${e.error.message}`)
|
|
178
|
+
.join("; ");
|
|
179
|
+
throw new Error(
|
|
180
|
+
`All ${errors.length} providers failed. Tried: ${candidates.join(", ")}. Details: ${errorSummary}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async *routeStream(
|
|
185
|
+
req: CodingRequest,
|
|
186
|
+
opts: { signal?: AbortSignal; cwd?: string } = {},
|
|
187
|
+
): AsyncGenerator<CodingResponse> {
|
|
188
|
+
const startTime = Date.now();
|
|
189
|
+
|
|
190
|
+
// Build candidate list
|
|
191
|
+
const candidates = this.getCandidateProviders(req);
|
|
192
|
+
if (candidates.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"No providers configured. Add providers to your config or check your routing.defaultOrder setting.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Try each provider in order
|
|
199
|
+
const errors: Array<{ provider: string; error: Error; kind: ProviderErrorKind }> = [];
|
|
200
|
+
|
|
201
|
+
for (const providerId of candidates) {
|
|
202
|
+
const provider = await this.getProvider(providerId);
|
|
203
|
+
if (!provider) {
|
|
204
|
+
errors.push({
|
|
205
|
+
provider: providerId,
|
|
206
|
+
error: new Error(`Provider ${providerId} not found or unavailable`),
|
|
207
|
+
kind: "NOT_FOUND",
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if provider is marked as out of credits
|
|
213
|
+
const state = await this.stateManager.getProviderState(providerId);
|
|
214
|
+
if (state.outOfCreditsUntil && new Date(state.outOfCreditsUntil) > new Date()) {
|
|
215
|
+
errors.push({
|
|
216
|
+
provider: providerId,
|
|
217
|
+
error: new Error(
|
|
218
|
+
`Provider ${providerId} is out of credits until ${state.outOfCreditsUntil}`,
|
|
219
|
+
),
|
|
220
|
+
kind: "OUT_OF_CREDITS",
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check daily limit
|
|
226
|
+
const creditConfig = this.config.credits.providers[providerId];
|
|
227
|
+
if (
|
|
228
|
+
creditConfig &&
|
|
229
|
+
"dailyRequestLimit" in creditConfig &&
|
|
230
|
+
state.requestsToday >= creditConfig.dailyRequestLimit
|
|
231
|
+
) {
|
|
232
|
+
const tomorrow = new Date();
|
|
233
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
234
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
235
|
+
await this.stateManager.markOutOfCredits(providerId, tomorrow);
|
|
236
|
+
|
|
237
|
+
errors.push({
|
|
238
|
+
provider: providerId,
|
|
239
|
+
error: new Error(`Daily request limit exceeded for ${providerId}`),
|
|
240
|
+
kind: "OUT_OF_CREDITS",
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const fullText = "";
|
|
247
|
+
|
|
248
|
+
// Execute streaming request
|
|
249
|
+
for await (const event of provider.runStream(req, opts)) {
|
|
250
|
+
if (event.type === "complete") {
|
|
251
|
+
// Record success with token usage
|
|
252
|
+
const tokensSaved = event.usage?.totalTokens ?? event.usage?.outputTokens ?? 0;
|
|
253
|
+
await this.stateManager.recordSuccess(providerId, tokensSaved);
|
|
254
|
+
yield {
|
|
255
|
+
provider: providerId,
|
|
256
|
+
text: event.text,
|
|
257
|
+
usage: event.usage,
|
|
258
|
+
meta: {
|
|
259
|
+
elapsedMs: Date.now() - startTime,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
return; // Success, exit generator
|
|
263
|
+
}
|
|
264
|
+
// Pass through events (could be extended to yield events too)
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const err = error as Error;
|
|
268
|
+
const context: ProviderErrorContext = {
|
|
269
|
+
exitCode: "exitCode" in err ? (err as any).exitCode : undefined,
|
|
270
|
+
stderr: err.message,
|
|
271
|
+
stdout: "stdout" in err ? (err as any).stdout : undefined,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const errorKind = provider.classifyError(context);
|
|
275
|
+
await this.stateManager.recordError(providerId, errorKind, err.message);
|
|
276
|
+
|
|
277
|
+
if (errorKind === "OUT_OF_CREDITS" || errorKind === "RATE_LIMIT") {
|
|
278
|
+
const cooldownTime = new Date(Date.now() + 60 * 60 * 1000);
|
|
279
|
+
await this.stateManager.markOutOfCredits(providerId, cooldownTime);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Errors that should fail immediately (user needs to fix request/config)
|
|
283
|
+
if (errorKind === "BAD_REQUEST") {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Bad request to ${providerId}: ${err.message}. Check your prompt and options.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (errorKind === "UNAUTHORIZED") {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Unauthorized for ${providerId}: ${err.message}. Check your API key or authentication.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (errorKind === "FORBIDDEN") {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Access denied for ${providerId}: ${err.message}. Check your permissions or subscription.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (errorKind === "CONTEXT_LENGTH") {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Context too long for ${providerId}: ${err.message}. Try a shorter prompt or fewer files.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (errorKind === "CONTENT_FILTER") {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Content filtered by ${providerId}: ${err.message}. Rephrase your prompt to avoid policy violations.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
errors.push({ provider: providerId, error: err, kind: errorKind });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// All providers failed
|
|
314
|
+
const errorSummary = errors
|
|
315
|
+
.map((e) => `${e.provider} (${e.kind}): ${e.error.message}`)
|
|
316
|
+
.join("; ");
|
|
317
|
+
throw new Error(
|
|
318
|
+
`All ${errors.length} providers failed. Tried: ${candidates.join(", ")}. Details: ${errorSummary}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private getCandidateProviders(req: CodingRequest): string[] {
|
|
323
|
+
// If provider is explicitly specified, use only that provider
|
|
324
|
+
if (req.provider) {
|
|
325
|
+
return [req.provider];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Check for per-mode override
|
|
329
|
+
const mode = req.mode || "generate";
|
|
330
|
+
if (this.config.routing.perModeOverride?.[mode]) {
|
|
331
|
+
return this.config.routing.perModeOverride[mode];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Use default order
|
|
335
|
+
return this.config.routing.defaultOrder;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getProviderInfo(): Promise<
|
|
339
|
+
Array<{
|
|
340
|
+
id: string;
|
|
341
|
+
displayName: string;
|
|
342
|
+
outOfCreditsUntil?: Date;
|
|
343
|
+
requestsToday: number;
|
|
344
|
+
available: boolean;
|
|
345
|
+
}>
|
|
346
|
+
> {
|
|
347
|
+
const info: Array<{
|
|
348
|
+
id: string;
|
|
349
|
+
displayName: string;
|
|
350
|
+
outOfCreditsUntil?: Date;
|
|
351
|
+
requestsToday: number;
|
|
352
|
+
available: boolean;
|
|
353
|
+
}> = [];
|
|
354
|
+
|
|
355
|
+
for (const providerId of this.providers.keys()) {
|
|
356
|
+
const provider = await this.getProvider(providerId);
|
|
357
|
+
const state = await this.stateManager.getProviderState(providerId);
|
|
358
|
+
|
|
359
|
+
info.push({
|
|
360
|
+
id: providerId,
|
|
361
|
+
displayName: provider?.displayName || providerId,
|
|
362
|
+
outOfCreditsUntil: state.outOfCreditsUntil ? new Date(state.outOfCreditsUntil) : undefined,
|
|
363
|
+
requestsToday: state.requestsToday,
|
|
364
|
+
available: !!provider,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return info;
|
|
369
|
+
}
|
|
370
|
+
}
|