ai-inference-stepper 1.0.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/.env.example +169 -0
- package/.eslintrc.cjs +23 -0
- package/.github/workflows/ci.yml +51 -0
- package/.github/workflows/keep-alive.yml +22 -0
- package/.github/workflows/publish.yml +34 -0
- package/ARCHITECTURE.md +594 -0
- package/Dockerfile +16 -0
- package/LICENSE +28 -0
- package/README.md +261 -0
- package/dist/alerts/discord.d.ts +19 -0
- package/dist/alerts/discord.d.ts.map +1 -0
- package/dist/alerts/discord.js +70 -0
- package/dist/alerts/discord.js.map +1 -0
- package/dist/cache/redisCache.d.ts +45 -0
- package/dist/cache/redisCache.d.ts.map +1 -0
- package/dist/cache/redisCache.js +171 -0
- package/dist/cache/redisCache.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +251 -0
- package/dist/config.js.map +1 -0
- package/dist/fallback/templateFallback.d.ts +7 -0
- package/dist/fallback/templateFallback.d.ts.map +1 -0
- package/dist/fallback/templateFallback.js +29 -0
- package/dist/fallback/templateFallback.js.map +1 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +198 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +10 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +44 -0
- package/dist/logging.js.map +1 -0
- package/dist/metrics/metrics.d.ts +22 -0
- package/dist/metrics/metrics.d.ts.map +1 -0
- package/dist/metrics/metrics.js +78 -0
- package/dist/metrics/metrics.js.map +1 -0
- package/dist/providers/factory.d.ts +11 -0
- package/dist/providers/factory.d.ts.map +1 -0
- package/dist/providers/factory.js +52 -0
- package/dist/providers/factory.js.map +1 -0
- package/dist/providers/hfSpace.adapter.d.ts +21 -0
- package/dist/providers/hfSpace.adapter.d.ts.map +1 -0
- package/dist/providers/hfSpace.adapter.js +110 -0
- package/dist/providers/hfSpace.adapter.js.map +1 -0
- package/dist/providers/httpTemplate.adapter.d.ts +42 -0
- package/dist/providers/httpTemplate.adapter.d.ts.map +1 -0
- package/dist/providers/httpTemplate.adapter.js +98 -0
- package/dist/providers/httpTemplate.adapter.js.map +1 -0
- package/dist/providers/promptBuilder.d.ts +34 -0
- package/dist/providers/promptBuilder.d.ts.map +1 -0
- package/dist/providers/promptBuilder.js +315 -0
- package/dist/providers/promptBuilder.js.map +1 -0
- package/dist/providers/provider.interface.d.ts +45 -0
- package/dist/providers/provider.interface.d.ts.map +1 -0
- package/dist/providers/provider.interface.js +47 -0
- package/dist/providers/provider.interface.js.map +1 -0
- package/dist/providers/specs.d.ts +18 -0
- package/dist/providers/specs.d.ts.map +1 -0
- package/dist/providers/specs.js +326 -0
- package/dist/providers/specs.js.map +1 -0
- package/dist/providers/unified.adapter.d.ts +37 -0
- package/dist/providers/unified.adapter.d.ts.map +1 -0
- package/dist/providers/unified.adapter.js +141 -0
- package/dist/providers/unified.adapter.js.map +1 -0
- package/dist/queue/producer.d.ts +30 -0
- package/dist/queue/producer.d.ts.map +1 -0
- package/dist/queue/producer.js +87 -0
- package/dist/queue/producer.js.map +1 -0
- package/dist/queue/worker.d.ts +9 -0
- package/dist/queue/worker.d.ts.map +1 -0
- package/dist/queue/worker.js +137 -0
- package/dist/queue/worker.js.map +1 -0
- package/dist/server/app.d.ts +4 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +394 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/start.d.ts +16 -0
- package/dist/server/start.d.ts.map +1 -0
- package/dist/server/start.js +45 -0
- package/dist/server/start.js.map +1 -0
- package/dist/stepper/orchestrator.d.ts +22 -0
- package/dist/stepper/orchestrator.d.ts.map +1 -0
- package/dist/stepper/orchestrator.js +333 -0
- package/dist/stepper/orchestrator.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/redaction.d.ts +9 -0
- package/dist/utils/redaction.d.ts.map +1 -0
- package/dist/utils/redaction.js +41 -0
- package/dist/utils/redaction.js.map +1 -0
- package/dist/utils/safeRequest.d.ts +38 -0
- package/dist/utils/safeRequest.d.ts.map +1 -0
- package/dist/utils/safeRequest.js +104 -0
- package/dist/utils/safeRequest.js.map +1 -0
- package/dist/validation/report.schema.d.ts +48 -0
- package/dist/validation/report.schema.d.ts.map +1 -0
- package/dist/validation/report.schema.js +72 -0
- package/dist/validation/report.schema.js.map +1 -0
- package/dist/webhooks/delivery.d.ts +31 -0
- package/dist/webhooks/delivery.d.ts.map +1 -0
- package/dist/webhooks/delivery.js +102 -0
- package/dist/webhooks/delivery.js.map +1 -0
- package/docs/assets/architecture.png +0 -0
- package/package.json +75 -0
- package/render.yaml +25 -0
- package/src/alerts/README.md +25 -0
- package/src/alerts/discord.ts +86 -0
- package/src/cache/How redis caching works in package stepper.md +971 -0
- package/src/cache/README.md +51 -0
- package/src/cache/redisCache.ts +194 -0
- package/src/ci/deploy.sh +36 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +265 -0
- package/src/fallback/templateFallback.ts +32 -0
- package/src/index.ts +246 -0
- package/src/logging.ts +46 -0
- package/src/metrics/README.md +24 -0
- package/src/metrics/metrics.ts +84 -0
- package/src/providers/How the providers interact.md +121 -0
- package/src/providers/README.md +121 -0
- package/src/providers/factory.ts +57 -0
- package/src/providers/hfSpace.adapter.ts +119 -0
- package/src/providers/httpTemplate.adapter.ts +138 -0
- package/src/providers/promptBuilder.ts +330 -0
- package/src/providers/provider.interface.ts +73 -0
- package/src/providers/specs.ts +366 -0
- package/src/providers/unified.adapter.ts +172 -0
- package/src/queue/How queue works in package stepper.md +149 -0
- package/src/queue/README.md +41 -0
- package/src/queue/producer.ts +108 -0
- package/src/queue/worker.ts +170 -0
- package/src/server/app.ts +451 -0
- package/src/server/start.ts +68 -0
- package/src/stepper/Dockerfile +48 -0
- package/src/stepper/How orchestrator works in package stepper.md +746 -0
- package/src/stepper/README.md +43 -0
- package/src/stepper/orchestrator.ts +437 -0
- package/src/types.ts +238 -0
- package/src/utils/redaction.ts +50 -0
- package/src/utils/safeRequest.ts +140 -0
- package/src/validation/README.md +25 -0
- package/src/validation/report.schema.ts +96 -0
- package/src/webhooks/delivery.ts +162 -0
- package/tests/integration/full-flow.test.ts +192 -0
- package/tests/unit/alerts/discord.test.ts +119 -0
- package/tests/unit/cache.test.ts +87 -0
- package/tests/unit/orchestrator-fallback.test.ts +92 -0
- package/tests/unit/orchestrator.test.ts +105 -0
- package/tests/unit/providers/factory.test.ts +161 -0
- package/tests/unit/providers/unified.adapter.test.ts +206 -0
- package/tests/unit/utils/redaction.test.ts +140 -0
- package/tests/unit/utils/safeRequest.test.ts +164 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# 🧠 Cache System
|
|
2
|
+
|
|
3
|
+
The **Inference Stepper** package is designed to generate AI-powered "commit diary" reports. Since generating these reports using AI services (like Gemini, Cohere, etc.) takes time and costs money, this module implements a **Redis-backed caching system**.
|
|
4
|
+
|
|
5
|
+
## 🎯 Purpose
|
|
6
|
+
|
|
7
|
+
1. **Remember** reports that were already generated
|
|
8
|
+
2. **Return them instantly** when requested again
|
|
9
|
+
3. **Track the progress** of reports being generated
|
|
10
|
+
4. **Handle failures** gracefully
|
|
11
|
+
|
|
12
|
+
## 🔑 Key Concepts
|
|
13
|
+
|
|
14
|
+
### Dehydrated vs. Hydrated
|
|
15
|
+
|
|
16
|
+
- **Dehydrated entry**: A placeholder in the cache saying "We're working on this report, come back later!". It contains a `jobId` to track progress.
|
|
17
|
+
- **Hydrated entry**: A completed report ready to be served. It contains the actual AI-generated content and metadata about which providers were tried.
|
|
18
|
+
|
|
19
|
+
### Stale-While-Revalidate
|
|
20
|
+
|
|
21
|
+
This system implements the **Stale-While-Revalidate** pattern:
|
|
22
|
+
|
|
23
|
+
1. Serve old (stale) data immediately to the user.
|
|
24
|
+
2. Trigger a background refresh to generate fresh data.
|
|
25
|
+
3. The next request gets the updated, fresh data.
|
|
26
|
+
|
|
27
|
+
## 📋 Functions
|
|
28
|
+
|
|
29
|
+
| Function | Purpose |
|
|
30
|
+
| -------------------- | ----------------------------------------------------------------------------- |
|
|
31
|
+
| `getRedisClient()` | Manages the connection to the Redis database. |
|
|
32
|
+
| `buildCacheKey()` | Creates unique identifiers like `stepper:report:user123:shaabc`. |
|
|
33
|
+
| `getReportCache()` | Retrieves a cached report if it exists. |
|
|
34
|
+
| `setDehydrated()` | Marks a report as "in progress". |
|
|
35
|
+
| `setHydrated()` | Stores a completed AI report. |
|
|
36
|
+
| `markFailed()` | Records logic failures so we don't keep retrying broken requests immediately. |
|
|
37
|
+
| `isHydratedFresh()` | Checks if a report is young enough to serve without refresh. |
|
|
38
|
+
| `isStaleButUsable()` | Checks if we can serve an old report while refreshing. |
|
|
39
|
+
|
|
40
|
+
## 🎯 Flow
|
|
41
|
+
|
|
42
|
+
```mermaid
|
|
43
|
+
graph TD
|
|
44
|
+
A[Request In] --> B{Check Cache}
|
|
45
|
+
B -- Hit (Fresh) --> C[Serve Immediately]
|
|
46
|
+
B -- Hit (Stale) --> D[Serve + Refresh Background]
|
|
47
|
+
B -- Miss --> E[Enqueue Job + Mark Dehydrated]
|
|
48
|
+
E --> F[AI Generation]
|
|
49
|
+
F -- Success --> G[Mark Hydrated]
|
|
50
|
+
F -- Failure --> H[Mark Failed]
|
|
51
|
+
```
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// packages/stepper/src/cache/redisCache.ts
|
|
2
|
+
|
|
3
|
+
import Redis from 'ioredis';
|
|
4
|
+
import { CacheEntry, ReportOutput, ProviderAttemptMeta } from '../types.js';
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
import { logger } from '../logging.js';
|
|
7
|
+
import { sendDiscordAlert } from '../alerts/discord.js';
|
|
8
|
+
|
|
9
|
+
let redisClient: Redis | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get or create Redis client
|
|
13
|
+
*/
|
|
14
|
+
export function getRedisClient(): Redis {
|
|
15
|
+
if (!redisClient) {
|
|
16
|
+
redisClient = new Redis(config.redis.url, {
|
|
17
|
+
maxRetriesPerRequest: null, // Required by BullMQ for blocking operations
|
|
18
|
+
enableReadyCheck: true,
|
|
19
|
+
lazyConnect: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
redisClient.on('error', (err) => {
|
|
23
|
+
logger.error({ err }, 'Redis client error');
|
|
24
|
+
void sendDiscordAlert({
|
|
25
|
+
title: 'Redis Connection Error',
|
|
26
|
+
message: `Redis client encountered an error: ${err.message}`,
|
|
27
|
+
severity: 'critical',
|
|
28
|
+
metadata: { error: err.message, timestamp: new Date().toISOString() }
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
redisClient.on('connect', () => {
|
|
33
|
+
logger.info('Redis client connected');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return redisClient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build cache key for a report
|
|
42
|
+
*/
|
|
43
|
+
export function buildCacheKey(userId: string, commitSha: string, templateHash: string = 'default'): string {
|
|
44
|
+
return `${config.redis.keyPrefix}report:${userId}:${commitSha}:${templateHash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get cache entry: Looks up a report in the cache using its key. Like asking: "Do we already have a copy of this report?"
|
|
49
|
+
*/
|
|
50
|
+
export async function getReportCache(key: string): Promise<CacheEntry | null> {
|
|
51
|
+
const redis = getRedisClient();
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const data = await redis.get(key);
|
|
55
|
+
if (!data) return null;
|
|
56
|
+
|
|
57
|
+
const entry: CacheEntry = JSON.parse(data);
|
|
58
|
+
return entry;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
logger.error({ error, key }, 'Failed to get cache entry');
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Set dehydrated cache entry (job enqueued)
|
|
67
|
+
*/
|
|
68
|
+
export async function setDehydrated(key: string, jobId: string): Promise<void> {
|
|
69
|
+
const redis = getRedisClient();
|
|
70
|
+
|
|
71
|
+
const entry: CacheEntry = {
|
|
72
|
+
status: 'dehydrated', //Mark it as "in progress"
|
|
73
|
+
jobId,
|
|
74
|
+
timestamps: {
|
|
75
|
+
created: new Date().toISOString(),
|
|
76
|
+
updated: new Date().toISOString(),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await redis.setex(key, config.cache.ttlSeconds, JSON.stringify(entry)); //Store in Redis with expiration time Default is 604,800 seconds = 7 days
|
|
82
|
+
logger.debug({ key, jobId }, 'Created dehydrated cache entry');
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logger.error({ error, key }, 'Failed to set dehydrated cache');
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set hydrated cache entry (report generated)
|
|
91
|
+
* Stores a completed report in the cache. This is the "meal is ready!" moment.
|
|
92
|
+
*/
|
|
93
|
+
export async function setHydrated(
|
|
94
|
+
key: string,
|
|
95
|
+
result: ReportOutput,
|
|
96
|
+
providersAttempted: ProviderAttemptMeta[],
|
|
97
|
+
fallback: boolean = false,
|
|
98
|
+
ttl?: number // How long to keep it 604800 (7 days in seconds)
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const redis = getRedisClient();
|
|
101
|
+
|
|
102
|
+
const entry: CacheEntry = {
|
|
103
|
+
status: 'hydrated', // Report is complete
|
|
104
|
+
result,
|
|
105
|
+
providersAttempted,
|
|
106
|
+
fallback,
|
|
107
|
+
timestamps: {
|
|
108
|
+
created: new Date().toISOString(),
|
|
109
|
+
updated: new Date().toISOString(),
|
|
110
|
+
},
|
|
111
|
+
ttl: ttl || config.cache.ttlSeconds,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await redis.setex(key, ttl || config.cache.ttlSeconds, JSON.stringify(entry));
|
|
116
|
+
logger.debug({ key, fallback }, 'Stored hydrated cache entry');
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.error({ error, key }, 'Failed to set hydrated cache');
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Mark cache entry as failed: Records that report generation failed completely. All AI providers were tried and none worked.
|
|
125
|
+
*/
|
|
126
|
+
export async function markFailed(key: string, errorMessage: string, providersAttempted: ProviderAttemptMeta[]): Promise<void> {
|
|
127
|
+
const redis = getRedisClient();
|
|
128
|
+
|
|
129
|
+
const entry: CacheEntry = {
|
|
130
|
+
status: 'failed',
|
|
131
|
+
error: errorMessage,
|
|
132
|
+
providersAttempted,
|
|
133
|
+
timestamps: {
|
|
134
|
+
created: new Date().toISOString(),
|
|
135
|
+
updated: new Date().toISOString(),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await redis.setex(key, 3600, JSON.stringify(entry)); // Keep failed for 1 hour
|
|
141
|
+
logger.debug({ key }, 'Marked cache entry as failed');
|
|
142
|
+
} catch (error) {
|
|
143
|
+
logger.error({ error, key }, 'Failed to mark cache as failed');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if hydrated entry is fresh
|
|
149
|
+
*/
|
|
150
|
+
export function isHydratedFresh(entry: CacheEntry): boolean {
|
|
151
|
+
if (entry.status !== 'hydrated') return false; //Is this a complete report?
|
|
152
|
+
|
|
153
|
+
const updatedAt = new Date(entry.timestamps.updated).getTime();
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const ageSeconds = (now - updatedAt) / 1000; //How old is this report?
|
|
156
|
+
|
|
157
|
+
return ageSeconds < config.cache.staleThresholdSeconds; //Is it younger than 24 hours?
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check if entry is stale but usable for stale-while-revalidate: Checks if a report is old but still usable while a new one is being generated in the background.
|
|
162
|
+
*/
|
|
163
|
+
export function isStaleButUsable(entry: CacheEntry): boolean {
|
|
164
|
+
if (entry.status !== 'hydrated') return false;
|
|
165
|
+
if (!config.cache.enableStaleWhileRevalidate) return false;
|
|
166
|
+
|
|
167
|
+
return !isHydratedFresh(entry);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Delete cache entry: Removes the record from Redis immediately.
|
|
172
|
+
* Call this once the backend has successfully saved the report to its database.
|
|
173
|
+
*/
|
|
174
|
+
export async function deleteCacheEntry(key: string): Promise<void> {
|
|
175
|
+
const redis = getRedisClient();
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await redis.del(key);
|
|
179
|
+
logger.debug({ key }, 'Deleted cache entry after successful delivery');
|
|
180
|
+
} catch (error) {
|
|
181
|
+
logger.error({ error, key }, 'Failed to delete cache entry');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Close Redis connection (for graceful shutdown)
|
|
187
|
+
*/
|
|
188
|
+
export async function closeRedis(): Promise<void> {
|
|
189
|
+
if (redisClient) {
|
|
190
|
+
await redisClient.quit();
|
|
191
|
+
redisClient = null;
|
|
192
|
+
logger.info('Redis client disconnected');
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/ci/deploy.sh
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
### `packages/stepper/src/ci/deploy.sh
|
|
4
|
+
|
|
5
|
+
#!/bin/bash
|
|
6
|
+
set -e
|
|
7
|
+
|
|
8
|
+
echo "🚀 Deploying Stepper Service"
|
|
9
|
+
|
|
10
|
+
# Build Docker image
|
|
11
|
+
echo "📦 Building Docker image..."
|
|
12
|
+
docker build -t commitdiary-stepper:latest .
|
|
13
|
+
|
|
14
|
+
# Tag for registry (example)
|
|
15
|
+
# docker tag commitdiary-stepper:latest registry.example.com/commitdiary-stepper:latest
|
|
16
|
+
|
|
17
|
+
# Push to registry (example)
|
|
18
|
+
# docker push registry.example.com/commitdiary-stepper:latest
|
|
19
|
+
|
|
20
|
+
echo "✅ Build complete"
|
|
21
|
+
|
|
22
|
+
# Example deployment commands (adjust for your platform)
|
|
23
|
+
# For Railway:
|
|
24
|
+
# railway up
|
|
25
|
+
|
|
26
|
+
# For Render:
|
|
27
|
+
# render deploy
|
|
28
|
+
|
|
29
|
+
# For Kubernetes:
|
|
30
|
+
# kubectl apply -f k8s/deployment.yml
|
|
31
|
+
|
|
32
|
+
echo "📋 Next steps:"
|
|
33
|
+
echo "1. Push image to your container registry"
|
|
34
|
+
echo "2. Update deployment configuration with new image"
|
|
35
|
+
echo "3. Apply deployment to your cluster/platform"
|
|
36
|
+
echo "4. Verify with: curl https://your-domain.com/health"
|
package/src/cli.ts
ADDED
package/src/config.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { StepperConfig, ProviderConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Load configuration from environment variables with sensible defaults.
|
|
5
|
+
* This is the central brain for all timing, retry, and safety-switch logic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load provider configurations from environment
|
|
10
|
+
*/
|
|
11
|
+
function loadProviderConfigs(): ProviderConfig[] {
|
|
12
|
+
const providers: ProviderConfig[] = [];
|
|
13
|
+
|
|
14
|
+
// Helper to add provider config
|
|
15
|
+
const addProvider = (name: string, envPrefix: string) => {
|
|
16
|
+
const enabled = process.env[`${envPrefix}_ENABLED`] === 'true';
|
|
17
|
+
if (enabled) {
|
|
18
|
+
providers.push({
|
|
19
|
+
name,
|
|
20
|
+
apiKey: process.env[`${envPrefix}_API_KEY`],
|
|
21
|
+
baseUrl: process.env[`${envPrefix}_BASE_URL`],
|
|
22
|
+
modelName: process.env[`${envPrefix}_MODEL`],
|
|
23
|
+
timeout: parseInt(process.env[`${envPrefix}_TIMEOUT`] || '15000', 10),
|
|
24
|
+
rateLimitRPS: parseInt(process.env[`${envPrefix}_RPS`] || '5', 10),
|
|
25
|
+
concurrency: parseInt(process.env[`${envPrefix}_CONCURRENCY`] || '2', 10),
|
|
26
|
+
enabled: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Special case: HuggingFace Space
|
|
32
|
+
if (process.env.HF_SPACE_ENABLED === 'true') {
|
|
33
|
+
providers.push({
|
|
34
|
+
name: 'hf-space',
|
|
35
|
+
baseUrl: process.env.HF_SPACE_URL,
|
|
36
|
+
apiKey: process.env.HF_SPACE_API_KEY,
|
|
37
|
+
timeout: parseInt(process.env.HF_SPACE_TIMEOUT || '30000', 10),
|
|
38
|
+
rateLimitRPS: parseInt(process.env.HF_SPACE_RPS || '3', 10),
|
|
39
|
+
concurrency: parseInt(process.env.HF_SPACE_CONCURRENCY || '1', 10),
|
|
40
|
+
enabled: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Add all other providers
|
|
45
|
+
addProvider('gemini', 'GEMINI');
|
|
46
|
+
addProvider('openai', 'OPENAI');
|
|
47
|
+
addProvider('anthropic', 'ANTHROPIC');
|
|
48
|
+
addProvider('cohere', 'COHERE');
|
|
49
|
+
addProvider('deepseek', 'DEEPSEEK');
|
|
50
|
+
addProvider('groq', 'GROQ');
|
|
51
|
+
addProvider('openrouter', 'OPENROUTER');
|
|
52
|
+
addProvider('mistral', 'MISTRAL');
|
|
53
|
+
addProvider('perplexity', 'PERPLEXITY');
|
|
54
|
+
addProvider('together', 'TOGETHER');
|
|
55
|
+
|
|
56
|
+
return providers;
|
|
57
|
+
}
|
|
58
|
+
export function loadConfig(): StepperConfig {
|
|
59
|
+
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
60
|
+
|
|
61
|
+
// Provider configurations: Rules for how we talk to each AI
|
|
62
|
+
const providers: ProviderConfig[] = [
|
|
63
|
+
{
|
|
64
|
+
name: 'hf-space',
|
|
65
|
+
enabled: process.env.HF_SPACE_ENABLED === 'true',
|
|
66
|
+
baseUrl: process.env.HF_SPACE_URL || 'https://your-space.hf.space',
|
|
67
|
+
apiKeyEnvVar: 'HF_SPACE_API_KEY',
|
|
68
|
+
// RPM (Requests Per Minute): We allow 5 requests every 60 seconds (one every 12 seconds)
|
|
69
|
+
// high RPM leads to "429 Too Many Requests" errors.
|
|
70
|
+
rateLimitRPM: parseInt(process.env.HF_SPACE_RPM || '5', 10),
|
|
71
|
+
// Concurrency: Max 2 active conversations at once. Prevents overloading the AI slot.
|
|
72
|
+
concurrency: parseInt(process.env.HF_SPACE_CONCURRENCY || '2', 10),
|
|
73
|
+
// Timeout: Give the AI 1 minute to think before we give up and try another provider.
|
|
74
|
+
timeout: parseInt(process.env.HF_SPACE_TIMEOUT || '60000', 10),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'gemini',
|
|
78
|
+
enabled: process.env.GEMINI_ENABLED === 'true',
|
|
79
|
+
baseUrl: process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1',
|
|
80
|
+
modelName: process.env.GEMINI_MODEL || 'gemini-pro',
|
|
81
|
+
apiKeyEnvVar: 'GEMINI_API_KEY',
|
|
82
|
+
rateLimitRPM: parseInt(process.env.GEMINI_RPM || '5', 10),
|
|
83
|
+
concurrency: parseInt(process.env.GEMINI_CONCURRENCY || '2', 10),
|
|
84
|
+
timeout: parseInt(process.env.GEMINI_TIMEOUT || '60000', 10),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'cohere',
|
|
88
|
+
enabled: process.env.COHERE_ENABLED === 'true',
|
|
89
|
+
baseUrl: process.env.COHERE_BASE_URL || 'https://api.cohere.ai/v1',
|
|
90
|
+
modelName: process.env.COHERE_MODEL || 'command',
|
|
91
|
+
apiKeyEnvVar: 'COHERE_API_KEY',
|
|
92
|
+
rateLimitRPM: parseInt(process.env.COHERE_RPM || '5', 10),
|
|
93
|
+
concurrency: parseInt(process.env.COHERE_CONCURRENCY || '2', 10),
|
|
94
|
+
timeout: parseInt(process.env.COHERE_TIMEOUT || '60000', 10),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Filter enabled providers and enforce order
|
|
99
|
+
const staticProviders = providers.filter((p) => p.enabled);
|
|
100
|
+
const dynamicProviders = loadProviderConfigs();
|
|
101
|
+
|
|
102
|
+
// Combine, preferring static if name conflicts
|
|
103
|
+
const allProviders = [...staticProviders];
|
|
104
|
+
for (const dp of dynamicProviders) {
|
|
105
|
+
if (!allProviders.some(sp => sp.name === dp.name)) {
|
|
106
|
+
allProviders.push(dp);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
providers: allProviders,
|
|
112
|
+
fallback: {
|
|
113
|
+
enabled: process.env.FALLBACK_ENABLED !== 'false',
|
|
114
|
+
},
|
|
115
|
+
redis: {
|
|
116
|
+
url: redisUrl,
|
|
117
|
+
keyPrefix: process.env.REDIS_KEY_PREFIX || 'stepper:',
|
|
118
|
+
},
|
|
119
|
+
cache: {
|
|
120
|
+
// TTL: How long the report stays in the database (Default: 2 days)
|
|
121
|
+
ttlSeconds: parseInt(process.env.CACHE_TTL_SECONDS || '172800', 10),
|
|
122
|
+
// Stale Threshold: After 24 hours (or effectively never if TTL < 24h), we consider the data "old"
|
|
123
|
+
staleThresholdSeconds: parseInt(process.env.CACHE_STALE_THRESHOLD || '86400', 10),
|
|
124
|
+
enableStaleWhileRevalidate: process.env.CACHE_STALE_WHILE_REVALIDATE !== 'false',
|
|
125
|
+
},
|
|
126
|
+
queue: {
|
|
127
|
+
name: process.env.QUEUE_NAME || 'report-generation',
|
|
128
|
+
// How many total background jobs we run across all providers
|
|
129
|
+
concurrency: parseInt(process.env.QUEUE_CONCURRENCY || '5', 10),
|
|
130
|
+
},
|
|
131
|
+
webhook: {
|
|
132
|
+
enabled: process.env.WEBHOOK_ENABLED !== 'false', // Enabled by default
|
|
133
|
+
secret: process.env.WEBHOOK_SECRET || '',
|
|
134
|
+
maxRetries: parseInt(process.env.WEBHOOK_MAX_RETRIES || '3', 10),
|
|
135
|
+
retryDelayMs: parseInt(process.env.WEBHOOK_RETRY_DELAY_MS || '5000', 10),
|
|
136
|
+
},
|
|
137
|
+
retry: {
|
|
138
|
+
// Max Attempts: Try a single provider 3 times before moving to the next one.
|
|
139
|
+
maxAttemptsPerProvider: parseInt(process.env.RETRY_MAX_ATTEMPTS || '3', 10),
|
|
140
|
+
// Base Delay: After a simple error (like network), wait 40 seconds before retrying.
|
|
141
|
+
baseDelayMs: parseInt(process.env.RETRY_BASE_DELAY_MS || '40000', 10),
|
|
142
|
+
// Jitter: Random +/- 10 seconds to prevent multiple retries hitting at once.
|
|
143
|
+
maxJitterMs: parseInt(process.env.RETRY_MAX_JITTER_MS || '10000', 10),
|
|
144
|
+
// Rate Limit Fallback: If AI says "Busy" but doesn't say for how long, wait ~2 hours (extreme safety).
|
|
145
|
+
// Note: User set this to 5400 in .env which is ~90mins.
|
|
146
|
+
rateLimitFallbackSeconds: parseInt(process.env.RETRY_RATE_LIMIT_FALLBACK || '7200', 10),
|
|
147
|
+
},
|
|
148
|
+
circuit: {
|
|
149
|
+
// Failure Threshold: Kill the provider if 5 requests in a row fail.
|
|
150
|
+
failureThreshold: parseInt(process.env.CIRCUIT_FAILURE_THRESHOLD || '5', 10),
|
|
151
|
+
// Window: Only look at failures from the last 5 minutes.
|
|
152
|
+
windowSeconds: parseInt(process.env.CIRCUIT_WINDOW_SECONDS || '300', 10),
|
|
153
|
+
// Cooldown: After killing a provider, wait 5 minutes before trying it again.
|
|
154
|
+
cooldownSeconds: parseInt(process.env.CIRCUIT_COOLDOWN_SECONDS || '300', 10),
|
|
155
|
+
},
|
|
156
|
+
security: {
|
|
157
|
+
redactBeforeSend: process.env.REDACT_BEFORE_SEND !== 'false',
|
|
158
|
+
// CORS: Control which domains can access your API
|
|
159
|
+
cors: {
|
|
160
|
+
enabled: process.env.CORS_ENABLED !== 'false', // Enabled by default
|
|
161
|
+
allowedOrigins: process.env.CORS_ALLOWED_ORIGINS
|
|
162
|
+
? process.env.CORS_ALLOWED_ORIGINS.split(',').map(s => s.trim())
|
|
163
|
+
: ['*'], // Default allows all; in production, specify your domains
|
|
164
|
+
allowCredentials: process.env.CORS_ALLOW_CREDENTIALS === 'true',
|
|
165
|
+
},
|
|
166
|
+
// Rate Limiting: Prevent abuse and DDoS
|
|
167
|
+
rateLimit: {
|
|
168
|
+
enabled: process.env.RATE_LIMIT_ENABLED !== 'false', // Enabled by default
|
|
169
|
+
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes default
|
|
170
|
+
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10), // 100 per window per IP
|
|
171
|
+
maxRequestsPerUser: parseInt(process.env.RATE_LIMIT_MAX_PER_USER || '50', 10), // 50 per window per userId
|
|
172
|
+
skipHealthEndpoints: process.env.RATE_LIMIT_SKIP_HEALTH !== 'false', // Skip /health & /metrics by default
|
|
173
|
+
},
|
|
174
|
+
// Helmet: Security headers (XSS, clickjacking, etc.)
|
|
175
|
+
helmet: {
|
|
176
|
+
enabled: process.env.HELMET_ENABLED !== 'false', // Enabled by default
|
|
177
|
+
},
|
|
178
|
+
// API Key: Simple authentication for API access
|
|
179
|
+
apiKey: {
|
|
180
|
+
enabled: process.env.API_KEY_ENABLED === 'true', // Disabled by default; opt-in
|
|
181
|
+
headerName: process.env.API_KEY_HEADER || 'x-api-key',
|
|
182
|
+
skipHealthEndpoints: process.env.API_KEY_SKIP_HEALTH !== 'false', // Skip auth for health/metrics
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
server: {
|
|
186
|
+
port: parseInt(process.env.PORT || '3001', 10),
|
|
187
|
+
metricsPort: process.env.METRICS_PORT ? parseInt(process.env.METRICS_PORT, 10) : undefined,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mergeConfig(base: StepperConfig, overrides: Partial<StepperConfig>): StepperConfig {
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
...overrides,
|
|
196
|
+
providers: overrides.providers ?? base.providers,
|
|
197
|
+
providerConfigs: overrides.providerConfigs ?? base.providerConfigs,
|
|
198
|
+
redis: {
|
|
199
|
+
...base.redis,
|
|
200
|
+
...overrides.redis,
|
|
201
|
+
},
|
|
202
|
+
cache: {
|
|
203
|
+
...base.cache,
|
|
204
|
+
...overrides.cache,
|
|
205
|
+
},
|
|
206
|
+
queue: {
|
|
207
|
+
...base.queue,
|
|
208
|
+
...overrides.queue,
|
|
209
|
+
},
|
|
210
|
+
webhook: {
|
|
211
|
+
...base.webhook,
|
|
212
|
+
...overrides.webhook,
|
|
213
|
+
},
|
|
214
|
+
retry: {
|
|
215
|
+
...base.retry,
|
|
216
|
+
...overrides.retry,
|
|
217
|
+
},
|
|
218
|
+
circuit: {
|
|
219
|
+
...base.circuit,
|
|
220
|
+
...overrides.circuit,
|
|
221
|
+
},
|
|
222
|
+
security: {
|
|
223
|
+
...base.security,
|
|
224
|
+
...overrides.security,
|
|
225
|
+
cors: {
|
|
226
|
+
...base.security.cors,
|
|
227
|
+
...overrides.security?.cors,
|
|
228
|
+
},
|
|
229
|
+
rateLimit: {
|
|
230
|
+
...base.security.rateLimit,
|
|
231
|
+
...overrides.security?.rateLimit,
|
|
232
|
+
},
|
|
233
|
+
helmet: {
|
|
234
|
+
...base.security.helmet,
|
|
235
|
+
...overrides.security?.helmet,
|
|
236
|
+
},
|
|
237
|
+
apiKey: {
|
|
238
|
+
...base.security.apiKey,
|
|
239
|
+
...overrides.security?.apiKey,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
server: {
|
|
243
|
+
...base.server,
|
|
244
|
+
...overrides.server,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createConfig(overrides?: Partial<StepperConfig>): StepperConfig {
|
|
250
|
+
const base = loadConfig();
|
|
251
|
+
if (!overrides) {
|
|
252
|
+
return base;
|
|
253
|
+
}
|
|
254
|
+
return mergeConfig(base, overrides);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export let config = loadConfig();
|
|
258
|
+
|
|
259
|
+
export function applyConfigOverrides(overrides?: Partial<StepperConfig>): StepperConfig {
|
|
260
|
+
if (!overrides) {
|
|
261
|
+
return config;
|
|
262
|
+
}
|
|
263
|
+
config = mergeConfig(loadConfig(), overrides);
|
|
264
|
+
return config;
|
|
265
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PromptInput, ReportOutput } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a deterministic, template-based fallback report
|
|
5
|
+
* Used when all AI providers fail
|
|
6
|
+
*/
|
|
7
|
+
export function generateTemplateFallback(input: PromptInput): ReportOutput {
|
|
8
|
+
const { message, files, components, diffSummary, repo } = input;
|
|
9
|
+
|
|
10
|
+
// Extract basic info from commit message
|
|
11
|
+
const firstLine = message.split('\n')[0] || 'Code changes';
|
|
12
|
+
const fileCount = files.length;
|
|
13
|
+
const componentCount = components.length;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
title: `${firstLine.slice(0, 80)}`,
|
|
17
|
+
summary: `This commit modifies ${fileCount} file${fileCount !== 1 ? 's' : ''} in ${repo}. ` +
|
|
18
|
+
`The changes affect ${componentCount} component${componentCount !== 1 ? 's' : ''}. ` +
|
|
19
|
+
`Commit message: "${message.slice(0, 200)}${message.length > 200 ? '...' : ''}"`,
|
|
20
|
+
changes: files.slice(0, 10).map((f) => `Modified ${f}`),
|
|
21
|
+
rationale: `Automated fallback: Unable to generate AI-powered analysis. ` +
|
|
22
|
+
`Diff summary: ${diffSummary.slice(0, 300)}${diffSummary.length > 300 ? '...' : ''}`,
|
|
23
|
+
impact_and_tests: `Please review the changes manually. Ensure tests are updated for modified files: ${files.slice(0, 5).join(', ')}`,
|
|
24
|
+
next_steps: [
|
|
25
|
+
'Review changes manually',
|
|
26
|
+
'Run test suite',
|
|
27
|
+
'Verify component integration',
|
|
28
|
+
'Update documentation if needed',
|
|
29
|
+
],
|
|
30
|
+
tags: components.slice(0, 5).join(', ') || 'general',
|
|
31
|
+
};
|
|
32
|
+
}
|