@hubblecommerce/overmind-core 0.1.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/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @hubblecommerce/overmind-core
2
+
3
+ Shared infrastructure package for the **Overmind** AI agent system. Contains all foundational code used across the agent server and webhook worker — API clients, vectorstore, embeddings, LLM providers, document processors, and reusable LangChain tools.
4
+
5
+ ## Contents
6
+
7
+ | Module | Import path | Description |
8
+ |---|---|---|
9
+ | Config | `@hubblecommerce/overmind-core/config` | Centralized app configuration from env vars |
10
+ | Confluence client | `@hubblecommerce/overmind-core/integrations/confluence` | Confluence REST API v2 client |
11
+ | Jira client | `@hubblecommerce/overmind-core/integrations/jira` | Jira REST API v3 client |
12
+ | GitLab client | `@hubblecommerce/overmind-core/integrations/gitlab` | GitLab API client |
13
+ | Vectorstore | `@hubblecommerce/overmind-core/vectorstore` | pgvector provider + interface |
14
+ | Embeddings | `@hubblecommerce/overmind-core/embeddings` | Hugging Face Transformers provider + interface |
15
+ | LLM | `@hubblecommerce/overmind-core/llm` | Anthropic/Claude provider |
16
+ | LLM retry | `@hubblecommerce/overmind-core/llm/retry` | 429 rate limit retry wrapper |
17
+ | Processors | `@hubblecommerce/overmind-core/processors` | Confluence HTML parser + document processor |
18
+ | Confluence search tool | `@hubblecommerce/overmind-core/tools/confluence-search` | LangChain tool for semantic Confluence search |
19
+ | Date tool | `@hubblecommerce/overmind-core/tools/get-current-date` | LangChain tool returning current date |
20
+
21
+ ## Usage
22
+
23
+ ```typescript
24
+ import { config } from '@hubblecommerce/overmind-core/config';
25
+ import { ConfluenceClient } from '@hubblecommerce/overmind-core/integrations/confluence';
26
+ import { createPostgresVectorStore } from '@hubblecommerce/overmind-core/vectorstore';
27
+ import { createConfluenceSearchTool } from '@hubblecommerce/overmind-core/tools/confluence-search';
28
+ ```
29
+
30
+ ## Requirements
31
+
32
+ - Node.js 20+
33
+ - PostgreSQL 15+ with `pgvector` and `uuid-ossp` extensions enabled
34
+ - `ANTHROPIC_API_KEY` env var set
35
+
36
+ ## Publishing
37
+
38
+ ```bash
39
+ npm publish --access restricted
40
+ ```
@@ -0,0 +1,52 @@
1
+ export const config = {
2
+ llm: {
3
+ provider: 'anthropic',
4
+ modelName: 'claude-sonnet-4-6',
5
+ temperature: 0,
6
+ timeoutMs: parseInt(process.env.CLAUDE_TIMEOUT_MS || '60000'),
7
+ maxRetries: 2, // LangChain built-in retry for network errors
8
+ },
9
+ queue: {
10
+ pollIntervalMs: parseInt(process.env.QUEUE_POLL_INTERVAL_MS || '5000'),
11
+ maxRetries: parseInt(process.env.QUEUE_MAX_RETRIES || '3'),
12
+ },
13
+ embedding: {
14
+ provider: 'transformers',
15
+ modelName: 'Xenova/jina-embeddings-v2-base-de',
16
+ pooling: 'mean',
17
+ normalize: true,
18
+ },
19
+ vectorStore: {
20
+ postgres: {
21
+ host: process.env.POSTGRES_HOST || 'localhost',
22
+ port: parseInt(process.env.POSTGRES_PORT || '5432'),
23
+ user: process.env.POSTGRES_USER || 'postgres',
24
+ password: process.env.POSTGRES_PASSWORD || 'postgres',
25
+ database: process.env.POSTGRES_DB || 'rag_db',
26
+ tableName: 'confluence',
27
+ },
28
+ },
29
+ gitlab: {
30
+ baseUrl: process.env.GITLAB_BASE_URL || 'https://gitlab.com',
31
+ token: process.env.GITLAB_ACCESS_TOKEN || '',
32
+ rateLimit: 10, // requests per second
33
+ chunking: {
34
+ chunkSize: 1000,
35
+ chunkOverlap: 200,
36
+ },
37
+ },
38
+ confluence: {
39
+ baseUrl: process.env.CONFLUENCE_URL || '',
40
+ email: process.env.CONFLUENCE_EMAIL || '',
41
+ apiToken: process.env.CONFLUENCE_API_TOKEN || '',
42
+ spaces: process.env.CONFLUENCE_SPACES?.split(',').map(s => s.trim()).filter(Boolean) || [],
43
+ rateLimit: 10, // requests per second
44
+ },
45
+ jira: {
46
+ baseUrl: process.env.JIRA_BASE_URL || '',
47
+ email: process.env.JIRA_EMAIL || '',
48
+ apiToken: process.env.JIRA_API_TOKEN || '',
49
+ suggestionsSpaceId: process.env.CONFLUENCE_SUGGESTIONS_SPACE_ID || '',
50
+ suggestionsParentId: process.env.CONFLUENCE_SUGGESTIONS_PARENT_ID || '',
51
+ },
52
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { pipeline } from '@xenova/transformers';
2
+ export async function createTransformersEmbeddings(modelName, pooling, normalize) {
3
+ console.log('Loading local embedding model (first time may take a moment to download)...');
4
+ const embedder = await pipeline('feature-extraction', modelName);
5
+ return {
6
+ async embedDocuments(texts) {
7
+ const embeddings = [];
8
+ for (const text of texts) {
9
+ const output = await embedder(text, { pooling, normalize });
10
+ embeddings.push(Array.from(output.data));
11
+ }
12
+ return embeddings;
13
+ },
14
+ async embedQuery(text) {
15
+ const output = await embedder(text, { pooling, normalize });
16
+ return Array.from(output.data);
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,230 @@
1
+ export class ConfluenceClient {
2
+ baseUrl;
3
+ authHeader;
4
+ rateLimit;
5
+ lastRequestTime = 0;
6
+ constructor(config) {
7
+ this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
8
+ this.rateLimit = config.rateLimit || 10; // Default: 10 req/sec
9
+ // Create Basic Auth header
10
+ const credentials = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
11
+ this.authHeader = `Basic ${credentials}`;
12
+ }
13
+ /**
14
+ * Rate limiting: Ensure we don't exceed configured requests per second
15
+ */
16
+ async enforceRateLimit() {
17
+ const now = Date.now();
18
+ const timeSinceLastRequest = now - this.lastRequestTime;
19
+ const minInterval = 1000 / this.rateLimit;
20
+ if (timeSinceLastRequest < minInterval) {
21
+ const delay = minInterval - timeSinceLastRequest;
22
+ await new Promise(resolve => setTimeout(resolve, delay));
23
+ }
24
+ this.lastRequestTime = Date.now();
25
+ }
26
+ /**
27
+ * Make HTTP request to Confluence API with retries
28
+ */
29
+ async request(endpoint, options = {}) {
30
+ await this.enforceRateLimit();
31
+ const url = `${this.baseUrl}${endpoint}`;
32
+ const maxRetries = 3;
33
+ let lastError = null;
34
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
35
+ try {
36
+ const response = await fetch(url, {
37
+ ...options,
38
+ headers: {
39
+ 'Authorization': this.authHeader,
40
+ 'Accept': 'application/json',
41
+ 'Content-Type': 'application/json',
42
+ ...options.headers,
43
+ },
44
+ });
45
+ if (!response.ok) {
46
+ const errorText = await response.text();
47
+ const error = {
48
+ statusCode: response.status,
49
+ message: `Confluence API error: ${response.statusText}`,
50
+ reason: errorText,
51
+ };
52
+ throw new Error(JSON.stringify(error));
53
+ }
54
+ const data = await response.json();
55
+ return data;
56
+ }
57
+ catch (error) {
58
+ lastError = error;
59
+ // Don't retry on 4xx errors (client errors)
60
+ if (error instanceof Error && error.message.includes('"statusCode":4')) {
61
+ throw error;
62
+ }
63
+ // Retry on network errors or 5xx errors
64
+ if (attempt < maxRetries) {
65
+ const backoffDelay = Math.pow(2, attempt) * 1000; // Exponential backoff
66
+ console.warn(`Request failed (attempt ${attempt}/${maxRetries}), retrying in ${backoffDelay}ms...`);
67
+ await new Promise(resolve => setTimeout(resolve, backoffDelay));
68
+ }
69
+ else {
70
+ throw lastError;
71
+ }
72
+ }
73
+ }
74
+ throw lastError || new Error('Request failed after max retries');
75
+ }
76
+ /**
77
+ * Get all spaces (with pagination)
78
+ */
79
+ async getSpaces(limit = 25) {
80
+ const spaces = [];
81
+ let nextUrl = `/wiki/api/v2/spaces?limit=${limit}`;
82
+ while (nextUrl) {
83
+ const response = await this.request(nextUrl);
84
+ const results = response.results;
85
+ spaces.push(...results);
86
+ // Extract cursor from next link if exists
87
+ const links = response._links;
88
+ const nextLink = links.next;
89
+ nextUrl = nextLink ? nextLink.replace(this.baseUrl, '') : undefined;
90
+ }
91
+ return spaces;
92
+ }
93
+ /**
94
+ * Get all pages in a space (with pagination)
95
+ * Note: Accepts space key and automatically resolves to space ID for API v2
96
+ */
97
+ async getSpacePages(spaceKey, limit = 25) {
98
+ // First, resolve space key to space ID (required by API v2)
99
+ const space = await this.getSpaceByKey(spaceKey);
100
+ const spaceId = space.id;
101
+ const pages = [];
102
+ let nextUrl = `/wiki/api/v2/spaces/${spaceId}/pages?limit=${limit}&status=current`;
103
+ while (nextUrl) {
104
+ const response = await this.request(nextUrl);
105
+ const results = response.results;
106
+ pages.push(...results);
107
+ // Extract cursor from next link if exists
108
+ const links = response._links;
109
+ nextUrl = links.next || undefined;
110
+ if (nextUrl) {
111
+ // Convert full URL to relative path
112
+ nextUrl = nextUrl.replace(this.baseUrl, '');
113
+ }
114
+ }
115
+ return pages;
116
+ }
117
+ /**
118
+ * Get a single page by ID with content
119
+ */
120
+ async getPageById(pageId, bodyFormat = 'storage') {
121
+ const endpoint = `/wiki/api/v2/pages/${pageId}?body-format=${bodyFormat}`;
122
+ return await this.request(endpoint);
123
+ }
124
+ /**
125
+ * Get page content only (HTML in storage format)
126
+ */
127
+ async getPageContent(pageId) {
128
+ const page = await this.getPageById(pageId, 'storage');
129
+ if (!page.body?.storage?.value) {
130
+ throw new Error(`Page ${pageId} has no content`);
131
+ }
132
+ return page.body.storage.value;
133
+ }
134
+ /**
135
+ * Get space by key
136
+ */
137
+ async getSpaceByKey(spaceKey) {
138
+ const endpoint = `/wiki/api/v2/spaces?keys=${spaceKey}&limit=1`;
139
+ const response = await this.request(endpoint);
140
+ if (response.results.length === 0) {
141
+ throw new Error(`Space with key "${spaceKey}" not found`);
142
+ }
143
+ return response.results[0];
144
+ }
145
+ /**
146
+ * Get recent pages across all spaces or specific spaces
147
+ */
148
+ async getRecentPages(spaceKeys, limit = 25) {
149
+ let endpoint = `/wiki/api/v2/pages?limit=${limit}&sort=-modified-date&status=current`;
150
+ if (spaceKeys && spaceKeys.length > 0) {
151
+ const spaceFilter = spaceKeys.map(key => `space-key=${key}`).join('&');
152
+ endpoint += `&${spaceFilter}`;
153
+ }
154
+ const response = await this.request(endpoint);
155
+ return response.results;
156
+ }
157
+ /**
158
+ * Get labels for a page (using v1 API as v2 doesn't support this yet)
159
+ * Returns only label names as strings
160
+ */
161
+ async getPageLabels(pageId) {
162
+ const labels = [];
163
+ let start = 0;
164
+ const limit = 200;
165
+ // Use v1 API endpoint for labels
166
+ let hasMore = true;
167
+ while (hasMore) {
168
+ const endpoint = `/wiki/rest/api/content/${pageId}/label?start=${start}&limit=${limit}`;
169
+ const response = await this.request(endpoint);
170
+ labels.push(...response.results);
171
+ // Check if there are more results
172
+ if (response.results.length < limit) {
173
+ hasMore = false;
174
+ }
175
+ else {
176
+ start += limit;
177
+ }
178
+ }
179
+ // Return only the label names (without prefix)
180
+ return labels.map(label => label.name);
181
+ }
182
+ /**
183
+ * Get numeric space ID from space key
184
+ * Helper method to convert space key to numeric ID required by v2 API
185
+ * @param spaceKey - The space key (e.g., "TEST")
186
+ * @returns The numeric space ID
187
+ */
188
+ async getSpaceIdByKey(spaceKey) {
189
+ const space = await this.getSpaceByKey(spaceKey);
190
+ return space.id;
191
+ }
192
+ /**
193
+ * Create a new page in Confluence using REST API v2 with ADF (Atlassian Document Format)
194
+ * This is the modern format for the new Confluence editor
195
+ * @param spaceId - The numeric space ID (not space key) - use getSpaceIdByKey() if you have a space key
196
+ * @param title - The page title
197
+ * @param adfBody - The page content in ADF format (from @atlaskit/editor-markdown-transformer)
198
+ * @param parentId - Optional parent page ID to create as child page
199
+ * @returns The created page
200
+ */
201
+ async createPageWithADF(params) {
202
+ const payload = {
203
+ spaceId: params.spaceId,
204
+ status: 'current',
205
+ title: params.title,
206
+ body: {
207
+ representation: 'atlas_doc_format',
208
+ value: JSON.stringify(params.adfBody), // ADF must be JSON-encoded string
209
+ },
210
+ };
211
+ // If parentId provided, add to payload
212
+ if (params.parentId) {
213
+ payload.parentId = params.parentId;
214
+ }
215
+ const response = await this.request('/wiki/api/v2/pages', {
216
+ method: 'POST',
217
+ body: JSON.stringify(payload),
218
+ });
219
+ return {
220
+ id: response.id,
221
+ status: response.status,
222
+ title: response.title,
223
+ spaceId: response.spaceId,
224
+ authorId: response.authorId,
225
+ createdAt: response.createdAt,
226
+ version: response.version,
227
+ _links: response._links,
228
+ };
229
+ }
230
+ }
@@ -0,0 +1,15 @@
1
+ import { Gitlab } from '@gitbeaker/rest';
2
+ import { config } from '../../config/app.config.js';
3
+ let gitlabClient = null;
4
+ export function getGitlabClient() {
5
+ if (!gitlabClient) {
6
+ if (!config.gitlab.token) {
7
+ throw new Error('GitLab access token not configured. Set GITLAB_ACCESS_TOKEN in .env');
8
+ }
9
+ gitlabClient = new Gitlab({
10
+ host: config.gitlab.baseUrl,
11
+ token: config.gitlab.token,
12
+ });
13
+ }
14
+ return gitlabClient;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,150 @@
1
+ export class JiraClient {
2
+ baseUrl;
3
+ authHeader;
4
+ constructor(config) {
5
+ this.baseUrl = config.baseUrl;
6
+ this.authHeader = 'Basic ' + Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
7
+ }
8
+ /**
9
+ * Parse Atlassian Document Format (ADF) to plain text
10
+ */
11
+ parseADFToText(node) {
12
+ if (node.type === 'text') {
13
+ return node.text || '';
14
+ }
15
+ if (node.type === 'mention' && node.attrs?.text) {
16
+ return node.attrs.text;
17
+ }
18
+ if (node.content) {
19
+ const childText = node.content.map(child => this.parseADFToText(child)).join('');
20
+ // Add spacing/formatting based on node type
21
+ switch (node.type) {
22
+ case 'paragraph':
23
+ return childText + '\n';
24
+ case 'blockquote':
25
+ return '> ' + childText + '\n';
26
+ case 'heading':
27
+ return '## ' + childText + '\n';
28
+ case 'listItem':
29
+ return '- ' + childText + '\n';
30
+ case 'codeBlock':
31
+ return '```\n' + childText + '```\n';
32
+ default:
33
+ return childText;
34
+ }
35
+ }
36
+ return '';
37
+ }
38
+ /**
39
+ * Convert ADF comment body to readable text
40
+ */
41
+ parseCommentBody(body) {
42
+ if (typeof body === 'string') {
43
+ return body;
44
+ }
45
+ if (typeof body === 'object' && body !== null) {
46
+ const adfDoc = body;
47
+ if (adfDoc.type === 'doc' && adfDoc.content) {
48
+ return adfDoc.content.map(node => this.parseADFToText(node)).join('').trim();
49
+ }
50
+ }
51
+ return '';
52
+ }
53
+ /**
54
+ * Fetch a Jira issue by key or ID, including comments
55
+ */
56
+ async getIssue(issueKeyOrId) {
57
+ try {
58
+ // Fetch issue details
59
+ const issueResponse = await fetch(`${this.baseUrl}/rest/api/3/issue/${issueKeyOrId}`, {
60
+ headers: {
61
+ 'Authorization': this.authHeader,
62
+ 'Content-Type': 'application/json',
63
+ 'Accept': 'application/json',
64
+ },
65
+ });
66
+ if (!issueResponse.ok) {
67
+ throw new Error(`Failed to fetch issue: ${issueResponse.status} ${issueResponse.statusText}`);
68
+ }
69
+ const issue = await issueResponse.json();
70
+ // Fetch comments
71
+ const commentsResponse = await fetch(`${this.baseUrl}/rest/api/3/issue/${issueKeyOrId}/comment`, {
72
+ headers: {
73
+ 'Authorization': this.authHeader,
74
+ 'Content-Type': 'application/json',
75
+ 'Accept': 'application/json',
76
+ },
77
+ });
78
+ if (!commentsResponse.ok) {
79
+ throw new Error(`Failed to fetch comments: ${commentsResponse.status} ${commentsResponse.statusText}`);
80
+ }
81
+ const commentsData = await commentsResponse.json();
82
+ // Parse and filter comments (only 'doc' type, latest version only)
83
+ const commentsMap = new Map();
84
+ commentsData.comments?.forEach(comment => {
85
+ // Only include ADF doc type comments
86
+ if (typeof comment.body === 'object' && comment.body !== null) {
87
+ const adfDoc = comment.body;
88
+ if (adfDoc.type !== 'doc') {
89
+ return;
90
+ }
91
+ const version = adfDoc.version || 1;
92
+ const existing = commentsMap.get(comment.id);
93
+ // Only keep the latest version of each comment
94
+ if (!existing || version > existing.version) {
95
+ commentsMap.set(comment.id, {
96
+ author: comment.author.displayName,
97
+ body: this.parseCommentBody(comment.body),
98
+ updated: comment.updated,
99
+ version,
100
+ });
101
+ }
102
+ }
103
+ });
104
+ // Convert map to array (without version field)
105
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
106
+ const parsedComments = Array.from(commentsMap.values()).map(({ version, ...comment }) => comment);
107
+ // Transform to our interface
108
+ return {
109
+ id: issue.id,
110
+ key: issue.key,
111
+ self: issue.self,
112
+ fields: {
113
+ summary: issue.fields.summary,
114
+ description: issue.fields.description,
115
+ status: {
116
+ name: issue.fields.status.name,
117
+ id: issue.fields.status.id,
118
+ },
119
+ issuetype: {
120
+ name: issue.fields.issuetype.name,
121
+ id: issue.fields.issuetype.id,
122
+ },
123
+ project: {
124
+ key: issue.fields.project.key,
125
+ id: issue.fields.project.id,
126
+ name: issue.fields.project.name,
127
+ },
128
+ assignee: issue.fields.assignee ? {
129
+ accountId: issue.fields.assignee.accountId,
130
+ displayName: issue.fields.assignee.displayName,
131
+ } : null,
132
+ reporter: issue.fields.reporter ? {
133
+ accountId: issue.fields.reporter.accountId,
134
+ displayName: issue.fields.reporter.displayName,
135
+ } : null,
136
+ created: issue.fields.created,
137
+ updated: issue.fields.updated,
138
+ resolutiondate: issue.fields.resolutiondate,
139
+ labels: issue.fields.labels || [],
140
+ components: issue.fields.components || [],
141
+ },
142
+ comments: parsedComments,
143
+ };
144
+ }
145
+ catch (error) {
146
+ console.error(`[JiraClient] Error fetching issue ${issueKeyOrId}:`, error);
147
+ throw error;
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Retry wrapper for Anthropic API calls with 429 rate limit handling
3
+ * Respects retry-after headers and implements exponential backoff
4
+ */
5
+ /**
6
+ * Custom error for rate limit responses
7
+ * Includes retry-after information from API headers
8
+ */
9
+ export class RateLimitError extends Error {
10
+ retryAfterSeconds;
11
+ constructor(message, retryAfterSeconds) {
12
+ super(message);
13
+ this.retryAfterSeconds = retryAfterSeconds;
14
+ this.name = 'RateLimitError';
15
+ }
16
+ }
17
+ /**
18
+ * Invoke an agent with automatic retry on 429 errors
19
+ * @param agent LangChain agent instance
20
+ * @param input Agent input (messages, etc.)
21
+ * @param maxRetries Maximum number of retry attempts (default: 3)
22
+ * @returns Agent response
23
+ * @throws RateLimitError if all retries exhausted due to 429
24
+ * @throws Original error if non-429 error occurs
25
+ */
26
+ export async function invokeWithRetry(agent, input, maxRetries = 3) {
27
+ let lastError = new Error('Unknown error');
28
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
29
+ try {
30
+ const result = await agent.invoke(input);
31
+ return result;
32
+ }
33
+ catch (error) {
34
+ if (!(error instanceof Error)) {
35
+ throw new Error('Unknown error occurred');
36
+ }
37
+ lastError = error;
38
+ const apiError = error;
39
+ // Check if it's a 429 rate limit error
40
+ const is429 = apiError.status === 429 ||
41
+ apiError.message.includes('429') ||
42
+ apiError.message.includes('rate limit') ||
43
+ apiError.message.toLowerCase().includes('too many requests');
44
+ if (is429) {
45
+ // Extract retry-after header (in seconds)
46
+ const retryAfter = extractRetryAfter(apiError);
47
+ const waitMs = retryAfter
48
+ ? retryAfter * 1000
49
+ : Math.pow(2, attempt) * 1000; // Exponential fallback: 2s, 4s, 8s
50
+ console.log(`[AnthropicRetry] Attempt ${attempt}/${maxRetries} failed with 429`, {
51
+ retryAfterHeader: retryAfter,
52
+ waitMs,
53
+ willRetry: attempt < maxRetries,
54
+ });
55
+ if (attempt < maxRetries) {
56
+ // Wait and retry
57
+ await sleep(waitMs);
58
+ continue;
59
+ }
60
+ else {
61
+ // Last attempt failed - throw with retry info for queue worker
62
+ throw new RateLimitError(apiError.message || 'Rate limit exceeded', retryAfter);
63
+ }
64
+ }
65
+ // Non-429 errors - throw immediately (don't retry)
66
+ console.error('[AnthropicRetry] Non-retryable error:', apiError.message);
67
+ throw error;
68
+ }
69
+ }
70
+ // Should never reach here, but TypeScript requires it
71
+ throw lastError;
72
+ }
73
+ /**
74
+ * Extract retry-after header from error response
75
+ * Tries multiple possible error structures from different API clients
76
+ * @param error Error object from API call
77
+ * @returns Retry-after value in seconds, or null if not found
78
+ */
79
+ function extractRetryAfter(error) {
80
+ // Try multiple paths to find retry-after header
81
+ let retryAfter;
82
+ if (error.response?.headers) {
83
+ const headers = error.response.headers;
84
+ if (typeof headers === 'object' && 'get' in headers && typeof headers.get === 'function') {
85
+ retryAfter = headers.get('retry-after');
86
+ }
87
+ else if (typeof headers === 'object') {
88
+ retryAfter = headers['retry-after'];
89
+ }
90
+ }
91
+ if (!retryAfter && error.headers) {
92
+ retryAfter = error.headers['retry-after'];
93
+ }
94
+ if (!retryAfter && error.error?.headers) {
95
+ retryAfter = error.error.headers['retry-after'];
96
+ }
97
+ if (!retryAfter) {
98
+ return null;
99
+ }
100
+ // Parse as integer (seconds)
101
+ const parsed = parseInt(retryAfter, 10);
102
+ return isNaN(parsed) ? null : parsed;
103
+ }
104
+ /**
105
+ * Sleep utility for async delays
106
+ * @param ms Milliseconds to sleep
107
+ */
108
+ function sleep(ms) {
109
+ return new Promise(resolve => setTimeout(resolve, ms));
110
+ }
@@ -0,0 +1,11 @@
1
+ import { ChatAnthropic } from '@langchain/anthropic';
2
+ export function createAnthropicProvider(config) {
3
+ return new ChatAnthropic({
4
+ modelName: config.modelName,
5
+ temperature: config.temperature,
6
+ maxRetries: config.maxRetries || 2, // LangChain built-in retry for network errors
7
+ clientOptions: {
8
+ timeout: config.timeoutMs || 60000, // 60 second timeout default
9
+ },
10
+ });
11
+ }