@soleri/forge 5.1.2 → 5.2.0
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/dist/index.js +0 -0
- package/dist/templates/activate.js +10 -2
- package/dist/templates/activate.js.map +1 -1
- package/dist/templates/brain.d.ts +6 -0
- package/dist/templates/brain.js +478 -0
- package/dist/templates/brain.js.map +1 -0
- package/dist/templates/core-facade.d.ts +6 -0
- package/dist/templates/core-facade.js +564 -0
- package/dist/templates/core-facade.js.map +1 -0
- package/dist/templates/facade-factory.d.ts +1 -0
- package/dist/templates/facade-factory.js +63 -0
- package/dist/templates/facade-factory.js.map +1 -0
- package/dist/templates/facade-types.d.ts +1 -0
- package/dist/templates/facade-types.js +46 -0
- package/dist/templates/facade-types.js.map +1 -0
- package/dist/templates/inject-claude-md.js +36 -0
- package/dist/templates/inject-claude-md.js.map +1 -1
- package/dist/templates/intelligence-loader.d.ts +1 -0
- package/dist/templates/intelligence-loader.js +43 -0
- package/dist/templates/intelligence-loader.js.map +1 -0
- package/dist/templates/intelligence-types.d.ts +1 -0
- package/dist/templates/intelligence-types.js +24 -0
- package/dist/templates/intelligence-types.js.map +1 -0
- package/dist/templates/llm-client.d.ts +7 -0
- package/dist/templates/llm-client.js +300 -0
- package/dist/templates/llm-client.js.map +1 -0
- package/dist/templates/llm-key-pool.d.ts +7 -0
- package/dist/templates/llm-key-pool.js +211 -0
- package/dist/templates/llm-key-pool.js.map +1 -0
- package/dist/templates/llm-types.d.ts +5 -0
- package/dist/templates/llm-types.js +161 -0
- package/dist/templates/llm-types.js.map +1 -0
- package/dist/templates/llm-utils.d.ts +5 -0
- package/dist/templates/llm-utils.js +260 -0
- package/dist/templates/llm-utils.js.map +1 -0
- package/dist/templates/planner.d.ts +5 -0
- package/dist/templates/planner.js +150 -0
- package/dist/templates/planner.js.map +1 -0
- package/dist/templates/test-brain.d.ts +6 -0
- package/dist/templates/test-brain.js +474 -0
- package/dist/templates/test-brain.js.map +1 -0
- package/dist/templates/test-llm.d.ts +7 -0
- package/dist/templates/test-llm.js +574 -0
- package/dist/templates/test-llm.js.map +1 -0
- package/dist/templates/test-loader.d.ts +5 -0
- package/dist/templates/test-loader.js +146 -0
- package/dist/templates/test-loader.js.map +1 -0
- package/dist/templates/test-planner.d.ts +5 -0
- package/dist/templates/test-planner.js +271 -0
- package/dist/templates/test-planner.js.map +1 -0
- package/dist/templates/test-vault.d.ts +5 -0
- package/dist/templates/test-vault.js +380 -0
- package/dist/templates/test-vault.js.map +1 -0
- package/dist/templates/vault.d.ts +5 -0
- package/dist/templates/vault.js +263 -0
- package/dist/templates/vault.js.map +1 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/templates/activate.ts +10 -2
- package/src/templates/inject-claude-md.ts +36 -0
- package/src/types.ts +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"facade-types.js","sourceRoot":"","sources":["../../src/templates/facade-types.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,mBAAmB;IACjC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CR,CAAC;AACF,CAAC"}
|
|
@@ -80,6 +80,42 @@ export function generateInjectClaudeMd(config) {
|
|
|
80
80
|
'}',
|
|
81
81
|
'',
|
|
82
82
|
'/**',
|
|
83
|
+
' * Remove agent section from a specific CLAUDE.md file.',
|
|
84
|
+
' * Returns true if the section was found and removed.',
|
|
85
|
+
' */',
|
|
86
|
+
'function removeFromFile(filePath: string): { removed: boolean; path: string } {',
|
|
87
|
+
' if (!existsSync(filePath)) return { removed: false, path: filePath };',
|
|
88
|
+
" const existing = readFileSync(filePath, 'utf-8');",
|
|
89
|
+
' const marker = getClaudeMdMarker();',
|
|
90
|
+
` const startMarker = '<!-- ' + marker + ' -->';`,
|
|
91
|
+
` const endMarker = '<!-- /' + marker + ' -->';`,
|
|
92
|
+
' const startIdx = existing.indexOf(startMarker);',
|
|
93
|
+
' if (startIdx === -1) return { removed: false, path: filePath };',
|
|
94
|
+
' const endIdx = existing.indexOf(endMarker);',
|
|
95
|
+
' if (endIdx === -1) return { removed: false, path: filePath };',
|
|
96
|
+
" const before = existing.slice(0, startIdx).replace(/\\n+$/, '');",
|
|
97
|
+
" const after = existing.slice(endIdx + endMarker.length).replace(/^\\n+/, '');",
|
|
98
|
+
" const cleaned = before + (before && after ? '\\n\\n' : '') + after;",
|
|
99
|
+
" writeFileSync(filePath, cleaned || '', 'utf-8');",
|
|
100
|
+
' return { removed: true, path: filePath };',
|
|
101
|
+
'}',
|
|
102
|
+
'',
|
|
103
|
+
'/**',
|
|
104
|
+
' * Remove agent section from the global ~/.claude/CLAUDE.md.',
|
|
105
|
+
' * Called on deactivation to clean up after test agents.',
|
|
106
|
+
' */',
|
|
107
|
+
'export function removeClaudeMdGlobal(): { removed: boolean; path: string } {',
|
|
108
|
+
" return removeFromFile(join(homedir(), '.claude', 'CLAUDE.md'));",
|
|
109
|
+
'}',
|
|
110
|
+
'',
|
|
111
|
+
'/**',
|
|
112
|
+
' * Remove agent section from a project CLAUDE.md.',
|
|
113
|
+
' */',
|
|
114
|
+
'export function removeClaudeMd(projectPath: string): { removed: boolean; path: string } {',
|
|
115
|
+
" return removeFromFile(join(projectPath, 'CLAUDE.md'));",
|
|
116
|
+
'}',
|
|
117
|
+
'',
|
|
118
|
+
'/**',
|
|
83
119
|
' * Check if the agent marker exists in a CLAUDE.md file.',
|
|
84
120
|
' */',
|
|
85
121
|
'export function hasAgentMarker(filePath: string): boolean {',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inject-claude-md.js","sourceRoot":"","sources":["../../src/templates/inject-claude-md.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,MAAM,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,OAAO,CAAC;IAEnC,OAAO;QACL,+EAA+E;QAC/E,oCAAoC;QACpC,mCAAmC;QACnC,iFAAiF;QACjF,EAAE;QACF,iCAAiC;QACjC,sBAAsB;QACtB,iBAAiB;QACjB,+CAA+C;QAC/C,GAAG;QACH,EAAE;QACF,KAAK;QACL,gDAAgD;QAChD,8DAA8D;QAC9D,KAAK;QACL,2DAA2D;QAC3D,yCAAyC;QACzC,uCAAuC;QACvC,kDAAkD;QAClD,iDAAiD;QACjD,EAAE;QACF,gCAAgC;QAChC,wDAAwD;QACxD,mEAAmE;QACnE,KAAK;QACL,EAAE;QACF,qDAAqD;QACrD,EAAE;QACF,yCAAyC;QACzC,qDAAqD;QACrD,iDAAiD;QACjD,0BAA0B;QAC1B,sHAAsH;QACtH,kDAAkD;QAClD,qEAAqE;QACrE,OAAO;QACP,wGAAwG;QACxG,gDAAgD;QAChD,mEAAmE;QACnE,KAAK;QACL,EAAE;QACF,kEAAkE;QAClE,6EAA6E;QAC7E,kEAAkE;QAClE,GAAG;QACH,EAAE;QACF,KAAK;QACL,mDAAmD;QACnD,0CAA0C;QAC1C,+DAA+D;QAC/D,4CAA4C;QAC5C,KAAK;QACL,qEAAqE;QACrE,0DAA0D;QAC1D,GAAG;QACH,EAAE;QACF,KAAK;QACL,wEAAwE;QACxE,sDAAsD;QACtD,0DAA0D;QAC1D,KAAK;QACL,wDAAwD;QACxD,iDAAiD;QACjD,iCAAiC;QACjC,gDAAgD;QAChD,KAAK;QACL,wDAAwD;QACxD,GAAG;QACH,EAAE;QACF,KAAK;QACL,0DAA0D;QAC1D,KAAK;QACL,6DAA6D;QAC7D,4CAA4C;QAC5C,oDAAoD;QACpD,mCAAmC,MAAM,SAAS;QAClD,GAAG;KACJ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
|
|
1
|
+
{"version":3,"file":"inject-claude-md.js","sourceRoot":"","sources":["../../src/templates/inject-claude-md.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,MAAM,MAAM,GAAG,GAAG,MAAM,CAAC,EAAE,OAAO,CAAC;IAEnC,OAAO;QACL,+EAA+E;QAC/E,oCAAoC;QACpC,mCAAmC;QACnC,iFAAiF;QACjF,EAAE;QACF,iCAAiC;QACjC,sBAAsB;QACtB,iBAAiB;QACjB,+CAA+C;QAC/C,GAAG;QACH,EAAE;QACF,KAAK;QACL,gDAAgD;QAChD,8DAA8D;QAC9D,KAAK;QACL,2DAA2D;QAC3D,yCAAyC;QACzC,uCAAuC;QACvC,kDAAkD;QAClD,iDAAiD;QACjD,EAAE;QACF,gCAAgC;QAChC,wDAAwD;QACxD,mEAAmE;QACnE,KAAK;QACL,EAAE;QACF,qDAAqD;QACrD,EAAE;QACF,yCAAyC;QACzC,qDAAqD;QACrD,iDAAiD;QACjD,0BAA0B;QAC1B,sHAAsH;QACtH,kDAAkD;QAClD,qEAAqE;QACrE,OAAO;QACP,wGAAwG;QACxG,gDAAgD;QAChD,mEAAmE;QACnE,KAAK;QACL,EAAE;QACF,kEAAkE;QAClE,6EAA6E;QAC7E,kEAAkE;QAClE,GAAG;QACH,EAAE;QACF,KAAK;QACL,mDAAmD;QACnD,0CAA0C;QAC1C,+DAA+D;QAC/D,4CAA4C;QAC5C,KAAK;QACL,qEAAqE;QACrE,0DAA0D;QAC1D,GAAG;QACH,EAAE;QACF,KAAK;QACL,wEAAwE;QACxE,sDAAsD;QACtD,0DAA0D;QAC1D,KAAK;QACL,wDAAwD;QACxD,iDAAiD;QACjD,iCAAiC;QACjC,gDAAgD;QAChD,KAAK;QACL,wDAAwD;QACxD,GAAG;QACH,EAAE;QACF,KAAK;QACL,yDAAyD;QACzD,uDAAuD;QACvD,KAAK;QACL,iFAAiF;QACjF,yEAAyE;QACzE,qDAAqD;QACrD,uCAAuC;QACvC,kDAAkD;QAClD,iDAAiD;QACjD,mDAAmD;QACnD,mEAAmE;QACnE,+CAA+C;QAC/C,iEAAiE;QACjE,oEAAoE;QACpE,iFAAiF;QACjF,uEAAuE;QACvE,oDAAoD;QACpD,6CAA6C;QAC7C,GAAG;QACH,EAAE;QACF,KAAK;QACL,8DAA8D;QAC9D,0DAA0D;QAC1D,KAAK;QACL,8EAA8E;QAC9E,mEAAmE;QACnE,GAAG;QACH,EAAE;QACF,KAAK;QACL,mDAAmD;QACnD,KAAK;QACL,2FAA2F;QAC3F,0DAA0D;QAC1D,GAAG;QACH,EAAE;QACF,KAAK;QACL,0DAA0D;QAC1D,KAAK;QACL,6DAA6D;QAC7D,4CAA4C;QAC5C,oDAAoD;QACpD,mCAAmC,MAAM,SAAS;QAClD,GAAG;KACJ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateIntelligenceLoader(): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function generateIntelligenceLoader() {
|
|
2
|
+
return `import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { IntelligenceBundle, IntelligenceEntry } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function loadIntelligenceData(dataDir: string): IntelligenceEntry[] {
|
|
7
|
+
const entries: IntelligenceEntry[] = [];
|
|
8
|
+
let files: string[];
|
|
9
|
+
try {
|
|
10
|
+
files = readdirSync(dataDir).filter((f) => f.endsWith('.json'));
|
|
11
|
+
} catch {
|
|
12
|
+
console.warn('Intelligence data directory not found: ' + dataDir);
|
|
13
|
+
return entries;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(join(dataDir, file), 'utf-8');
|
|
19
|
+
const bundle = JSON.parse(raw) as IntelligenceBundle;
|
|
20
|
+
if (!bundle.entries || !Array.isArray(bundle.entries)) continue;
|
|
21
|
+
for (const entry of bundle.entries) {
|
|
22
|
+
if (validateEntry(entry)) entries.push(entry);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.warn('Failed to load ' + file + ': ' + (err instanceof Error ? err.message : err));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return entries;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateEntry(entry: IntelligenceEntry): boolean {
|
|
32
|
+
return (
|
|
33
|
+
typeof entry.id === 'string' && entry.id.length > 0 &&
|
|
34
|
+
['pattern', 'anti-pattern', 'rule'].includes(entry.type) &&
|
|
35
|
+
typeof entry.title === 'string' && entry.title.length > 0 &&
|
|
36
|
+
typeof entry.description === 'string' && entry.description.length > 0 &&
|
|
37
|
+
['critical', 'warning', 'suggestion'].includes(entry.severity) &&
|
|
38
|
+
Array.isArray(entry.tags)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=intelligence-loader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intelligence-loader.js","sourceRoot":"","sources":["../../src/templates/intelligence-loader.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,0BAA0B;IACxC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateIntelligenceTypes(): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function generateIntelligenceTypes() {
|
|
2
|
+
return `export interface IntelligenceEntry {
|
|
3
|
+
id: string;
|
|
4
|
+
type: 'pattern' | 'anti-pattern' | 'rule';
|
|
5
|
+
domain: string;
|
|
6
|
+
title: string;
|
|
7
|
+
severity: 'critical' | 'warning' | 'suggestion';
|
|
8
|
+
description: string;
|
|
9
|
+
context?: string;
|
|
10
|
+
example?: string;
|
|
11
|
+
counterExample?: string;
|
|
12
|
+
why?: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
appliesTo?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IntelligenceBundle {
|
|
18
|
+
domain: string;
|
|
19
|
+
version: string;
|
|
20
|
+
entries: IntelligenceEntry[];
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=intelligence-types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intelligence-types.js","sourceRoot":"","sources":["../../src/templates/intelligence-types.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,yBAAyB;IACvC,OAAO;;;;;;;;;;;;;;;;;;;;CAoBR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate the LLM client file for a new agent.
|
|
4
|
+
* Contains LLMClient (OpenAI fetch + Anthropic SDK) and ModelRouter (inlined).
|
|
5
|
+
* Uses config.id to resolve ~/.{agentId}/model-routing.json.
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateLLMClient(config: AgentConfig): string;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the LLM client file for a new agent.
|
|
3
|
+
* Contains LLMClient (OpenAI fetch + Anthropic SDK) and ModelRouter (inlined).
|
|
4
|
+
* Uses config.id to resolve ~/.{agentId}/model-routing.json.
|
|
5
|
+
*/
|
|
6
|
+
export function generateLLMClient(config) {
|
|
7
|
+
return `/**
|
|
8
|
+
* LLM Client — Unified OpenAI/Anthropic caller with key pool rotation
|
|
9
|
+
* Generated by Soleri — do not edit manually.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
13
|
+
import { SecretString, LLMError, CircuitBreaker, retry, parseRateLimitHeaders } from '@soleri/core';
|
|
14
|
+
import type {
|
|
15
|
+
LLMCallOptions,
|
|
16
|
+
LLMCallResult,
|
|
17
|
+
RouteEntry,
|
|
18
|
+
RoutingConfig,
|
|
19
|
+
KeyPool,
|
|
20
|
+
} from '@soleri/core';
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// CONSTANTS
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// MODEL ROUTER (inlined)
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
function loadRoutingConfig(): RoutingConfig {
|
|
36
|
+
const defaultConfig: RoutingConfig = {
|
|
37
|
+
routes: [],
|
|
38
|
+
defaultOpenAIModel: 'gpt-4o-mini',
|
|
39
|
+
defaultAnthropicModel: 'claude-sonnet-4-20250514',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const configPath = path.join(
|
|
43
|
+
homedir(),
|
|
44
|
+
'.${config.id}',
|
|
45
|
+
'model-routing.json',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(configPath)) {
|
|
50
|
+
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Partial<RoutingConfig>;
|
|
51
|
+
if (data.routes && Array.isArray(data.routes)) {
|
|
52
|
+
defaultConfig.routes = data.routes;
|
|
53
|
+
}
|
|
54
|
+
if (data.defaultOpenAIModel) {
|
|
55
|
+
defaultConfig.defaultOpenAIModel = data.defaultOpenAIModel;
|
|
56
|
+
}
|
|
57
|
+
if (data.defaultAnthropicModel) {
|
|
58
|
+
defaultConfig.defaultAnthropicModel = data.defaultAnthropicModel;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Config not available — use defaults
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return defaultConfig;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inferProvider(model: string): 'openai' | 'anthropic' {
|
|
69
|
+
if (model.startsWith('claude-') || model.startsWith('anthropic/')) {
|
|
70
|
+
return 'anthropic';
|
|
71
|
+
}
|
|
72
|
+
return 'openai';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class ModelRouter {
|
|
76
|
+
private config: RoutingConfig;
|
|
77
|
+
|
|
78
|
+
constructor(config?: RoutingConfig) {
|
|
79
|
+
this.config = config || loadRoutingConfig();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
resolve(
|
|
83
|
+
caller: string,
|
|
84
|
+
task?: string,
|
|
85
|
+
originalModel?: string,
|
|
86
|
+
): { model: string; provider: 'openai' | 'anthropic' } {
|
|
87
|
+
if (task) {
|
|
88
|
+
const exactMatch = this.config.routes.find(
|
|
89
|
+
(r) => r.caller === caller && r.task === task,
|
|
90
|
+
);
|
|
91
|
+
if (exactMatch) {
|
|
92
|
+
return { model: exactMatch.model, provider: exactMatch.provider };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const callerMatch = this.config.routes.find(
|
|
97
|
+
(r) => r.caller === caller && !r.task,
|
|
98
|
+
);
|
|
99
|
+
if (callerMatch) {
|
|
100
|
+
return { model: callerMatch.model, provider: callerMatch.provider };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (originalModel) {
|
|
104
|
+
const provider = inferProvider(originalModel);
|
|
105
|
+
return { model: originalModel, provider };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { model: this.config.defaultOpenAIModel, provider: 'openai' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getRoutes(): RouteEntry[] {
|
|
112
|
+
return [...this.config.routes];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// LLM CLIENT
|
|
118
|
+
// =============================================================================
|
|
119
|
+
|
|
120
|
+
export class LLMClient {
|
|
121
|
+
private openaiKeyPool: KeyPool;
|
|
122
|
+
private anthropicKeyPool: KeyPool;
|
|
123
|
+
private anthropicClient: Anthropic | null = null;
|
|
124
|
+
private anthropicBreaker: CircuitBreaker;
|
|
125
|
+
private anthropicKeyFingerprint: string = '';
|
|
126
|
+
private router: ModelRouter;
|
|
127
|
+
|
|
128
|
+
constructor(openaiKeyPool: KeyPool, anthropicKeyPool: KeyPool) {
|
|
129
|
+
this.openaiKeyPool = openaiKeyPool;
|
|
130
|
+
this.anthropicKeyPool = anthropicKeyPool;
|
|
131
|
+
this.anthropicBreaker = new CircuitBreaker({
|
|
132
|
+
name: 'llm-anthropic',
|
|
133
|
+
failureThreshold: 5,
|
|
134
|
+
resetTimeoutMs: 60_000,
|
|
135
|
+
});
|
|
136
|
+
this.router = new ModelRouter();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async complete(options: LLMCallOptions): Promise<LLMCallResult> {
|
|
140
|
+
const routed = this.router.resolve(
|
|
141
|
+
options.caller,
|
|
142
|
+
options.task,
|
|
143
|
+
options.model,
|
|
144
|
+
);
|
|
145
|
+
const resolvedOptions = { ...options, model: routed.model, provider: routed.provider };
|
|
146
|
+
|
|
147
|
+
return resolvedOptions.provider === 'anthropic'
|
|
148
|
+
? this.callAnthropic(resolvedOptions)
|
|
149
|
+
: this.callOpenAI(resolvedOptions);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
isAvailable(): { openai: boolean; anthropic: boolean } {
|
|
153
|
+
return {
|
|
154
|
+
openai: this.openaiKeyPool.hasKeys,
|
|
155
|
+
anthropic: this.anthropicKeyPool.hasKeys,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getRoutes(): RouteEntry[] {
|
|
160
|
+
return this.router.getRoutes();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ===========================================================================
|
|
164
|
+
// OPENAI
|
|
165
|
+
// ===========================================================================
|
|
166
|
+
|
|
167
|
+
private async callOpenAI(options: LLMCallOptions): Promise<LLMCallResult> {
|
|
168
|
+
const keyPool = this.openaiKeyPool.hasKeys ? this.openaiKeyPool : null;
|
|
169
|
+
|
|
170
|
+
if (!keyPool) {
|
|
171
|
+
throw new LLMError('OpenAI API key not configured', { retryable: false });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
|
|
176
|
+
const doRequest = async (): Promise<LLMCallResult> => {
|
|
177
|
+
const apiKey = keyPool.getActiveKey().expose();
|
|
178
|
+
const keyIndex = keyPool.activeKeyIndex;
|
|
179
|
+
|
|
180
|
+
const response = await fetch(OPENAI_API_URL, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
Authorization: \`Bearer \${apiKey}\`,
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
model: options.model,
|
|
188
|
+
messages: [
|
|
189
|
+
{ role: 'system', content: options.systemPrompt },
|
|
190
|
+
{ role: 'user', content: options.userPrompt },
|
|
191
|
+
],
|
|
192
|
+
temperature: options.temperature ?? 0.3,
|
|
193
|
+
max_completion_tokens: options.maxTokens ?? 500,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (response.headers) {
|
|
198
|
+
const rateLimits = parseRateLimitHeaders(response.headers);
|
|
199
|
+
if (rateLimits.remaining !== null) {
|
|
200
|
+
keyPool.updateQuota(keyIndex, rateLimits.remaining);
|
|
201
|
+
keyPool.rotatePreemptive();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
if (response.status === 429 && keyPool.poolSize > 1) {
|
|
207
|
+
keyPool.rotateOnError();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const errorBody = await response.text();
|
|
211
|
+
throw new LLMError(
|
|
212
|
+
\`OpenAI API error: \${response.status} - \${errorBody}\`,
|
|
213
|
+
{ retryable: response.status === 429 || response.status >= 500, statusCode: response.status },
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const data = (await response.json()) as {
|
|
218
|
+
choices: Array<{ message: { content: string } }>;
|
|
219
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
text: data.choices[0]?.message?.content || '',
|
|
224
|
+
model: options.model,
|
|
225
|
+
provider: 'openai' as const,
|
|
226
|
+
inputTokens: data.usage?.prompt_tokens,
|
|
227
|
+
outputTokens: data.usage?.completion_tokens,
|
|
228
|
+
durationMs: Date.now() - start,
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return retry(doRequest, { maxAttempts: 3 });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ===========================================================================
|
|
236
|
+
// ANTHROPIC
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
|
|
239
|
+
private async callAnthropic(options: LLMCallOptions): Promise<LLMCallResult> {
|
|
240
|
+
const client = this.getAnthropicClient();
|
|
241
|
+
if (!client) {
|
|
242
|
+
throw new LLMError('Anthropic API key not configured', { retryable: false });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const start = Date.now();
|
|
246
|
+
|
|
247
|
+
return this.anthropicBreaker.call(() =>
|
|
248
|
+
retry(
|
|
249
|
+
async () => {
|
|
250
|
+
const response = await client.messages.create(
|
|
251
|
+
{
|
|
252
|
+
model: options.model,
|
|
253
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
254
|
+
system: options.systemPrompt,
|
|
255
|
+
messages: [{ role: 'user', content: options.userPrompt }],
|
|
256
|
+
},
|
|
257
|
+
{ timeout: 60_000 },
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const text = response.content
|
|
261
|
+
.filter(
|
|
262
|
+
(block): block is Anthropic.TextBlock => block.type === 'text',
|
|
263
|
+
)
|
|
264
|
+
.map((block) => block.text)
|
|
265
|
+
.join('\\n');
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
text,
|
|
269
|
+
model: options.model,
|
|
270
|
+
provider: 'anthropic' as const,
|
|
271
|
+
inputTokens: response.usage?.input_tokens,
|
|
272
|
+
outputTokens: response.usage?.output_tokens,
|
|
273
|
+
durationMs: Date.now() - start,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
{ maxAttempts: 2 },
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private getAnthropicClient(): Anthropic | null {
|
|
282
|
+
if (!this.anthropicKeyPool.hasKeys) return null;
|
|
283
|
+
|
|
284
|
+
const currentKey = this.anthropicKeyPool.getActiveKey().expose();
|
|
285
|
+
const currentFingerprint = currentKey.slice(-8);
|
|
286
|
+
|
|
287
|
+
if (currentFingerprint !== this.anthropicKeyFingerprint) {
|
|
288
|
+
this.anthropicClient = null;
|
|
289
|
+
this.anthropicKeyFingerprint = currentFingerprint;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (this.anthropicClient) return this.anthropicClient;
|
|
293
|
+
|
|
294
|
+
this.anthropicClient = new Anthropic({ apiKey: currentKey });
|
|
295
|
+
return this.anthropicClient;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
`;
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=llm-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-client.js","sourceRoot":"","sources":["../../src/templates/llm-client.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAmB;IACnD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAqCD,MAAM,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8PhB,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentConfig } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate the LLM key pool file for a new agent.
|
|
4
|
+
* KeyPool manages multiple API keys with per-key circuit breakers.
|
|
5
|
+
* Uses config.id to resolve ~/.{agentId}/keys.json.
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateLLMKeyPool(config: AgentConfig): string;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the LLM key pool file for a new agent.
|
|
3
|
+
* KeyPool manages multiple API keys with per-key circuit breakers.
|
|
4
|
+
* Uses config.id to resolve ~/.{agentId}/keys.json.
|
|
5
|
+
*/
|
|
6
|
+
export function generateLLMKeyPool(config) {
|
|
7
|
+
return `/**
|
|
8
|
+
* Key Pool — Multi-key management with auto-rotation
|
|
9
|
+
* Generated by Soleri — do not edit manually.
|
|
10
|
+
*
|
|
11
|
+
* Key loading priority:
|
|
12
|
+
* 1. ~/.${config.id}/keys.json → { "openai": ["sk-..."], "anthropic": ["sk-ant-..."] }
|
|
13
|
+
* 2. Fallback: OPENAI_API_KEY / ANTHROPIC_API_KEY env vars
|
|
14
|
+
* 3. Empty pool → graceful degradation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { SecretString } from './types.js';
|
|
21
|
+
import type { KeyPoolConfig, KeyStatus } from './types.js';
|
|
22
|
+
import { CircuitBreaker } from './utils.js';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// CONSTANTS
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PREEMPTIVE_THRESHOLD = 50;
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// KEY POOL
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
class RateLimitRotationError extends Error {
|
|
35
|
+
retryable = true;
|
|
36
|
+
constructor() {
|
|
37
|
+
super('Rate limit rotation');
|
|
38
|
+
this.name = 'RateLimitRotationError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class KeyPool {
|
|
43
|
+
private keys: SecretString[];
|
|
44
|
+
private activeIndex: number = 0;
|
|
45
|
+
private keyBreakers: Map<number, CircuitBreaker> = new Map();
|
|
46
|
+
private remainingQuota: Map<number, number> = new Map();
|
|
47
|
+
private readonly preemptiveThreshold: number;
|
|
48
|
+
|
|
49
|
+
constructor(config: KeyPoolConfig) {
|
|
50
|
+
this.keys = config.keys
|
|
51
|
+
.filter((k) => k.length > 0)
|
|
52
|
+
.map((k) => new SecretString(k));
|
|
53
|
+
this.preemptiveThreshold =
|
|
54
|
+
config.preemptiveThreshold ?? DEFAULT_PREEMPTIVE_THRESHOLD;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
57
|
+
this.keyBreakers.set(
|
|
58
|
+
i,
|
|
59
|
+
new CircuitBreaker({
|
|
60
|
+
name: \`key-pool-\${i}\`,
|
|
61
|
+
failureThreshold: 3,
|
|
62
|
+
resetTimeoutMs: 30_000,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (this.keys.length > 1) {
|
|
68
|
+
console.error(\`[LLM] KeyPool initialized with \${this.keys.length} keys\`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get hasKeys(): boolean {
|
|
73
|
+
return this.keys.length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getActiveKey(): SecretString {
|
|
77
|
+
return this.keys[this.activeIndex];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get activeKeyIndex(): number {
|
|
81
|
+
return this.activeIndex;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get poolSize(): number {
|
|
85
|
+
return this.keys.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get exhausted(): boolean {
|
|
89
|
+
if (this.keys.length === 0) return true;
|
|
90
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
91
|
+
const breaker = this.keyBreakers.get(i)!;
|
|
92
|
+
if (!breaker.isOpen()) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
rotateOnError(): SecretString | null {
|
|
98
|
+
const breaker = this.keyBreakers.get(this.activeIndex);
|
|
99
|
+
if (breaker) {
|
|
100
|
+
breaker.recordFailure();
|
|
101
|
+
}
|
|
102
|
+
return this.findNextHealthyKey();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
rotatePreemptive(): boolean {
|
|
106
|
+
const previousIndex = this.activeIndex;
|
|
107
|
+
const remaining = this.remainingQuota.get(previousIndex);
|
|
108
|
+
if (remaining !== undefined && remaining < this.preemptiveThreshold) {
|
|
109
|
+
const next = this.findNextHealthyKey();
|
|
110
|
+
if (next) {
|
|
111
|
+
console.error(
|
|
112
|
+
\`[LLM] KeyPool preemptive rotation: key \${previousIndex} remaining=\${remaining} < threshold=\${this.preemptiveThreshold}\`,
|
|
113
|
+
);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
updateQuota(keyIndex: number, remaining: number): void {
|
|
121
|
+
this.remainingQuota.set(keyIndex, remaining);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getStatus(): {
|
|
125
|
+
poolSize: number;
|
|
126
|
+
activeKeyIndex: number;
|
|
127
|
+
exhausted: boolean;
|
|
128
|
+
perKeyStatus: KeyStatus[];
|
|
129
|
+
} {
|
|
130
|
+
const perKeyStatus: KeyStatus[] = [];
|
|
131
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
132
|
+
perKeyStatus.push({
|
|
133
|
+
index: i,
|
|
134
|
+
circuitState: this.keyBreakers.get(i)!.getState(),
|
|
135
|
+
remainingQuota: this.remainingQuota.get(i) ?? null,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
poolSize: this.poolSize,
|
|
140
|
+
activeKeyIndex: this.activeIndex,
|
|
141
|
+
exhausted: this.exhausted,
|
|
142
|
+
perKeyStatus,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private findNextHealthyKey(): SecretString | null {
|
|
147
|
+
const startIndex = this.activeIndex;
|
|
148
|
+
for (let offset = 1; offset <= this.keys.length; offset++) {
|
|
149
|
+
const candidateIndex = (startIndex + offset) % this.keys.length;
|
|
150
|
+
const breaker = this.keyBreakers.get(candidateIndex)!;
|
|
151
|
+
if (!breaker.isOpen()) {
|
|
152
|
+
this.activeIndex = candidateIndex;
|
|
153
|
+
return this.keys[candidateIndex];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.error('[LLM] KeyPool: all keys exhausted — no healthy key available');
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// =============================================================================
|
|
162
|
+
// KEY LOADING
|
|
163
|
+
// =============================================================================
|
|
164
|
+
|
|
165
|
+
export interface KeyPoolFiles {
|
|
166
|
+
openai: KeyPoolConfig;
|
|
167
|
+
anthropic: KeyPoolConfig;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function loadKeyPoolConfig(): KeyPoolFiles {
|
|
171
|
+
const keysFilePath = path.join(
|
|
172
|
+
homedir(),
|
|
173
|
+
'.${config.id}',
|
|
174
|
+
'keys.json',
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
let openaiKeys: string[] = [];
|
|
178
|
+
let anthropicKeys: string[] = [];
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (fs.existsSync(keysFilePath)) {
|
|
182
|
+
const data = JSON.parse(fs.readFileSync(keysFilePath, 'utf-8'));
|
|
183
|
+
if (data?.openai && Array.isArray(data.openai)) {
|
|
184
|
+
openaiKeys = data.openai;
|
|
185
|
+
}
|
|
186
|
+
if (data?.anthropic && Array.isArray(data.anthropic)) {
|
|
187
|
+
anthropicKeys = data.anthropic;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
console.error('[LLM] Could not read keys.json, falling back to env vars');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fallback: environment variables
|
|
195
|
+
if (openaiKeys.length === 0) {
|
|
196
|
+
const envKey = process.env.OPENAI_API_KEY || '';
|
|
197
|
+
if (envKey) openaiKeys = [envKey];
|
|
198
|
+
}
|
|
199
|
+
if (anthropicKeys.length === 0) {
|
|
200
|
+
const envKey = process.env.ANTHROPIC_API_KEY || '';
|
|
201
|
+
if (envKey) anthropicKeys = [envKey];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
openai: { keys: openaiKeys },
|
|
206
|
+
anthropic: { keys: anthropicKeys },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=llm-key-pool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-key-pool.js","sourceRoot":"","sources":["../../src/templates/llm-key-pool.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAmB;IACpD,OAAO;;;;;WAKE,MAAM,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAiKZ,MAAM,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoChB,CAAC;AACF,CAAC"}
|