acpreact 1.0.8 → 1.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # acpreact - ACP SDK
2
2
 
3
- A lightweight SDK for registering tools and running them via kilo CLI or opencode. Allows kilo and opencode to call registered tools via a custom JSON-RPC 2.0 protocol injected into the prompt.
3
+ A lightweight SDK for registering tools and running them via kilo CLI, opencode, or gemini. Supports multi-service fallback with automatic rate-limit detection, per-provider cooldowns, and transparent failover across a service stack.
4
4
 
5
5
  ## Features
6
6
 
@@ -8,7 +8,9 @@ A lightweight SDK for registering tools and running them via kilo CLI or opencod
8
8
  - **Tool Registration**: Register custom tools with descriptions and input schemas
9
9
  - **Tool Whitelist**: Built-in security model for controlling tool access
10
10
  - **Tool Execution**: Execute whitelisted tools with validation and logging
11
- - **CLI Integration**: Works with kilo CLI and opencode via `process()` method
11
+ - **CLI Integration**: Works with kilo CLI, opencode, and gemini via `process()` method
12
+ - **Multi-Service Fallback**: Automatically falls back through a service stack on rate limits
13
+ - **Rate-Limit Detection**: Detects 429, quota errors, and RESOURCE_EXHAUSTED per provider
12
14
  - **ES Module**: Pure ES modules, no build step required
13
15
 
14
16
  ## Prerequisites
@@ -61,6 +63,76 @@ console.log(result.logs); // tool call audit log
61
63
  const result = await acp.process('What is 15 + 27? Use the add tool.', { cli: 'opencode' });
62
64
  ```
63
65
 
66
+ ### Multi-Service Fallback
67
+
68
+ Pass a `services` array to the constructor to enable automatic fallback across providers. On rate limits, the engine marks the current service unavailable and continues to the next:
69
+
70
+ ```javascript
71
+ import { ACPProtocol } from 'acpreact';
72
+
73
+ const acp = new ACPProtocol('You are a calculator.', [
74
+ { cli: 'kilo' },
75
+ { cli: 'opencode' },
76
+ { cli: 'gemini' },
77
+ ]);
78
+
79
+ acp.registerTool('add', 'Add two numbers', { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }, async ({ a, b }) => ({ sum: a + b }));
80
+
81
+ const result = await acp.process('What is 15 + 27? Use the add tool.');
82
+ ```
83
+
84
+ Override services per call:
85
+
86
+ ```javascript
87
+ const result = await acp.process('prompt', {
88
+ services: [{ cli: 'opencode' }, { cli: 'kilo' }],
89
+ });
90
+ ```
91
+
92
+ Multiple profiles per provider (useful for separate API key accounts):
93
+
94
+ ```javascript
95
+ const acp = new ACPProtocol('instruction', [
96
+ { cli: 'kilo', profile: 'account-a' },
97
+ { cli: 'kilo', profile: 'account-b' },
98
+ { cli: 'opencode' },
99
+ ]);
100
+ ```
101
+
102
+ ### createServiceStack
103
+
104
+ Build a typed service stack for use with `FallbackEngine` directly:
105
+
106
+ ```javascript
107
+ import { createServiceStack, FallbackEngine } from 'acpreact';
108
+
109
+ const stack = createServiceStack([
110
+ { cli: 'kilo', profile: 'work' },
111
+ { cli: 'opencode' },
112
+ { cli: 'gemini' },
113
+ ]);
114
+
115
+ const engine = new FallbackEngine(stack);
116
+ ```
117
+
118
+ ### Fallback Events
119
+
120
+ `ACPProtocol` (and `FallbackEngine`) emit events during fallback:
121
+
122
+ ```javascript
123
+ acp.on('rate-limited', ({ name, profileId, cooldownMs }) => {
124
+ console.log(`${name}(${profileId}) rate-limited for ${cooldownMs}ms`);
125
+ });
126
+
127
+ acp.on('fallback', ({ from, to }) => {
128
+ console.log(`Falling back from ${from.name} to ${to.name}`);
129
+ });
130
+
131
+ acp.on('success', ({ name, profileId, attempted }) => {
132
+ console.log(`Succeeded on ${name}(${profileId}) after ${attempted} attempt(s)`);
133
+ });
134
+ ```
135
+
64
136
  ### Using System Instructions
65
137
 
66
138
  ```javascript
@@ -98,8 +170,9 @@ Main class for ACP protocol communication.
98
170
 
99
171
  **Constructor:**
100
172
 
101
- - `new ACPProtocol(instruction?)`: Initialize the protocol
173
+ - `new ACPProtocol(instruction?, services?)`: Initialize the protocol
102
174
  - `instruction` (optional): String - system instruction prepended to every prompt sent to the CLI
175
+ - `services` (optional): Array of `{ cli, profile?, model? }` - instance-level default service stack for multi-service fallback
103
176
 
104
177
  **Methods:**
105
178
 
@@ -110,11 +183,14 @@ Main class for ACP protocol communication.
110
183
  - `handler`: Async function - receives params object, returns result
111
184
  - Returns: Tool definition object
112
185
 
113
- - `async process(text, options?)`: Send a prompt to kilo or opencode and execute any tool calls
186
+ - `async process(text, options?)`: Send a prompt to a CLI and execute any tool calls
114
187
  - `text`: String - the user prompt
115
- - `options.cli`: `'kilo'` (default) or `'opencode'`
188
+ - `options.cli`: `'kilo'` (default), `'opencode'`, or `'gemini'` — used when `options.services` not set
189
+ - `options.services`: Array of `{ cli, profile?, model? }` — enables multi-service fallback for this call; takes precedence over `options.cli`
116
190
  - `options.model`: String - model in `provider/model` format (uses CLI default if omitted)
191
+ - `options.timeout`: Number - per-attempt timeout in ms (default 120000)
117
192
  - Returns: `{ text, rawOutput, toolCalls, logs }` or `{ text, rawOutput, error, logs }` on parse failure
193
+ - Throws `AggregateError` when all services in the stack are exhausted
118
194
 
119
195
  - `createInitializeResponse()`: Generate ACP protocol initialization response with registered tools
120
196
 
package/core.js CHANGED
@@ -1,9 +1,46 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { EventEmitter } from 'events';
3
3
  import { parseTextOutput, parseToolCalls } from './parser.js';
4
+ import { ServiceRegistry, createServiceStack, buildArgs } from './services.js';
5
+ import { FallbackEngine } from './fallback.js';
6
+
7
+ const DEFAULT_TIMEOUT_MS = 120_000;
8
+
9
+ function attachOutputs(err, output, errorOutput) { err.output = output; err.stderr = errorOutput; return err; }
10
+
11
+ function spawnService(entry, prompt, options, callbacks) {
12
+ const abortSignal = options?._abortSignal;
13
+ return new Promise((resolve, reject) => {
14
+ if (abortSignal?.aborted) return reject(new Error('Aborted'));
15
+ const binary = entry.config?.binary || entry.name;
16
+ const args = entry.config?.buildArgs
17
+ ? entry.config.buildArgs(prompt, options)
18
+ : buildArgs(entry.name, prompt, options);
19
+ let output = '', errorOutput = '';
20
+ const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd(), env: { ...process.env } });
21
+ child.stdin.end();
22
+ child.stdout.on('data', (d) => { const c = d.toString(); output += c; callbacks?.onOutput?.(c); });
23
+ child.stderr.on('data', (d) => { const c = d.toString(); errorOutput += c; callbacks?.onStderr?.(c); });
24
+ const timeoutMs = options?.timeout ?? DEFAULT_TIMEOUT_MS;
25
+ const timer = setTimeout(() => {
26
+ child.kill();
27
+ reject(attachOutputs(new Error(`Timeout after ${timeoutMs}ms`), output, errorOutput));
28
+ }, timeoutMs);
29
+ const onAbort = () => { child.kill(); clearTimeout(timer); reject(attachOutputs(new Error('Aborted'), output, errorOutput)); };
30
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
31
+ child.on('close', (code) => {
32
+ clearTimeout(timer);
33
+ abortSignal?.removeEventListener('abort', onAbort);
34
+ if (code !== 0 && code !== null && !output)
35
+ return reject(attachOutputs(new Error(`${binary} exited with code ${code}: ${errorOutput}`), output, errorOutput));
36
+ resolve({ rawOutput: output, stderr: errorOutput, code });
37
+ });
38
+ child.on('error', (err) => { clearTimeout(timer); abortSignal?.removeEventListener('abort', onAbort); reject(attachOutputs(err, output, errorOutput)); });
39
+ });
40
+ }
4
41
 
5
42
  class ACPProtocol extends EventEmitter {
6
- constructor(instruction) {
43
+ constructor(instruction, services) {
7
44
  super();
8
45
  this.messageId = 0;
9
46
  this.instruction = instruction;
@@ -13,11 +50,23 @@ class ACPProtocol extends EventEmitter {
13
50
  this.toolCallLog = [];
14
51
  this.rejectedCallLog = [];
15
52
  this.tools = {};
53
+ this.registry = new ServiceRegistry();
54
+ if (services) {
55
+ for (const svc of services) {
56
+ this.registry.registerService(svc.cli || svc.name, svc);
57
+ }
58
+ }
59
+ this.fallback = new FallbackEngine([]);
60
+ this.fallback.on('rate-limited', (e) => {
61
+ this.registry.markRateLimited(e.name, e.profileId, e.cooldownMs);
62
+ this.emit('rate-limited', e);
63
+ });
64
+ this.fallback.on('fallback', (e) => this.emit('fallback', e));
65
+ this.fallback.on('success', (e) => this.emit('success', e));
66
+ this._abortController = null;
16
67
  }
17
68
 
18
- generateRequestId() {
19
- return ++this.messageId;
20
- }
69
+ generateRequestId() { return ++this.messageId; }
21
70
 
22
71
  registerTool(name, description, inputSchema, handler) {
23
72
  this.toolWhitelist.add(name);
@@ -51,8 +100,7 @@ class ACPProtocol extends EventEmitter {
51
100
 
52
101
  createInitializeResponse() {
53
102
  return {
54
- jsonrpc: '2.0',
55
- id: 1,
103
+ jsonrpc: '2.0', id: 1,
56
104
  result: {
57
105
  tools: this.getToolsList(),
58
106
  instruction: this.instruction,
@@ -65,30 +113,14 @@ class ACPProtocol extends EventEmitter {
65
113
  return { jsonrpc: '2.0', id: this.generateRequestId(), method, params };
66
114
  }
67
115
 
68
- createJsonRpcResponse(id, result) {
69
- return { jsonrpc: '2.0', id, result };
70
- }
116
+ createJsonRpcResponse(id, result) { return { jsonrpc: '2.0', id, result }; }
71
117
 
72
- createJsonRpcError(id, error) {
73
- return {
74
- jsonrpc: '2.0',
75
- id,
76
- error: {
77
- code: error?.code ?? -32000,
78
- message: error instanceof Error ? error.message : String(error),
79
- },
80
- };
81
- }
118
+ createJsonRpcError(id, error) { return { jsonrpc: '2.0', id, error: { code: error?.code ?? -32000, message: error instanceof Error ? error.message : String(error) } }; }
82
119
 
83
120
  validateToolCall(toolName) {
84
121
  if (!this.toolWhitelist.has(toolName)) {
85
122
  const availableTools = Array.from(this.toolWhitelist);
86
- this.rejectedCallLog.push({
87
- timestamp: new Date().toISOString(),
88
- attemptedTool: toolName,
89
- reason: 'Not in whitelist',
90
- availableTools,
91
- });
123
+ this.rejectedCallLog.push({ timestamp: new Date().toISOString(), attemptedTool: toolName, reason: 'Not in whitelist', availableTools });
92
124
  return { allowed: false, error: `Tool not available. Available: ${availableTools.join(', ')}` };
93
125
  }
94
126
  return { allowed: true };
@@ -97,10 +129,8 @@ class ACPProtocol extends EventEmitter {
97
129
  async callTool(toolName, params) {
98
130
  const validation = this.validateToolCall(toolName);
99
131
  if (!validation.allowed) throw new Error(validation.error);
100
-
101
132
  const entry = { timestamp: new Date().toISOString(), toolName, params, status: 'executing' };
102
133
  this.toolCallLog.push(entry);
103
-
104
134
  if (!this.tools[toolName]) throw new Error(`Unknown tool: ${toolName}`);
105
135
  const result = await this.tools[toolName](params);
106
136
  entry.status = 'completed';
@@ -108,70 +138,52 @@ class ACPProtocol extends EventEmitter {
108
138
  return result;
109
139
  }
110
140
 
111
- parseTextOutput(output) {
112
- return parseTextOutput(output);
113
- }
114
-
115
- parseToolCalls(output) {
116
- return parseToolCalls(output);
117
- }
141
+ parseTextOutput(output) { return parseTextOutput(output); }
142
+ parseToolCalls(output) { return parseToolCalls(output); }
118
143
 
119
144
  async process(text, options = {}) {
120
- const cli = options.cli || 'kilo';
121
- const model = options.model;
122
145
  const fullPrompt = this.instruction
123
146
  ? `${this.instruction}${this.getToolsPrompt()}\n\n---\n\n${text}`
124
147
  : `${this.getToolsPrompt()}\n\n---\n\n${text}`;
125
148
 
126
- const args = ['run', '--format', 'json'];
127
- if (cli === 'kilo') args.push('--auto');
128
- if (model) args.push('--model', model);
129
- args.push(fullPrompt);
130
-
131
- return new Promise((resolve, reject) => {
132
- let output = '';
133
- let errorOutput = '';
134
-
135
- const child = spawn(cli, args, {
136
- stdio: ['pipe', 'pipe', 'pipe'],
137
- cwd: process.cwd(),
138
- env: { ...process.env },
139
- });
140
-
141
- child.stdin.end();
142
- child.stdout.on('data', (data) => { output += data.toString(); });
143
- child.stderr.on('data', (data) => { errorOutput += data.toString(); });
144
-
145
- child.on('close', async (code) => {
146
- if (code !== 0 && code !== null && !output) {
147
- reject(new Error(`CLI exited with code ${code}: ${errorOutput}`));
148
- return;
149
- }
150
- try {
151
- const toolCalls = parseToolCalls(output);
152
- const results = [];
153
- for (const call of toolCalls) {
154
- const toolName = call.method.replace('tools/', '');
155
- if (this.toolWhitelist.has(toolName)) {
156
- const result = await this.callTool(toolName, call.params);
157
- results.push({ tool: toolName, result });
158
- }
159
- }
160
- resolve({ text: parseTextOutput(output), rawOutput: output, toolCalls: results, logs: this.toolCallLog });
161
- } catch (e) {
162
- resolve({ text: parseTextOutput(output), rawOutput: output, error: e.message, logs: this.toolCallLog });
163
- }
164
- });
165
-
166
- child.on('error', (error) => {
167
- reject(new Error(`Failed to spawn ${cli}: ${error.message}`));
168
- });
149
+ let stack;
150
+ if (options.services) {
151
+ stack = createServiceStack(options.services);
152
+ } else if (options.cli) {
153
+ stack = [{ name: options.cli, profileId: '__default__', config: { cli: options.cli } }];
154
+ } else {
155
+ stack = this.registry.getAll().length > 0
156
+ ? this.registry.getAvailable()
157
+ : [{ name: 'kilo', profileId: '__default__', config: { cli: 'kilo' } }];
158
+ }
169
159
 
170
- setTimeout(() => { child.kill(); reject(new Error('Timeout')); }, 120000);
171
- });
160
+ this._abortController = new AbortController();
161
+ const runOptions = { ...options, _abortSignal: this._abortController.signal };
162
+ const engine = new FallbackEngine(stack);
163
+ engine.on('rate-limited', (e) => this.fallback.emit('rate-limited', e));
164
+ engine.on('fallback', (e) => this.fallback.emit('fallback', e));
165
+ engine.on('success', (e) => this.fallback.emit('success', e));
166
+
167
+ const { rawOutput } = await engine.run(spawnService, fullPrompt, runOptions);
168
+ this._abortController = null;
169
+
170
+ try {
171
+ const toolCalls = parseToolCalls(rawOutput);
172
+ const results = [];
173
+ for (const call of toolCalls) {
174
+ const toolName = call.method.replace('tools/', '');
175
+ if (this.toolWhitelist.has(toolName)) {
176
+ const result = await this.callTool(toolName, call.params);
177
+ results.push({ tool: toolName, result });
178
+ }
179
+ }
180
+ return { text: parseTextOutput(rawOutput), rawOutput, toolCalls: results, logs: this.toolCallLog };
181
+ } catch (e) {
182
+ return { text: parseTextOutput(rawOutput), rawOutput, error: e.message, logs: this.toolCallLog };
183
+ }
172
184
  }
173
185
 
174
- stop() {}
186
+ stop() { if (this._abortController) { this._abortController.abort(); this._abortController = null; } }
175
187
  }
176
188
 
177
189
  export { ACPProtocol };
package/fallback.js ADDED
@@ -0,0 +1,66 @@
1
+ import { EventEmitter } from 'events';
2
+ import { isRateLimited, DEFAULT_COOLDOWN_MS } from './services.js';
3
+
4
+ class FallbackEngine extends EventEmitter {
5
+ constructor(serviceStack = []) {
6
+ super();
7
+ this._stack = serviceStack;
8
+ }
9
+
10
+ async run(spawnFn, text, options = {}) {
11
+ if (this._stack.length === 0) {
12
+ throw new Error('FallbackEngine: service stack is empty');
13
+ }
14
+
15
+ const errors = [];
16
+ let attempted = 0;
17
+
18
+ for (const entry of this._stack) {
19
+ const { name, profileId } = entry;
20
+ attempted++;
21
+
22
+ let result;
23
+ let spawnError;
24
+ let output = '';
25
+ let stderr = '';
26
+
27
+ try {
28
+ result = await spawnFn(entry, text, options, {
29
+ onOutput: (chunk) => { output += chunk; },
30
+ onStderr: (chunk) => { stderr += chunk; },
31
+ });
32
+ } catch (err) {
33
+ spawnError = err;
34
+ output = err.output || '';
35
+ stderr = err.stderr || '';
36
+ }
37
+
38
+ const rlCheck = isRateLimited(name, output, stderr);
39
+ const isMissing = spawnError?.code === 'ENOENT';
40
+
41
+ if (!spawnError && !rlCheck.rateLimited) {
42
+ this.emit('success', { name, profileId, attempted });
43
+ return result;
44
+ }
45
+
46
+ if (rlCheck.rateLimited || isMissing) {
47
+ const cooldownMs = rlCheck.retryAfterMs ?? DEFAULT_COOLDOWN_MS;
48
+ this.emit('rate-limited', { name, profileId, cooldownMs, error: spawnError?.message });
49
+ errors.push({ name, profileId, rateLimited: true, retryAfterMs: cooldownMs, error: spawnError?.message });
50
+ const remaining = this._stack.slice(attempted);
51
+ if (remaining.length > 0) this.emit('fallback', { from: { name, profileId }, to: remaining[0] });
52
+ continue;
53
+ }
54
+
55
+ throw spawnError || new Error(`Service ${name} failed: ${output}${stderr}`);
56
+ }
57
+
58
+ const summary = errors.map(e => `${e.name}(${e.profileId}): rate-limited`).join(', ');
59
+ throw new AggregateError(
60
+ errors.map(e => Object.assign(new Error(`${e.name} rate-limited`), e)),
61
+ `All services exhausted: ${summary}`
62
+ );
63
+ }
64
+ }
65
+
66
+ export { FallbackEngine };
package/index.js CHANGED
@@ -1,28 +1,49 @@
1
1
  import { ACPProtocol } from './core.js';
2
+ import { ServiceRegistry, isRateLimited, createServiceStack, buildArgs, DEFAULT_COOLDOWN_MS } from './services.js';
3
+ import { FallbackEngine } from './fallback.js';
2
4
 
3
- export { ACPProtocol };
5
+ export { ACPProtocol, ServiceRegistry, FallbackEngine, isRateLimited, createServiceStack, buildArgs, DEFAULT_COOLDOWN_MS };
4
6
 
5
7
  /*
6
- * acpreact - ACP SDK for registering tools
8
+ * acpreact - ACP SDK for registering tools with multi-service fallback
7
9
  *
8
- * Basic usage:
10
+ * Basic usage (single service, backward compatible):
9
11
  * import { ACPProtocol } from 'acpreact';
12
+ * const acp = new ACPProtocol('Your instruction');
13
+ * const result = await acp.process('prompt', { cli: 'kilo' });
10
14
  *
11
- * Create ACP Protocol instance:
12
- * const acp = new ACPProtocol();
15
+ * Multi-service fallback pass a services array to the constructor:
16
+ * const acp = new ACPProtocol('Your instruction', [
17
+ * { cli: 'kilo' },
18
+ * { cli: 'opencode' },
19
+ * { cli: 'gemini' },
20
+ * ]);
21
+ * const result = await acp.process('prompt');
13
22
  *
14
- * Register custom tools:
15
- * acp.registerTool('my_tool', 'Tool description', {
16
- * type: 'object',
17
- * properties: { query: { type: 'string' } },
18
- * required: ['query']
19
- * }, async (params) => {
20
- * return { result: 'processed' };
23
+ * Per-call override:
24
+ * const result = await acp.process('prompt', {
25
+ * services: [{ cli: 'opencode' }, { cli: 'kilo' }],
21
26
  * });
22
27
  *
23
- * Initialize protocol:
24
- * const response = acp.createInitializeResponse();
28
+ * Custom ACP-compatible provider:
29
+ * acp.registry.registerService('my-agent', {
30
+ * binary: 'my-agent',
31
+ * buildArgs: (prompt, opts) => ['run', prompt],
32
+ * });
33
+ *
34
+ * Multiple profiles per provider (different logins):
35
+ * const acp = new ACPProtocol('instruction', [
36
+ * { cli: 'kilo', profile: 'account-a' },
37
+ * { cli: 'kilo', profile: 'account-b' },
38
+ * { cli: 'opencode', profile: 'account-c' },
39
+ * ]);
40
+ *
41
+ * Listen to fallback events:
42
+ * acp.on('rate-limited', ({ name, profileId, cooldownMs }) => { ... });
43
+ * acp.on('fallback', ({ from, to }) => { ... });
44
+ * acp.on('success', ({ name, profileId, attempted }) => { ... });
25
45
  *
26
- * Execute tool:
27
- * const result = await acp.callTool('my_tool', { query: 'test' });
46
+ * Build a service stack manually:
47
+ * import { createServiceStack, ServiceRegistry, FallbackEngine } from 'acpreact';
48
+ * const stack = createServiceStack([{ cli: 'kilo' }, { cli: 'gemini' }]);
28
49
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acpreact",
3
- "version": "1.0.8",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "ACP SDK for monitoring and reacting to chat conversations",
package/services.js ADDED
@@ -0,0 +1,139 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ const DEFAULT_COOLDOWN_MS = 60_000;
4
+
5
+ const RATE_LIMIT_PATTERNS = {
6
+ common: [
7
+ /\b429\b/,
8
+ /rate.?limit/i,
9
+ /quota.?exceeded/i,
10
+ /too.?many.?requests/i,
11
+ ],
12
+ gemini: [/RESOURCE_EXHAUSTED/],
13
+ kilo: [],
14
+ opencode: [],
15
+ };
16
+
17
+ const RETRY_AFTER_PATTERN = /retry.?after[:\s]+(\d+)/i;
18
+
19
+ const BUILTIN_ARG_BUILDERS = {
20
+ kilo: (prompt, options) => {
21
+ const args = ['run', '--format', 'json', '--auto'];
22
+ if (options?.model) args.push('--model', options.model);
23
+ args.push(prompt);
24
+ return args;
25
+ },
26
+ opencode: (prompt, options) => {
27
+ const args = ['run', '--format', 'json'];
28
+ if (options?.model) args.push('--model', options.model);
29
+ args.push(prompt);
30
+ return args;
31
+ },
32
+ gemini: (prompt, options) => {
33
+ const args = ['run'];
34
+ if (options?.model) args.push('--model', options.model);
35
+ args.push(prompt);
36
+ return args;
37
+ },
38
+ };
39
+
40
+ function buildArgs(providerName, prompt, options) {
41
+ const builder = BUILTIN_ARG_BUILDERS[providerName];
42
+ if (builder) return builder(prompt, options);
43
+ const args = ['run'];
44
+ if (options?.model) args.push('--model', options.model);
45
+ args.push(prompt);
46
+ return args;
47
+ }
48
+
49
+ function isRateLimited(providerName, output = '', stderr = '') {
50
+ const combined = `${output}\n${stderr}`;
51
+ const patterns = [
52
+ ...RATE_LIMIT_PATTERNS.common,
53
+ ...(RATE_LIMIT_PATTERNS[providerName] || []),
54
+ ];
55
+
56
+ let rateLimited = false;
57
+ let retryAfterMs;
58
+
59
+ for (const pattern of patterns) {
60
+ if (pattern.test(combined)) {
61
+ rateLimited = true;
62
+ break;
63
+ }
64
+ }
65
+
66
+ if (rateLimited) {
67
+ const match = combined.match(RETRY_AFTER_PATTERN);
68
+ if (match) retryAfterMs = parseInt(match[1], 10) * 1000;
69
+ }
70
+
71
+ return retryAfterMs !== undefined ? { rateLimited, retryAfterMs } : { rateLimited };
72
+ }
73
+
74
+ function createServiceStack(configs) {
75
+ return configs.map(cfg => ({
76
+ name: cfg.cli || cfg.name,
77
+ profileId: cfg.profile ?? '__default__',
78
+ config: cfg,
79
+ }));
80
+ }
81
+
82
+ class ServiceRegistry extends EventEmitter {
83
+ constructor() {
84
+ super();
85
+ this._services = [];
86
+ this._cooldowns = new Map();
87
+ }
88
+
89
+ _cooldownKey(name, profileId) {
90
+ return `${name}::${profileId ?? '__default__'}`;
91
+ }
92
+
93
+ registerService(name, config = {}) {
94
+ const profileId = config.profile ?? '__default__';
95
+ const existing = this._services.findIndex(
96
+ s => s.name === name && s.profileId === profileId
97
+ );
98
+ const entry = { name, profileId, config };
99
+ if (existing >= 0) {
100
+ this._services[existing] = entry;
101
+ } else {
102
+ this._services.push(entry);
103
+ }
104
+ this._cooldowns.delete(this._cooldownKey(name, profileId));
105
+ return this;
106
+ }
107
+
108
+ markRateLimited(name, profileId, cooldownMs = DEFAULT_COOLDOWN_MS) {
109
+ const key = this._cooldownKey(name, profileId ?? '__default__');
110
+ const expiry = cooldownMs === 0 ? 0 : Date.now() + cooldownMs;
111
+ this._cooldowns.set(key, expiry);
112
+ this.emit('rate-limited', { name, profileId, cooldownMs, expiry });
113
+ }
114
+
115
+ clearCooldown(name, profileId) {
116
+ this._cooldowns.delete(this._cooldownKey(name, profileId ?? '__default__'));
117
+ }
118
+
119
+ isAvailable(name, profileId) {
120
+ const key = this._cooldownKey(name, profileId ?? '__default__');
121
+ if (!this._cooldowns.has(key)) return true;
122
+ const expiry = this._cooldowns.get(key);
123
+ if (expiry === 0 || Date.now() >= expiry) {
124
+ this._cooldowns.delete(key);
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ getAvailable() {
131
+ return this._services.filter(s => this.isAvailable(s.name, s.profileId));
132
+ }
133
+
134
+ getAll() {
135
+ return [...this._services];
136
+ }
137
+ }
138
+
139
+ export { ServiceRegistry, isRateLimited, createServiceStack, buildArgs, DEFAULT_COOLDOWN_MS };