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.
Files changed (160) hide show
  1. package/.env.example +169 -0
  2. package/.eslintrc.cjs +23 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/keep-alive.yml +22 -0
  5. package/.github/workflows/publish.yml +34 -0
  6. package/ARCHITECTURE.md +594 -0
  7. package/Dockerfile +16 -0
  8. package/LICENSE +28 -0
  9. package/README.md +261 -0
  10. package/dist/alerts/discord.d.ts +19 -0
  11. package/dist/alerts/discord.d.ts.map +1 -0
  12. package/dist/alerts/discord.js +70 -0
  13. package/dist/alerts/discord.js.map +1 -0
  14. package/dist/cache/redisCache.d.ts +45 -0
  15. package/dist/cache/redisCache.d.ts.map +1 -0
  16. package/dist/cache/redisCache.js +171 -0
  17. package/dist/cache/redisCache.js.map +1 -0
  18. package/dist/cli.d.ts +3 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +8 -0
  21. package/dist/cli.js.map +1 -0
  22. package/dist/config.d.ts +6 -0
  23. package/dist/config.d.ts.map +1 -0
  24. package/dist/config.js +251 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/fallback/templateFallback.d.ts +7 -0
  27. package/dist/fallback/templateFallback.d.ts.map +1 -0
  28. package/dist/fallback/templateFallback.js +29 -0
  29. package/dist/fallback/templateFallback.js.map +1 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +198 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/logging.d.ts +10 -0
  35. package/dist/logging.d.ts.map +1 -0
  36. package/dist/logging.js +44 -0
  37. package/dist/logging.js.map +1 -0
  38. package/dist/metrics/metrics.d.ts +22 -0
  39. package/dist/metrics/metrics.d.ts.map +1 -0
  40. package/dist/metrics/metrics.js +78 -0
  41. package/dist/metrics/metrics.js.map +1 -0
  42. package/dist/providers/factory.d.ts +11 -0
  43. package/dist/providers/factory.d.ts.map +1 -0
  44. package/dist/providers/factory.js +52 -0
  45. package/dist/providers/factory.js.map +1 -0
  46. package/dist/providers/hfSpace.adapter.d.ts +21 -0
  47. package/dist/providers/hfSpace.adapter.d.ts.map +1 -0
  48. package/dist/providers/hfSpace.adapter.js +110 -0
  49. package/dist/providers/hfSpace.adapter.js.map +1 -0
  50. package/dist/providers/httpTemplate.adapter.d.ts +42 -0
  51. package/dist/providers/httpTemplate.adapter.d.ts.map +1 -0
  52. package/dist/providers/httpTemplate.adapter.js +98 -0
  53. package/dist/providers/httpTemplate.adapter.js.map +1 -0
  54. package/dist/providers/promptBuilder.d.ts +34 -0
  55. package/dist/providers/promptBuilder.d.ts.map +1 -0
  56. package/dist/providers/promptBuilder.js +315 -0
  57. package/dist/providers/promptBuilder.js.map +1 -0
  58. package/dist/providers/provider.interface.d.ts +45 -0
  59. package/dist/providers/provider.interface.d.ts.map +1 -0
  60. package/dist/providers/provider.interface.js +47 -0
  61. package/dist/providers/provider.interface.js.map +1 -0
  62. package/dist/providers/specs.d.ts +18 -0
  63. package/dist/providers/specs.d.ts.map +1 -0
  64. package/dist/providers/specs.js +326 -0
  65. package/dist/providers/specs.js.map +1 -0
  66. package/dist/providers/unified.adapter.d.ts +37 -0
  67. package/dist/providers/unified.adapter.d.ts.map +1 -0
  68. package/dist/providers/unified.adapter.js +141 -0
  69. package/dist/providers/unified.adapter.js.map +1 -0
  70. package/dist/queue/producer.d.ts +30 -0
  71. package/dist/queue/producer.d.ts.map +1 -0
  72. package/dist/queue/producer.js +87 -0
  73. package/dist/queue/producer.js.map +1 -0
  74. package/dist/queue/worker.d.ts +9 -0
  75. package/dist/queue/worker.d.ts.map +1 -0
  76. package/dist/queue/worker.js +137 -0
  77. package/dist/queue/worker.js.map +1 -0
  78. package/dist/server/app.d.ts +4 -0
  79. package/dist/server/app.d.ts.map +1 -0
  80. package/dist/server/app.js +394 -0
  81. package/dist/server/app.js.map +1 -0
  82. package/dist/server/start.d.ts +16 -0
  83. package/dist/server/start.d.ts.map +1 -0
  84. package/dist/server/start.js +45 -0
  85. package/dist/server/start.js.map +1 -0
  86. package/dist/stepper/orchestrator.d.ts +22 -0
  87. package/dist/stepper/orchestrator.d.ts.map +1 -0
  88. package/dist/stepper/orchestrator.js +333 -0
  89. package/dist/stepper/orchestrator.js.map +1 -0
  90. package/dist/types.d.ts +216 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +14 -0
  93. package/dist/types.js.map +1 -0
  94. package/dist/utils/redaction.d.ts +9 -0
  95. package/dist/utils/redaction.d.ts.map +1 -0
  96. package/dist/utils/redaction.js +41 -0
  97. package/dist/utils/redaction.js.map +1 -0
  98. package/dist/utils/safeRequest.d.ts +38 -0
  99. package/dist/utils/safeRequest.d.ts.map +1 -0
  100. package/dist/utils/safeRequest.js +104 -0
  101. package/dist/utils/safeRequest.js.map +1 -0
  102. package/dist/validation/report.schema.d.ts +48 -0
  103. package/dist/validation/report.schema.d.ts.map +1 -0
  104. package/dist/validation/report.schema.js +72 -0
  105. package/dist/validation/report.schema.js.map +1 -0
  106. package/dist/webhooks/delivery.d.ts +31 -0
  107. package/dist/webhooks/delivery.d.ts.map +1 -0
  108. package/dist/webhooks/delivery.js +102 -0
  109. package/dist/webhooks/delivery.js.map +1 -0
  110. package/docs/assets/architecture.png +0 -0
  111. package/package.json +75 -0
  112. package/render.yaml +25 -0
  113. package/src/alerts/README.md +25 -0
  114. package/src/alerts/discord.ts +86 -0
  115. package/src/cache/How redis caching works in package stepper.md +971 -0
  116. package/src/cache/README.md +51 -0
  117. package/src/cache/redisCache.ts +194 -0
  118. package/src/ci/deploy.sh +36 -0
  119. package/src/cli.ts +9 -0
  120. package/src/config.ts +265 -0
  121. package/src/fallback/templateFallback.ts +32 -0
  122. package/src/index.ts +246 -0
  123. package/src/logging.ts +46 -0
  124. package/src/metrics/README.md +24 -0
  125. package/src/metrics/metrics.ts +84 -0
  126. package/src/providers/How the providers interact.md +121 -0
  127. package/src/providers/README.md +121 -0
  128. package/src/providers/factory.ts +57 -0
  129. package/src/providers/hfSpace.adapter.ts +119 -0
  130. package/src/providers/httpTemplate.adapter.ts +138 -0
  131. package/src/providers/promptBuilder.ts +330 -0
  132. package/src/providers/provider.interface.ts +73 -0
  133. package/src/providers/specs.ts +366 -0
  134. package/src/providers/unified.adapter.ts +172 -0
  135. package/src/queue/How queue works in package stepper.md +149 -0
  136. package/src/queue/README.md +41 -0
  137. package/src/queue/producer.ts +108 -0
  138. package/src/queue/worker.ts +170 -0
  139. package/src/server/app.ts +451 -0
  140. package/src/server/start.ts +68 -0
  141. package/src/stepper/Dockerfile +48 -0
  142. package/src/stepper/How orchestrator works in package stepper.md +746 -0
  143. package/src/stepper/README.md +43 -0
  144. package/src/stepper/orchestrator.ts +437 -0
  145. package/src/types.ts +238 -0
  146. package/src/utils/redaction.ts +50 -0
  147. package/src/utils/safeRequest.ts +140 -0
  148. package/src/validation/README.md +25 -0
  149. package/src/validation/report.schema.ts +96 -0
  150. package/src/webhooks/delivery.ts +162 -0
  151. package/tests/integration/full-flow.test.ts +192 -0
  152. package/tests/unit/alerts/discord.test.ts +119 -0
  153. package/tests/unit/cache.test.ts +87 -0
  154. package/tests/unit/orchestrator-fallback.test.ts +92 -0
  155. package/tests/unit/orchestrator.test.ts +105 -0
  156. package/tests/unit/providers/factory.test.ts +161 -0
  157. package/tests/unit/providers/unified.adapter.test.ts +206 -0
  158. package/tests/unit/utils/redaction.test.ts +140 -0
  159. package/tests/unit/utils/safeRequest.test.ts +164 -0
  160. 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
+ }
@@ -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
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { startServer } from './server/start.js';
4
+ import { logger } from './logging.js';
5
+
6
+ startServer().catch((error) => {
7
+ logger.error({ error }, 'Failed to start Stepper server');
8
+ process.exit(1);
9
+ });
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
+ }