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.
Files changed (71) hide show
  1. package/bin/wraptc +4 -4
  2. package/package.json +2 -2
  3. package/src/cli/__tests__/cli.test.ts +337 -0
  4. package/src/cli/index.ts +149 -0
  5. package/src/core/__tests__/fixtures/configs/project-config.json +14 -0
  6. package/src/core/__tests__/fixtures/configs/system-config.json +14 -0
  7. package/src/core/__tests__/fixtures/configs/user-config.json +15 -0
  8. package/src/core/__tests__/integration/integration.test.ts +241 -0
  9. package/src/core/__tests__/integration/mock-coder-adapter.test.ts +243 -0
  10. package/src/core/__tests__/test-utils.ts +136 -0
  11. package/src/core/__tests__/unit/adapters/runner.test.ts +302 -0
  12. package/src/core/__tests__/unit/basic-test.test.ts +44 -0
  13. package/src/core/__tests__/unit/basic.test.ts +12 -0
  14. package/src/core/__tests__/unit/config.test.ts +244 -0
  15. package/src/core/__tests__/unit/error-patterns.test.ts +181 -0
  16. package/src/core/__tests__/unit/memory-monitor.test.ts +354 -0
  17. package/src/core/__tests__/unit/plugin/registry.test.ts +356 -0
  18. package/src/core/__tests__/unit/providers/codex.test.ts +173 -0
  19. package/src/core/__tests__/unit/providers/configurable.test.ts +429 -0
  20. package/src/core/__tests__/unit/providers/gemini.test.ts +251 -0
  21. package/src/core/__tests__/unit/providers/opencode.test.ts +258 -0
  22. package/src/core/__tests__/unit/providers/qwen-code.test.ts +195 -0
  23. package/src/core/__tests__/unit/providers/simple-codex.test.ts +18 -0
  24. package/src/core/__tests__/unit/router.test.ts +967 -0
  25. package/src/core/__tests__/unit/state.test.ts +1079 -0
  26. package/src/core/__tests__/unit/unified/capabilities.test.ts +186 -0
  27. package/src/core/__tests__/unit/wrap-terminalcoder.test.ts +32 -0
  28. package/src/core/adapters/builtin/codex.ts +35 -0
  29. package/src/core/adapters/builtin/gemini.ts +34 -0
  30. package/src/core/adapters/builtin/index.ts +31 -0
  31. package/src/core/adapters/builtin/mock-coder.ts +148 -0
  32. package/src/core/adapters/builtin/qwen.ts +34 -0
  33. package/src/core/adapters/define.ts +48 -0
  34. package/src/core/adapters/index.ts +43 -0
  35. package/src/core/adapters/loader.ts +143 -0
  36. package/src/core/adapters/provider-bridge.ts +190 -0
  37. package/src/core/adapters/runner.ts +437 -0
  38. package/src/core/adapters/types.ts +172 -0
  39. package/src/core/config.ts +290 -0
  40. package/src/core/define-provider.ts +212 -0
  41. package/src/core/error-patterns.ts +147 -0
  42. package/src/core/index.ts +130 -0
  43. package/src/core/memory-monitor.ts +171 -0
  44. package/src/core/plugin/builtin.ts +87 -0
  45. package/src/core/plugin/index.ts +34 -0
  46. package/src/core/plugin/registry.ts +350 -0
  47. package/src/core/plugin/types.ts +209 -0
  48. package/src/core/provider-factory.ts +397 -0
  49. package/src/core/provider-loader.ts +171 -0
  50. package/src/core/providers/codex.ts +56 -0
  51. package/src/core/providers/configurable.ts +637 -0
  52. package/src/core/providers/custom.ts +261 -0
  53. package/src/core/providers/gemini.ts +41 -0
  54. package/src/core/providers/index.ts +383 -0
  55. package/src/core/providers/opencode.ts +168 -0
  56. package/src/core/providers/qwen-code.ts +41 -0
  57. package/src/core/router.ts +370 -0
  58. package/src/core/state.ts +258 -0
  59. package/src/core/types.ts +206 -0
  60. package/src/core/unified/capabilities.ts +184 -0
  61. package/src/core/unified/errors.ts +141 -0
  62. package/src/core/unified/index.ts +29 -0
  63. package/src/core/unified/output.ts +189 -0
  64. package/src/core/wrap-terminalcoder.ts +245 -0
  65. package/src/mcp/__tests__/server.test.ts +295 -0
  66. package/src/mcp/server.ts +284 -0
  67. package/src/test-fixtures/mock-coder.sh +194 -0
  68. package/dist/cli/index.js +0 -16501
  69. package/dist/core/index.js +0 -7531
  70. package/dist/mcp/server.js +0 -14568
  71. package/dist/wraptc-1.0.2.tgz +0 -0
@@ -0,0 +1,290 @@
1
+ import { rename } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { Command } from "commander";
4
+ import type { Config } from "./types";
5
+ import { ConfigSchema } from "./types";
6
+
7
+ export interface ConfigLoaderOptions {
8
+ configPath?: string;
9
+ projectConfigPath?: string;
10
+ /** Enable debug logging (default: false) */
11
+ debug?: boolean;
12
+ }
13
+
14
+ export class ConfigLoader {
15
+ private options: ConfigLoaderOptions;
16
+
17
+ constructor(options: ConfigLoaderOptions = {}) {
18
+ this.options = options;
19
+ }
20
+
21
+ async loadConfig(): Promise<Config> {
22
+ // Load configs from different sources in order of precedence
23
+ const configs: Config[] = [];
24
+
25
+ // 1. Built-in defaults
26
+ configs.push(this.getDefaultConfig());
27
+
28
+ // 2. System config (optional)
29
+ const systemConfig = await this.loadSystemConfig();
30
+ if (systemConfig) {
31
+ configs.push(systemConfig);
32
+ }
33
+
34
+ // 3. User config
35
+ const userConfig = await this.loadUserConfig();
36
+ if (userConfig) {
37
+ configs.push(userConfig);
38
+ }
39
+
40
+ // 4. Project config
41
+ const projectConfig = await this.loadProjectConfig();
42
+ if (projectConfig) {
43
+ configs.push(projectConfig);
44
+ }
45
+
46
+ // 5. Env variables (WTC_*)
47
+ const envConfig = this.loadEnvConfig();
48
+ if (envConfig) {
49
+ configs.push(envConfig);
50
+ }
51
+
52
+ // Merge all configs
53
+ const merged = this.mergeConfigs(configs);
54
+
55
+ // Validate with Zod
56
+ const result = ConfigSchema.safeParse(merged);
57
+ if (!result.success) {
58
+ throw new Error(`Invalid configuration: ${result.error.message}`);
59
+ }
60
+
61
+ return result.data;
62
+ }
63
+
64
+ private getDefaultConfig(): Config {
65
+ return {
66
+ routing: {
67
+ defaultOrder: ["gemini", "opencode", "qwen-code", "codex"],
68
+ perModeOverride: {
69
+ test: ["codex", "gemini"],
70
+ explain: ["gemini", "qwen-code"],
71
+ },
72
+ },
73
+ providers: {
74
+ gemini: {
75
+ binary: "gemini",
76
+ args: [],
77
+ jsonMode: "flag",
78
+ jsonFlag: "--output-format",
79
+ streamingMode: "jsonl",
80
+ capabilities: ["generate", "edit", "explain", "test"],
81
+ },
82
+ opencode: {
83
+ binary: "opencode",
84
+ args: [],
85
+ jsonMode: "flag",
86
+ jsonFlag: "-f",
87
+ streamingMode: "none",
88
+ capabilities: ["generate", "edit", "explain", "test", "refactor"],
89
+ },
90
+ "qwen-code": {
91
+ binary: "qwen",
92
+ args: [],
93
+ jsonMode: "flag",
94
+ jsonFlag: "--json",
95
+ streamingMode: "jsonl",
96
+ capabilities: ["generate", "edit", "explain", "test"],
97
+ },
98
+ codex: {
99
+ binary: "codex",
100
+ args: [],
101
+ jsonMode: "none",
102
+ streamingMode: "line",
103
+ capabilities: ["generate", "edit", "test"],
104
+ },
105
+ },
106
+ credits: {
107
+ providers: {
108
+ gemini: {
109
+ dailyRequestLimit: 1000,
110
+ resetHourUtc: 0,
111
+ },
112
+ opencode: {
113
+ dailyRequestLimit: 1000,
114
+ resetHourUtc: 0,
115
+ },
116
+ "qwen-code": {
117
+ dailyRequestLimit: 2000,
118
+ resetHourUtc: 0,
119
+ },
120
+ codex: {
121
+ plan: "chatgpt_plus",
122
+ },
123
+ },
124
+ },
125
+ };
126
+ }
127
+
128
+ private async loadSystemConfig(): Promise<Config | null> {
129
+ const systemPath = "/etc/wrap-terminalcoder/config.json";
130
+ return this.loadConfigFile(systemPath);
131
+ }
132
+
133
+ private async loadUserConfig(): Promise<Config | null> {
134
+ const userPath = join(process.env.HOME || "~", ".config", "wrap-terminalcoder", "config.json");
135
+ return this.loadConfigFile(userPath);
136
+ }
137
+
138
+ private async loadProjectConfig(): Promise<Config | null> {
139
+ const projectPath = this.options.projectConfigPath || ".config/wraptc/config.json";
140
+ return this.loadConfigFile(projectPath);
141
+ }
142
+
143
+ private async loadConfigFile(filePath: string): Promise<Config | null> {
144
+ // Use Bun.file() for async file existence check and reading
145
+ const file = Bun.file(filePath);
146
+ const exists = await file.exists();
147
+
148
+ if (!exists) {
149
+ return null;
150
+ }
151
+
152
+ // File exists - now errors are meaningful and should be reported
153
+ try {
154
+ const content = await file.text();
155
+ const parsed = JSON.parse(content);
156
+
157
+ if (this.options.debug) {
158
+ console.debug(`[ConfigLoader] Loaded config from ${filePath}`);
159
+ }
160
+ return parsed;
161
+ } catch (error) {
162
+ // File exists but couldn't be read or parsed - this is a real error
163
+ const errorMessage = error instanceof Error ? error.message : String(error);
164
+ if (errorMessage.includes("JSON")) {
165
+ throw new Error(`Invalid JSON in config file ${filePath}: ${errorMessage}`);
166
+ }
167
+ throw new Error(`Failed to read config file ${filePath}: ${errorMessage}`);
168
+ }
169
+ }
170
+
171
+ private loadEnvConfig(): Config | null {
172
+ const envVars = Object.entries(process.env).filter(([key]) => key.startsWith("WTC_"));
173
+
174
+ if (envVars.length === 0) {
175
+ return null;
176
+ }
177
+
178
+ const config: any = {};
179
+
180
+ for (const [key, value] of envVars) {
181
+ // Convert WTC_ROUTING__DEFAULT_ORDER='["qwen-code","gemini"]' to config.routing.defaultOrder
182
+ const path = key
183
+ .replace(/^WTC_/, "")
184
+ .toLowerCase()
185
+ .split("__")
186
+ .map((segment) => this.camelCase(segment));
187
+
188
+ this.setNestedProperty(config, path, this.parseEnvValue(value));
189
+ }
190
+
191
+ return config;
192
+ }
193
+
194
+ private camelCase(str: string): string {
195
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
196
+ }
197
+
198
+ private parseEnvValue(value: string | undefined): any {
199
+ if (!value) return value;
200
+ // Try to parse as JSON first
201
+ try {
202
+ return JSON.parse(value);
203
+ } catch {
204
+ // If not JSON, return as string
205
+ return value;
206
+ }
207
+ }
208
+
209
+ private setNestedProperty(obj: any, path: string[], value: any): void {
210
+ if (path.length === 0) {
211
+ // For empty path, set the value directly on the object
212
+ obj[""] = value;
213
+ return;
214
+ }
215
+
216
+ let current = obj;
217
+
218
+ for (let i = 0; i < path.length - 1; i++) {
219
+ const key = path[i];
220
+ if (!(key in current)) {
221
+ current[key] = {};
222
+ }
223
+ current = current[key];
224
+ }
225
+
226
+ current[path[path.length - 1]] = value;
227
+ }
228
+
229
+ private mergeConfigs(configs: Config[]): Config {
230
+ // Deep merge all configs, with later configs overriding earlier ones
231
+ const merged = configs.reduce((acc, config) => {
232
+ return this.deepMerge(acc, config);
233
+ }, {} as Config);
234
+
235
+ return merged;
236
+ }
237
+
238
+ private deepMerge(target: any, source: any): any {
239
+ const result = { ...target };
240
+
241
+ for (const key in source) {
242
+ if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
243
+ result[key] = this.deepMerge(result[key] || {}, source[key]);
244
+ } else {
245
+ result[key] = source[key];
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
251
+
252
+ async saveConfig(config: Config, path?: string): Promise<void> {
253
+ const configPath = path || this.options.configPath || this.getDefaultUserConfigPath();
254
+
255
+ // Ensure directory exists using Bun shell
256
+ const dir = dirname(configPath);
257
+ await Bun.$`mkdir -p ${dir}`.quiet();
258
+
259
+ // Write atomically: write to temp file, then rename
260
+ const tempPath = `${configPath}.tmp`;
261
+
262
+ // Use Bun.write() for faster file writing
263
+ await Bun.write(tempPath, JSON.stringify(config, null, 2));
264
+
265
+ // Atomic rename
266
+ await rename(tempPath, configPath);
267
+ }
268
+
269
+ private getDefaultUserConfigPath(): string {
270
+ return join(process.env.HOME || "~", ".config", "wrap-terminalcoder", "config.json");
271
+ }
272
+ }
273
+
274
+ export function addCLIConfigOverrides(command: Command): void {
275
+ command
276
+ .option("--provider <provider>", "Override provider")
277
+ .option("--routing-default-order <order>", "Override routing default order (JSON array)")
278
+ .option("--config <path>", "Config file path")
279
+ .hook("preAction", async (thisCommand) => {
280
+ const opts = thisCommand.opts();
281
+
282
+ // Convert CLI options to env variables temporarily
283
+ if (opts.provider) {
284
+ process.env.WTC_REQUEST__PROVIDER = opts.provider;
285
+ }
286
+ if (opts.routingDefaultOrder) {
287
+ process.env.WTC_ROUTING__DEFAULT_ORDER = opts.routingDefaultOrder;
288
+ }
289
+ });
290
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * defineProvider - Helper for creating CLI provider configurations
3
+ *
4
+ * This enables users to add new CLI tools by creating a simple .ts file:
5
+ *
6
+ * ```typescript
7
+ * // ~/.config/wraptc/providers/my-cli.ts
8
+ * import { defineProvider } from "wrap-terminalcoder";
9
+ *
10
+ * export default defineProvider({
11
+ * id: "my-cli",
12
+ * binary: "my-cli",
13
+ * input: { method: "positional" },
14
+ * output: { format: "json", textField: "response" },
15
+ * });
16
+ * ```
17
+ */
18
+
19
+ import { z } from "zod";
20
+ import type { CodingRequest, ProviderErrorContext, ProviderErrorKind, TokenUsage } from "./types";
21
+
22
+ // Input configuration - how to pass the prompt to the CLI
23
+ export const InputConfigSchema = z
24
+ .object({
25
+ // stdin: pipe prompt to stdin
26
+ // positional: pass prompt as positional argument
27
+ // flag: pass prompt with a flag (e.g., --prompt "...")
28
+ method: z.enum(["stdin", "positional", "flag"]).default("stdin"),
29
+ // Flag name if method is "flag" (e.g., "--prompt")
30
+ flag: z.string().optional(),
31
+ // Position if method is "positional" (first or last argument)
32
+ position: z.enum(["first", "last"]).default("last"),
33
+ })
34
+ .default({ method: "stdin" });
35
+
36
+ // Output configuration - how to parse CLI output
37
+ export const OutputConfigSchema = z
38
+ .object({
39
+ // text: raw stdout as response
40
+ // json: parse JSON, extract textField
41
+ // jsonl: parse JSON lines
42
+ format: z.enum(["text", "json", "jsonl"]).default("text"),
43
+ // Field containing the text response (for json format)
44
+ textField: z.string().optional(),
45
+ // Field containing usage info (for json format)
46
+ usageField: z.string().optional(),
47
+ })
48
+ .default({ format: "text" });
49
+
50
+ // Streaming configuration
51
+ export const StreamingConfigSchema = z
52
+ .object({
53
+ // none: no streaming support
54
+ // line: emit each line as text_delta
55
+ // jsonl: parse JSON lines and emit as chunks
56
+ mode: z.enum(["none", "line", "jsonl"]).default("none"),
57
+ })
58
+ .default({ mode: "none" });
59
+
60
+ // Args configuration - how to build command line arguments
61
+ export const ArgsConfigSchema = z
62
+ .object({
63
+ // Base arguments always included
64
+ base: z.array(z.string()).default([]),
65
+ // Flag to enable JSON output (e.g., "-o json" or "--format json")
66
+ jsonFlag: z.string().optional(),
67
+ // Flag to enable streaming
68
+ streamFlag: z.string().optional(),
69
+ // Flag for model selection (e.g., "--model")
70
+ modelFlag: z.string().optional(),
71
+ // Flag for file context (e.g., "-f" - will be repeated for each file)
72
+ fileFlag: z.string().optional(),
73
+ // Flag for max tokens (e.g., "--max-tokens")
74
+ maxTokensFlag: z.string().optional(),
75
+ // Flag for temperature (e.g., "--temperature" or "-t")
76
+ temperatureFlag: z.string().optional(),
77
+ // Flag for language (e.g., "--language" or "-l")
78
+ languageFlag: z.string().optional(),
79
+ // Flag for system prompt (e.g., "--system" or "-S")
80
+ systemPromptFlag: z.string().optional(),
81
+ // How to handle system prompt
82
+ systemPromptMethod: z.enum(["flag", "combined"]).optional(),
83
+ })
84
+ .default({ base: [] });
85
+
86
+ // Error patterns - strings to match in stderr/stdout for error classification
87
+ export const ErrorPatternsSchema = z.record(z.array(z.string())).optional();
88
+
89
+ // Retry configuration schema
90
+ export const RetryConfigSchema = z
91
+ .object({
92
+ // Maximum number of retry attempts (1 = no retries)
93
+ maxAttempts: z.number().default(1),
94
+ // Initial delay between retries in milliseconds
95
+ delayMs: z.number().default(1000),
96
+ // Multiplier for exponential backoff
97
+ backoffMultiplier: z.number().default(2),
98
+ // Error kinds that should trigger a retry
99
+ retryOn: z.array(z.string()).default(["TRANSIENT"]),
100
+ })
101
+ .optional();
102
+
103
+ // Main provider definition schema
104
+ export const ProviderDefinitionSchema = z.object({
105
+ // Unique identifier for the provider
106
+ id: z.string(),
107
+ // Display name (defaults to id)
108
+ displayName: z.string().optional(),
109
+ // Path to the CLI binary
110
+ binary: z.string(),
111
+ // Subcommand to invoke (e.g., "exec", "run", "chat")
112
+ subcommand: z.string().optional(),
113
+
114
+ // Environment variables to set for the process
115
+ env: z.record(z.string()).optional(),
116
+ // Default working directory for the process
117
+ defaultCwd: z.string().optional(),
118
+
119
+ // Input handling configuration
120
+ input: InputConfigSchema,
121
+ // Output parsing configuration
122
+ output: OutputConfigSchema,
123
+ // Streaming configuration
124
+ streaming: StreamingConfigSchema,
125
+ // Arguments configuration
126
+ args: ArgsConfigSchema,
127
+
128
+ // Error patterns for classification
129
+ errors: ErrorPatternsSchema,
130
+ // Exit codes to treat as success (default: [0])
131
+ allowedExitCodes: z.array(z.number()).default([0]),
132
+
133
+ // Timeout in milliseconds (default: 60000 = 1 minute)
134
+ timeoutMs: z.number().optional(),
135
+ // Retry configuration for transient errors
136
+ retry: RetryConfigSchema,
137
+
138
+ // Capabilities this provider supports
139
+ capabilities: z.array(z.string()).default(["generate"]),
140
+ });
141
+
142
+ // Inferred type from schema
143
+ export type ProviderDefinitionBase = z.infer<typeof ProviderDefinitionSchema>;
144
+
145
+ // Full provider definition including optional function overrides
146
+ export interface ProviderDefinition extends ProviderDefinitionBase {
147
+ /**
148
+ * Custom argument building logic.
149
+ * Called instead of default arg building when provided.
150
+ */
151
+ buildArgs?: (req: CodingRequest) => string[];
152
+
153
+ /**
154
+ * Custom output parsing logic.
155
+ * Called instead of default parsing when provided.
156
+ */
157
+ parseOutput?: (stdout: string) => { text: string; usage?: TokenUsage };
158
+
159
+ /**
160
+ * Custom error classification logic.
161
+ * Called instead of default pattern matching when provided.
162
+ */
163
+ classifyError?: (ctx: ProviderErrorContext) => ProviderErrorKind;
164
+
165
+ /**
166
+ * Custom input handling.
167
+ * Returns the string to pipe to stdin, or undefined to use args only.
168
+ */
169
+ getStdinInput?: (req: CodingRequest) => string | undefined;
170
+ }
171
+
172
+ // Input type for defineProvider (allows partial nested objects)
173
+ export type ProviderDefinitionInput = Omit<
174
+ ProviderDefinition,
175
+ "input" | "output" | "streaming" | "args" | "capabilities"
176
+ > & {
177
+ input?: Partial<z.infer<typeof InputConfigSchema>>;
178
+ output?: Partial<z.infer<typeof OutputConfigSchema>>;
179
+ streaming?: Partial<z.infer<typeof StreamingConfigSchema>>;
180
+ args?: Partial<z.infer<typeof ArgsConfigSchema>>;
181
+ capabilities?: string[];
182
+ };
183
+
184
+ /**
185
+ * Define a CLI provider with full TypeScript support.
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * export default defineProvider({
190
+ * id: "my-cli",
191
+ * binary: "my-cli",
192
+ * input: { method: "positional" },
193
+ * output: { format: "json", textField: "response" },
194
+ * });
195
+ * ```
196
+ */
197
+ export function defineProvider(config: ProviderDefinitionInput): ProviderDefinition {
198
+ // Validate base config with Zod
199
+ const validated = ProviderDefinitionSchema.parse(config);
200
+
201
+ // Merge with function overrides
202
+ return {
203
+ ...validated,
204
+ buildArgs: config.buildArgs,
205
+ parseOutput: config.parseOutput,
206
+ classifyError: config.classifyError,
207
+ getStdinInput: config.getStdinInput,
208
+ };
209
+ }
210
+
211
+ // Re-export from centralized error patterns module
212
+ export { DEFAULT_ERROR_PATTERNS, classifyErrorDefault } from "./error-patterns";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Centralized Error Pattern Registry
3
+ *
4
+ * This module provides a unified set of error patterns for classifying
5
+ * errors across all providers. Providers can extend these patterns
6
+ * with provider-specific patterns.
7
+ */
8
+
9
+ import type { ProviderErrorKind } from "./types";
10
+
11
+ /**
12
+ * Default error patterns used by all providers
13
+ * Patterns are matched case-insensitively against stderr + stdout
14
+ */
15
+ export const DEFAULT_ERROR_PATTERNS: Record<ProviderErrorKind, string[]> = {
16
+ OUT_OF_CREDITS: [
17
+ "quota exceeded",
18
+ "out of quota",
19
+ "out of credits",
20
+ "credits exhausted",
21
+ "insufficient quota",
22
+ "no credits remaining",
23
+ "credit limit",
24
+ "you've exceeded your quota",
25
+ ],
26
+ RATE_LIMIT: ["rate limit", "rate_limit", "too many requests", "429", "throttle", "slow down"],
27
+ BAD_REQUEST: [
28
+ "bad request",
29
+ "invalid request",
30
+ "malformed",
31
+ "400",
32
+ "validation error",
33
+ "invalid parameter",
34
+ ],
35
+ UNAUTHORIZED: [
36
+ "unauthorized",
37
+ "authentication failed",
38
+ "invalid api key",
39
+ "api key invalid",
40
+ "api key missing",
41
+ "401",
42
+ ],
43
+ FORBIDDEN: ["forbidden", "permission denied", "access denied", "403"],
44
+ NOT_FOUND: ["not found", "404", "no such file", "command not found", "model not found"],
45
+ TIMEOUT: ["timeout", "timed out", "deadline exceeded", "request timeout"],
46
+ CONTEXT_LENGTH: [
47
+ "context length",
48
+ "too long",
49
+ "max tokens",
50
+ "token limit",
51
+ "context too large",
52
+ "input too long",
53
+ ],
54
+ CONTENT_FILTER: ["content filter", "blocked", "safety", "moderation", "policy violation"],
55
+ INTERNAL: ["internal error", "internal server error", "server error", "500", "502", "503"],
56
+ TRANSIENT: [
57
+ "temporarily unavailable",
58
+ "service unavailable",
59
+ "connection refused",
60
+ "econnrefused",
61
+ "econnreset",
62
+ "network error",
63
+ "connection reset",
64
+ "socket hang up",
65
+ ],
66
+ UNKNOWN: [],
67
+ };
68
+
69
+ /**
70
+ * Classify an error based on the combined output using default patterns
71
+ */
72
+ export function classifyErrorDefault(
73
+ stderr = "",
74
+ stdout = "",
75
+ exitCode?: number | null,
76
+ httpStatus?: number,
77
+ ): ProviderErrorKind {
78
+ // HTTP status code based classification takes priority
79
+ if (httpStatus) {
80
+ if (httpStatus === 401) return "UNAUTHORIZED";
81
+ if (httpStatus === 403) return "FORBIDDEN";
82
+ if (httpStatus === 404) return "NOT_FOUND";
83
+ if (httpStatus === 429) return "RATE_LIMIT";
84
+ if (httpStatus >= 500) return "INTERNAL";
85
+ }
86
+
87
+ const combined = (stderr + stdout).toLowerCase();
88
+
89
+ for (const [kind, patterns] of Object.entries(DEFAULT_ERROR_PATTERNS)) {
90
+ if (patterns.some((p) => combined.includes(p.toLowerCase()))) {
91
+ return kind as ProviderErrorKind;
92
+ }
93
+ }
94
+
95
+ // Check exit codes for common error types
96
+ if (exitCode === 127) {
97
+ return "NOT_FOUND"; // Command not found
98
+ }
99
+
100
+ return "TRANSIENT";
101
+ }
102
+
103
+ /**
104
+ * Merge provider-specific patterns with default patterns
105
+ */
106
+ export function mergeErrorPatterns(
107
+ providerPatterns: Partial<Record<ProviderErrorKind, string[]>>,
108
+ ): Record<ProviderErrorKind, string[]> {
109
+ const merged = { ...DEFAULT_ERROR_PATTERNS };
110
+
111
+ for (const [kind, patterns] of Object.entries(providerPatterns)) {
112
+ if (patterns) {
113
+ merged[kind as ProviderErrorKind] = [
114
+ ...patterns,
115
+ ...DEFAULT_ERROR_PATTERNS[kind as ProviderErrorKind],
116
+ ];
117
+ }
118
+ }
119
+
120
+ return merged;
121
+ }
122
+
123
+ /**
124
+ * Create a classifier function with merged patterns
125
+ */
126
+ export function createErrorClassifier(
127
+ providerPatterns: Partial<Record<ProviderErrorKind, string[]>> = {},
128
+ ): (stderr?: string, stdout?: string, exitCode?: number | null) => ProviderErrorKind {
129
+ const patterns = mergeErrorPatterns(providerPatterns);
130
+
131
+ return (stderr = "", stdout = "", exitCode?: number | null): ProviderErrorKind => {
132
+ const combined = (stderr + stdout).toLowerCase();
133
+
134
+ for (const [kind, kindPatterns] of Object.entries(patterns)) {
135
+ if (kindPatterns.some((p) => combined.includes(p.toLowerCase()))) {
136
+ return kind as ProviderErrorKind;
137
+ }
138
+ }
139
+
140
+ // Check exit codes for common error types
141
+ if (exitCode === 127) {
142
+ return "NOT_FOUND";
143
+ }
144
+
145
+ return "TRANSIENT";
146
+ };
147
+ }