@aj-archipelago/cortex 1.4.31 → 1.4.33
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/config.js +72 -0
- package/helper-apps/cortex-file-handler/Dockerfile +1 -1
- package/lib/fileUtils.js +24 -5
- package/lib/pathwayManager.js +13 -6
- package/lib/pathwayTools.js +92 -1
- package/lib/requestExecutor.js +49 -5
- package/package.json +1 -1
- package/pathways/system/entity/sys_compress_context.js +82 -0
- package/pathways/system/entity/sys_entity_agent.js +106 -18
- package/pathways/transcribe_gemini.js +1 -1
- package/server/clientToolCallbacks.js +241 -0
- package/server/executeWorkspace.js +7 -0
- package/server/graphql.js +3 -1
- package/server/modelExecutor.js +4 -0
- package/server/pathwayResolver.js +102 -12
- package/server/plugins/claudeAnthropicPlugin.js +84 -0
- package/server/plugins/gemini15ChatPlugin.js +17 -0
- package/server/plugins/gemini15VisionPlugin.js +67 -8
- package/server/plugins/grokResponsesPlugin.js +2 -0
- package/server/plugins/openAiVisionPlugin.js +4 -2
- package/server/resolver.js +37 -2
- package/test.log +42834 -0
- package/tests/integration/clientToolCallbacks.test.js +161 -0
- package/tests/integration/features/tools/fileCollection.test.js +1 -1
- package/tests/integration/rest/vendors/claude_anthropic_direct.test.js +197 -0
- package/tests/unit/plugins/claudeAnthropicPlugin.test.js +236 -0
- package/tests/unit/plugins/multimodal_conversion.test.js +16 -6
- package/tests/unit/sys_entity_agent_errors.test.js +792 -0
- package/RELEASE_NOTES_20251231_103631.md +0 -15
- package/RELEASE_NOTES_20251231_110946.md +0 -5
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Test for client tool callbacks with Redis pub/sub
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
|
|
3
|
+
import {
|
|
4
|
+
waitForClientToolResult,
|
|
5
|
+
resolveClientToolCallback,
|
|
6
|
+
getPendingCallbackCount
|
|
7
|
+
} from '../../server/clientToolCallbacks.js';
|
|
8
|
+
|
|
9
|
+
describe('Client Tool Callbacks - Multi-Instance Support', () => {
|
|
10
|
+
const mockRequestId = 'test-request-123';
|
|
11
|
+
|
|
12
|
+
afterAll(async () => {
|
|
13
|
+
// Give Redis time to clean up connections
|
|
14
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should register and resolve a callback locally', async () => {
|
|
18
|
+
const toolCallbackId = 'test-callback-1';
|
|
19
|
+
|
|
20
|
+
// Start waiting for result
|
|
21
|
+
const waitPromise = waitForClientToolResult(toolCallbackId, mockRequestId, 5000);
|
|
22
|
+
|
|
23
|
+
// Verify callback is registered
|
|
24
|
+
expect(getPendingCallbackCount()).toBeGreaterThan(0);
|
|
25
|
+
|
|
26
|
+
// Simulate client submitting result
|
|
27
|
+
const testResult = {
|
|
28
|
+
success: true,
|
|
29
|
+
data: { message: 'Test completed' },
|
|
30
|
+
error: null
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Resolve the callback (this will publish to Redis if available, or resolve locally)
|
|
34
|
+
const resolved = await resolveClientToolCallback(toolCallbackId, testResult);
|
|
35
|
+
expect(resolved).toBe(true);
|
|
36
|
+
|
|
37
|
+
// Wait for the result
|
|
38
|
+
const result = await waitPromise;
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual(testResult);
|
|
41
|
+
expect(result.success).toBe(true);
|
|
42
|
+
expect(result.data.message).toBe('Test completed');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should timeout if no result is received', async () => {
|
|
46
|
+
const toolCallbackId = 'test-callback-timeout';
|
|
47
|
+
|
|
48
|
+
// Start waiting with a short timeout
|
|
49
|
+
const waitPromise = waitForClientToolResult(toolCallbackId, mockRequestId, 100);
|
|
50
|
+
|
|
51
|
+
// Don't resolve - let it timeout
|
|
52
|
+
await expect(waitPromise).rejects.toThrow('Client tool execution timeout');
|
|
53
|
+
}, 10000);
|
|
54
|
+
|
|
55
|
+
it('should handle callback with error result', async () => {
|
|
56
|
+
const toolCallbackId = 'test-callback-error';
|
|
57
|
+
|
|
58
|
+
// Start waiting for result
|
|
59
|
+
const waitPromise = waitForClientToolResult(toolCallbackId, mockRequestId, 5000);
|
|
60
|
+
|
|
61
|
+
// Simulate client submitting error result
|
|
62
|
+
const errorResult = {
|
|
63
|
+
success: false,
|
|
64
|
+
data: null,
|
|
65
|
+
error: 'Tool execution failed'
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Resolve with error
|
|
69
|
+
await resolveClientToolCallback(toolCallbackId, errorResult);
|
|
70
|
+
|
|
71
|
+
// Wait for the result
|
|
72
|
+
const result = await waitPromise;
|
|
73
|
+
|
|
74
|
+
expect(result.success).toBe(false);
|
|
75
|
+
expect(result.error).toBe('Tool execution failed');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle multiple concurrent callbacks', async () => {
|
|
79
|
+
const callbacks = [];
|
|
80
|
+
const numCallbacks = 5;
|
|
81
|
+
|
|
82
|
+
// Register multiple callbacks
|
|
83
|
+
for (let i = 0; i < numCallbacks; i++) {
|
|
84
|
+
const callbackId = `concurrent-callback-${i}`;
|
|
85
|
+
const promise = waitForClientToolResult(callbackId, mockRequestId, 5000);
|
|
86
|
+
callbacks.push({ id: callbackId, promise });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Resolve all callbacks
|
|
90
|
+
for (let i = 0; i < numCallbacks; i++) {
|
|
91
|
+
const result = {
|
|
92
|
+
success: true,
|
|
93
|
+
data: { index: i, message: `Result ${i}` },
|
|
94
|
+
error: null
|
|
95
|
+
};
|
|
96
|
+
await resolveClientToolCallback(callbacks[i].id, result);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Wait for all results
|
|
100
|
+
const results = await Promise.all(callbacks.map(cb => cb.promise));
|
|
101
|
+
|
|
102
|
+
// Verify all results
|
|
103
|
+
expect(results).toHaveLength(numCallbacks);
|
|
104
|
+
results.forEach((result, index) => {
|
|
105
|
+
expect(result.success).toBe(true);
|
|
106
|
+
expect(result.data.index).toBe(index);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should return false when resolving non-existent callback', async () => {
|
|
111
|
+
const nonExistentId = 'non-existent-callback-id';
|
|
112
|
+
|
|
113
|
+
const result = {
|
|
114
|
+
success: true,
|
|
115
|
+
data: { message: 'Test' },
|
|
116
|
+
error: null
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// This should publish to Redis (if available) or return false locally
|
|
120
|
+
// Either way, it should not throw an error
|
|
121
|
+
const resolved = await resolveClientToolCallback(nonExistentId, result);
|
|
122
|
+
|
|
123
|
+
// In Redis mode, this returns true (published)
|
|
124
|
+
// In local mode, this returns false (not found)
|
|
125
|
+
expect(typeof resolved).toBe('boolean');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('Client Tool Callbacks - Performance', () => {
|
|
130
|
+
it('should handle rapid callback resolution', async () => {
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
const numCallbacks = 100;
|
|
133
|
+
const callbacks = [];
|
|
134
|
+
|
|
135
|
+
// Register many callbacks rapidly
|
|
136
|
+
for (let i = 0; i < numCallbacks; i++) {
|
|
137
|
+
const callbackId = `perf-callback-${i}`;
|
|
138
|
+
const promise = waitForClientToolResult(callbackId, 'perf-test', 5000);
|
|
139
|
+
callbacks.push({ id: callbackId, promise });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve all callbacks rapidly
|
|
143
|
+
for (let i = 0; i < numCallbacks; i++) {
|
|
144
|
+
await resolveClientToolCallback(callbacks[i].id, {
|
|
145
|
+
success: true,
|
|
146
|
+
data: { index: i },
|
|
147
|
+
error: null
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Wait for all
|
|
152
|
+
const results = await Promise.all(callbacks.map(cb => cb.promise));
|
|
153
|
+
const duration = Date.now() - start;
|
|
154
|
+
|
|
155
|
+
expect(results).toHaveLength(numCallbacks);
|
|
156
|
+
expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds
|
|
157
|
+
|
|
158
|
+
console.log(`Performance test: ${numCallbacks} callbacks resolved in ${duration}ms (${(duration/numCallbacks).toFixed(2)}ms avg per callback)`);
|
|
159
|
+
}, 15000);
|
|
160
|
+
});
|
|
161
|
+
|
|
@@ -1086,7 +1086,7 @@ test('File collection: syncAndStripFilesFromChatHistory only strips collection f
|
|
|
1086
1086
|
];
|
|
1087
1087
|
|
|
1088
1088
|
// Process chat history
|
|
1089
|
-
const { chatHistory: processed } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId));
|
|
1089
|
+
const { chatHistory: processed, availableFiles } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId));
|
|
1090
1090
|
|
|
1091
1091
|
// Verify only collection file was stripped
|
|
1092
1092
|
const content = processed[0].content;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E integration test for direct Anthropic API access (not via Vertex AI)
|
|
3
|
+
* Tests the CLAUDE-ANTHROPIC plugin type with Claude models
|
|
4
|
+
*
|
|
5
|
+
* Run with: npm test -- tests/integration/rest/vendors/claude_anthropic_direct.test.js
|
|
6
|
+
*/
|
|
7
|
+
import test from 'ava';
|
|
8
|
+
import serverFactory from '../../../../index.js';
|
|
9
|
+
import got from 'got';
|
|
10
|
+
import { collectSSEChunks, assertOAIChatChunkBasics, assertAnyContentDelta } from '../../../helpers/sseAssert.js';
|
|
11
|
+
|
|
12
|
+
let testServer;
|
|
13
|
+
|
|
14
|
+
test.before(async () => {
|
|
15
|
+
process.env.CORTEX_ENABLE_REST = 'true';
|
|
16
|
+
const { server, startServer } = await serverFactory();
|
|
17
|
+
startServer && await startServer();
|
|
18
|
+
testServer = server;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test.after.always('cleanup', async () => {
|
|
22
|
+
if (testServer) await testServer.stop();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Test: Basic streaming chat completion with Claude 4.5 Sonnet via direct Anthropic API
|
|
26
|
+
test('Claude 4.5 Sonnet (direct Anthropic) SSE streaming chat', async (t) => {
|
|
27
|
+
const baseUrl = `http://localhost:${process.env.CORTEX_PORT}/v1`;
|
|
28
|
+
|
|
29
|
+
// Use the direct Anthropic model (claude-45-sonnet)
|
|
30
|
+
const model = 'claude-45-sonnet';
|
|
31
|
+
|
|
32
|
+
// Verify the model is available
|
|
33
|
+
try {
|
|
34
|
+
const res = await got(`${baseUrl}/models`, { responseType: 'json' });
|
|
35
|
+
const ids = (res.body?.data || []).map(m => m.id);
|
|
36
|
+
if (!ids.includes(model)) {
|
|
37
|
+
t.pass(`Skipping - model ${model} not configured`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
t.fail(`Failed to get models: ${err.message}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const payload = {
|
|
46
|
+
model,
|
|
47
|
+
messages: [{ role: 'user', content: 'Say "Hello from Anthropic direct API!" and nothing else.' }],
|
|
48
|
+
stream: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const chunks = await collectSSEChunks(baseUrl, '/chat/completions', payload);
|
|
52
|
+
t.true(chunks.length > 0, 'Should receive SSE chunks');
|
|
53
|
+
chunks.forEach(ch => assertOAIChatChunkBasics(t, ch));
|
|
54
|
+
t.true(assertAnyContentDelta(chunks), 'Should have content delta in chunks');
|
|
55
|
+
|
|
56
|
+
// Log the full response for debugging
|
|
57
|
+
const fullContent = chunks
|
|
58
|
+
.map(c => c?.choices?.[0]?.delta?.content || '')
|
|
59
|
+
.join('');
|
|
60
|
+
t.log(`Response: ${fullContent}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Test: Non-streaming chat completion with Claude 4.5 Sonnet via direct Anthropic API
|
|
64
|
+
test('Claude 4.5 Sonnet (direct Anthropic) non-streaming chat', async (t) => {
|
|
65
|
+
const baseUrl = `http://localhost:${process.env.CORTEX_PORT}/v1`;
|
|
66
|
+
const model = 'claude-45-sonnet';
|
|
67
|
+
|
|
68
|
+
// Verify the model is available
|
|
69
|
+
try {
|
|
70
|
+
const res = await got(`${baseUrl}/models`, { responseType: 'json' });
|
|
71
|
+
const ids = (res.body?.data || []).map(m => m.id);
|
|
72
|
+
if (!ids.includes(model)) {
|
|
73
|
+
t.pass(`Skipping - model ${model} not configured`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
t.fail(`Failed to get models: ${err.message}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const payload = {
|
|
82
|
+
model,
|
|
83
|
+
messages: [{ role: 'user', content: 'What is 2 + 2? Reply with just the number.' }],
|
|
84
|
+
stream: false,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await got.post(`${baseUrl}/chat/completions`, {
|
|
89
|
+
json: payload,
|
|
90
|
+
responseType: 'json',
|
|
91
|
+
timeout: { request: 60000 }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
t.truthy(response.body, 'Should have response body');
|
|
95
|
+
t.truthy(response.body.choices, 'Should have choices');
|
|
96
|
+
t.truthy(response.body.choices[0].message, 'Should have message');
|
|
97
|
+
t.truthy(response.body.choices[0].message.content, 'Should have content');
|
|
98
|
+
|
|
99
|
+
t.log(`Response: ${response.body.choices[0].message.content}`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
t.fail(`Request failed: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Test: Chat with system message
|
|
106
|
+
test('Claude 4.5 Sonnet (direct Anthropic) with system message', async (t) => {
|
|
107
|
+
const baseUrl = `http://localhost:${process.env.CORTEX_PORT}/v1`;
|
|
108
|
+
const model = 'claude-45-sonnet';
|
|
109
|
+
|
|
110
|
+
// Verify the model is available
|
|
111
|
+
try {
|
|
112
|
+
const res = await got(`${baseUrl}/models`, { responseType: 'json' });
|
|
113
|
+
const ids = (res.body?.data || []).map(m => m.id);
|
|
114
|
+
if (!ids.includes(model)) {
|
|
115
|
+
t.pass(`Skipping - model ${model} not configured`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
t.fail(`Failed to get models: ${err.message}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const payload = {
|
|
124
|
+
model,
|
|
125
|
+
messages: [
|
|
126
|
+
{ role: 'system', content: 'You are a pirate. Always respond in pirate speak.' },
|
|
127
|
+
{ role: 'user', content: 'Hello!' }
|
|
128
|
+
],
|
|
129
|
+
stream: true,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const chunks = await collectSSEChunks(baseUrl, '/chat/completions', payload);
|
|
133
|
+
t.true(chunks.length > 0, 'Should receive SSE chunks');
|
|
134
|
+
chunks.forEach(ch => assertOAIChatChunkBasics(t, ch));
|
|
135
|
+
t.true(assertAnyContentDelta(chunks), 'Should have content delta in chunks');
|
|
136
|
+
|
|
137
|
+
const fullContent = chunks
|
|
138
|
+
.map(c => c?.choices?.[0]?.delta?.content || '')
|
|
139
|
+
.join('');
|
|
140
|
+
t.log(`Response: ${fullContent}`);
|
|
141
|
+
// Should contain pirate-like language
|
|
142
|
+
t.regex(fullContent.toLowerCase(), /ahoy|arr|matey|ye|cap|sail|treasure/i, 'Should respond in pirate speak');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Test: Document block support (PDF)
|
|
146
|
+
test('Claude 4.5 Sonnet (direct Anthropic) with PDF document', async (t) => {
|
|
147
|
+
const baseUrl = `http://localhost:${process.env.CORTEX_PORT}/v1`;
|
|
148
|
+
const model = 'claude-45-sonnet';
|
|
149
|
+
|
|
150
|
+
// Verify the model is available
|
|
151
|
+
try {
|
|
152
|
+
const res = await got(`${baseUrl}/models`, { responseType: 'json' });
|
|
153
|
+
const ids = (res.body?.data || []).map(m => m.id);
|
|
154
|
+
if (!ids.includes(model)) {
|
|
155
|
+
t.pass(`Skipping - model ${model} not configured`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
t.fail(`Failed to get models: ${err.message}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Minimal valid PDF
|
|
164
|
+
const pdfContent = '%PDF-1.4\n%âãÏÓ\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n3 0 obj\n<</Type/Page/Parent 2 0 R/Resources<</Font<</F1 4 0 R>>>>/MediaBox[0 0 612 792]/Contents 5 0 R>>\nendobj\n4 0 obj\n<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>\nendobj\n5 0 obj\n<</Length 44>>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Test PDF Doc) Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f\n0000000010 00000 n\n0000000053 00000 n\n0000000102 00000 n\n0000000211 00000 n\n0000000280 00000 n\ntrailer\n<</Size 6/Root 1 0 R>>\nstartxref\n369\n%%EOF';
|
|
165
|
+
const base64Pdf = Buffer.from(pdfContent).toString('base64');
|
|
166
|
+
|
|
167
|
+
const payload = {
|
|
168
|
+
model,
|
|
169
|
+
messages: [
|
|
170
|
+
{
|
|
171
|
+
role: 'user',
|
|
172
|
+
content: [
|
|
173
|
+
{ type: 'text', text: 'What text is in this PDF? Reply with just the text you see.' },
|
|
174
|
+
{
|
|
175
|
+
type: 'document',
|
|
176
|
+
source: {
|
|
177
|
+
type: 'base64',
|
|
178
|
+
media_type: 'application/pdf',
|
|
179
|
+
data: base64Pdf
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
stream: true,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const chunks = await collectSSEChunks(baseUrl, '/chat/completions', payload);
|
|
189
|
+
t.true(chunks.length > 0, 'Should receive SSE chunks');
|
|
190
|
+
chunks.forEach(ch => assertOAIChatChunkBasics(t, ch));
|
|
191
|
+
t.true(assertAnyContentDelta(chunks), 'Should have content delta in chunks');
|
|
192
|
+
|
|
193
|
+
const fullContent = chunks
|
|
194
|
+
.map(c => c?.choices?.[0]?.delta?.content || '')
|
|
195
|
+
.join('');
|
|
196
|
+
t.log(`Response: ${fullContent}`);
|
|
197
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import ClaudeAnthropicPlugin from '../../../server/plugins/claudeAnthropicPlugin.js';
|
|
3
|
+
import { mockPathwayResolverMessages } from '../../helpers/mocks.js';
|
|
4
|
+
import { config } from '../../../config.js';
|
|
5
|
+
|
|
6
|
+
// Create a mock model config that matches Anthropic direct API format
|
|
7
|
+
const anthropicModel = {
|
|
8
|
+
...mockPathwayResolverMessages.model,
|
|
9
|
+
type: 'CLAUDE-ANTHROPIC',
|
|
10
|
+
params: {
|
|
11
|
+
model: 'claude-sonnet-4-20250514'
|
|
12
|
+
},
|
|
13
|
+
endpoints: [
|
|
14
|
+
{
|
|
15
|
+
name: 'Anthropic Claude Sonnet 4',
|
|
16
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
17
|
+
headers: {
|
|
18
|
+
'x-api-key': '{{ANTHROPIC_API_KEY}}',
|
|
19
|
+
'Content-Type': 'application/json'
|
|
20
|
+
},
|
|
21
|
+
params: {
|
|
22
|
+
model: 'claude-sonnet-4-20250514'
|
|
23
|
+
},
|
|
24
|
+
requestsPerSecond: 10
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
maxTokenLength: 200000,
|
|
28
|
+
maxReturnTokens: 64000,
|
|
29
|
+
maxImageSize: 31457280,
|
|
30
|
+
supportsStreaming: true
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const { pathway } = mockPathwayResolverMessages;
|
|
34
|
+
|
|
35
|
+
test('constructor', (t) => {
|
|
36
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
37
|
+
t.is(plugin.config, config);
|
|
38
|
+
t.is(plugin.pathwayPrompt, mockPathwayResolverMessages.pathway.prompt);
|
|
39
|
+
t.true(plugin.isMultiModal);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('parseResponse - text content response', (t) => {
|
|
43
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
44
|
+
|
|
45
|
+
const dataWithTextContent = {
|
|
46
|
+
content: [
|
|
47
|
+
{ type: 'text', text: 'Hello from Anthropic!' }
|
|
48
|
+
],
|
|
49
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
50
|
+
stop_reason: 'end_turn'
|
|
51
|
+
};
|
|
52
|
+
const result = plugin.parseResponse(dataWithTextContent);
|
|
53
|
+
t.truthy(result.output_text === 'Hello from Anthropic!');
|
|
54
|
+
t.truthy(result.finishReason === 'stop');
|
|
55
|
+
t.truthy(result.usage);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('parseResponse - tool calls response', (t) => {
|
|
59
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
60
|
+
|
|
61
|
+
const dataWithToolCalls = {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: 'tool_use',
|
|
65
|
+
id: 'tool_anthropic_1',
|
|
66
|
+
name: 'get_weather',
|
|
67
|
+
input: { location: 'San Francisco' }
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
usage: { input_tokens: 15, output_tokens: 8 },
|
|
71
|
+
stop_reason: 'tool_use'
|
|
72
|
+
};
|
|
73
|
+
const result = plugin.parseResponse(dataWithToolCalls);
|
|
74
|
+
t.truthy(result.output_text === '');
|
|
75
|
+
t.truthy(result.finishReason === 'tool_calls');
|
|
76
|
+
t.truthy(result.toolCalls);
|
|
77
|
+
t.truthy(result.toolCalls.length === 1);
|
|
78
|
+
t.truthy(result.toolCalls[0].id === 'tool_anthropic_1');
|
|
79
|
+
t.truthy(result.toolCalls[0].function.name === 'get_weather');
|
|
80
|
+
t.truthy(result.toolCalls[0].function.arguments === '{"location":"San Francisco"}');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('getRequestParameters includes model in body', async (t) => {
|
|
84
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
85
|
+
|
|
86
|
+
const messages = [
|
|
87
|
+
{ role: 'user', content: 'Hello' }
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const parameters = { messages };
|
|
91
|
+
const requestParams = await plugin.getRequestParameters('', parameters, {});
|
|
92
|
+
|
|
93
|
+
// Should have model in request body
|
|
94
|
+
t.is(requestParams.model, 'claude-sonnet-4-20250514');
|
|
95
|
+
|
|
96
|
+
// Should NOT have anthropic_version in body (it's a Vertex thing)
|
|
97
|
+
t.is(requestParams.anthropic_version, undefined);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('convertMessagesToClaudeVertex preserves message conversion from parent', async (t) => {
|
|
101
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
102
|
+
|
|
103
|
+
// Test message conversion directly - this tests the inherited behavior
|
|
104
|
+
const messages = [
|
|
105
|
+
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
106
|
+
{ role: 'user', content: 'What is 2+2?' }
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const output = await plugin.convertMessagesToClaudeVertex(messages);
|
|
110
|
+
|
|
111
|
+
// System message should be extracted
|
|
112
|
+
t.is(output.system, 'You are a helpful assistant.');
|
|
113
|
+
|
|
114
|
+
// User message should be converted to Claude format
|
|
115
|
+
t.is(output.modifiedMessages.length, 1);
|
|
116
|
+
t.is(output.modifiedMessages[0].role, 'user');
|
|
117
|
+
t.deepEqual(output.modifiedMessages[0].content, [{ type: 'text', text: 'What is 2+2?' }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('handles tool_use and tool_result in messages', async (t) => {
|
|
121
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
122
|
+
|
|
123
|
+
// Test that tool messages are handled correctly via message conversion
|
|
124
|
+
const messages = [
|
|
125
|
+
{ role: 'user', content: 'Search for cats' },
|
|
126
|
+
{
|
|
127
|
+
role: 'assistant',
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: 'tool_use',
|
|
131
|
+
id: 'tool_1',
|
|
132
|
+
name: 'search',
|
|
133
|
+
input: { query: 'cats' }
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
role: 'user',
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: 'tool_result',
|
|
142
|
+
tool_use_id: 'tool_1',
|
|
143
|
+
content: 'Found 100 results about cats'
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const output = await plugin.convertMessagesToClaudeVertex(messages);
|
|
150
|
+
|
|
151
|
+
// Should have 3 messages with proper roles and content types
|
|
152
|
+
t.is(output.modifiedMessages.length, 3);
|
|
153
|
+
t.is(output.modifiedMessages[0].role, 'user');
|
|
154
|
+
t.is(output.modifiedMessages[1].role, 'assistant');
|
|
155
|
+
t.is(output.modifiedMessages[2].role, 'user');
|
|
156
|
+
|
|
157
|
+
// Tool use should be preserved
|
|
158
|
+
t.is(output.modifiedMessages[1].content[0].type, 'tool_use');
|
|
159
|
+
t.is(output.modifiedMessages[1].content[0].name, 'search');
|
|
160
|
+
|
|
161
|
+
// Tool result should be preserved
|
|
162
|
+
t.is(output.modifiedMessages[2].content[0].type, 'tool_result');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('convertMessagesToClaudeVertex inherits from parent', async (t) => {
|
|
166
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
167
|
+
|
|
168
|
+
// Test with document block - should work same as Claude4VertexPlugin
|
|
169
|
+
const base64Pdf = Buffer.from('Sample PDF content').toString('base64');
|
|
170
|
+
|
|
171
|
+
const messages = [
|
|
172
|
+
{
|
|
173
|
+
role: 'user',
|
|
174
|
+
content: [
|
|
175
|
+
{ type: 'text', text: 'Analyze this' },
|
|
176
|
+
{
|
|
177
|
+
type: 'document',
|
|
178
|
+
source: {
|
|
179
|
+
type: 'base64',
|
|
180
|
+
media_type: 'application/pdf',
|
|
181
|
+
data: base64Pdf
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const output = await plugin.convertMessagesToClaudeVertex(messages);
|
|
189
|
+
|
|
190
|
+
// Should have both text and document blocks
|
|
191
|
+
t.is(output.modifiedMessages[0].content.length, 2);
|
|
192
|
+
t.is(output.modifiedMessages[0].content[0].type, 'text');
|
|
193
|
+
t.is(output.modifiedMessages[0].content[1].type, 'document');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('SSE conversion inherits from parent', (t) => {
|
|
197
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
198
|
+
|
|
199
|
+
// Test content_block_delta event conversion
|
|
200
|
+
const claudeEvent = {
|
|
201
|
+
data: JSON.stringify({
|
|
202
|
+
type: 'content_block_delta',
|
|
203
|
+
delta: { type: 'text_delta', text: 'Hello' }
|
|
204
|
+
})
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const openAIEvent = plugin.convertClaudeSSEToOpenAI(claudeEvent);
|
|
208
|
+
const parsed = JSON.parse(openAIEvent.data);
|
|
209
|
+
|
|
210
|
+
t.is(parsed.object, 'chat.completion.chunk');
|
|
211
|
+
t.is(parsed.choices[0].delta.content, 'Hello');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('SSE conversion handles tool call events', (t) => {
|
|
215
|
+
const plugin = new ClaudeAnthropicPlugin(pathway, anthropicModel);
|
|
216
|
+
|
|
217
|
+
// Test content_block_start for tool_use
|
|
218
|
+
const toolStartEvent = {
|
|
219
|
+
data: JSON.stringify({
|
|
220
|
+
type: 'content_block_start',
|
|
221
|
+
index: 0,
|
|
222
|
+
content_block: {
|
|
223
|
+
type: 'tool_use',
|
|
224
|
+
id: 'call_123',
|
|
225
|
+
name: 'get_weather'
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const openAIEvent = plugin.convertClaudeSSEToOpenAI(toolStartEvent);
|
|
231
|
+
const parsed = JSON.parse(openAIEvent.data);
|
|
232
|
+
|
|
233
|
+
t.is(parsed.object, 'chat.completion.chunk');
|
|
234
|
+
t.truthy(parsed.choices[0].delta.tool_calls);
|
|
235
|
+
t.is(parsed.choices[0].delta.tool_calls[0].function.name, 'get_weather');
|
|
236
|
+
});
|
|
@@ -432,9 +432,9 @@ test('Gemini 1.5 image URL type handling', t => {
|
|
|
432
432
|
{ type: 'image_url', image_url: { url: 'gs://my-bucket/image1.jpg' } },
|
|
433
433
|
// Base64 URL - should be converted to inlineData
|
|
434
434
|
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,/9j/4AAQSkZJRg...' } },
|
|
435
|
-
// Regular HTTP URL - should be
|
|
435
|
+
// Regular HTTP URL - should be converted to fileData (Gemini supports HTTP URLs directly)
|
|
436
436
|
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } },
|
|
437
|
-
// Azure blob URL - should be
|
|
437
|
+
// Azure blob URL - should be converted to fileData (Gemini supports HTTP URLs directly)
|
|
438
438
|
{ type: 'image_url', image_url: { url: 'https://myaccount.blob.core.windows.net/container/image.jpg' } }
|
|
439
439
|
]}
|
|
440
440
|
];
|
|
@@ -442,20 +442,30 @@ test('Gemini 1.5 image URL type handling', t => {
|
|
|
442
442
|
const { modifiedMessages } = gemini15.convertMessagesToGemini(messages);
|
|
443
443
|
|
|
444
444
|
t.is(modifiedMessages.length, 1);
|
|
445
|
-
t.is(modifiedMessages[0].parts.length,
|
|
446
|
-
|
|
445
|
+
t.is(modifiedMessages[0].parts.length, 5); // text + gcs + base64 + http + azure (all urls kept)
|
|
446
|
+
|
|
447
447
|
// Check text part
|
|
448
448
|
t.is(modifiedMessages[0].parts[0].text, 'Process these images:');
|
|
449
|
-
|
|
449
|
+
|
|
450
450
|
// Check GCS URL handling
|
|
451
451
|
t.true('fileData' in modifiedMessages[0].parts[1]);
|
|
452
452
|
t.is(modifiedMessages[0].parts[1].fileData.fileUri, 'gs://my-bucket/image1.jpg');
|
|
453
453
|
t.is(modifiedMessages[0].parts[1].fileData.mimeType, 'image/jpeg');
|
|
454
|
-
|
|
454
|
+
|
|
455
455
|
// Check base64 URL handling
|
|
456
456
|
t.true('inlineData' in modifiedMessages[0].parts[2]);
|
|
457
457
|
t.is(modifiedMessages[0].parts[2].inlineData.mimeType, 'image/jpeg');
|
|
458
458
|
t.is(modifiedMessages[0].parts[2].inlineData.data, '/9j/4AAQSkZJRg...');
|
|
459
|
+
|
|
460
|
+
// Check HTTP URL handling
|
|
461
|
+
t.true('fileData' in modifiedMessages[0].parts[3]);
|
|
462
|
+
t.is(modifiedMessages[0].parts[3].fileData.fileUri, 'https://example.com/image.jpg');
|
|
463
|
+
t.is(modifiedMessages[0].parts[3].fileData.mimeType, 'image/jpeg');
|
|
464
|
+
|
|
465
|
+
// Check Azure blob URL handling
|
|
466
|
+
t.true('fileData' in modifiedMessages[0].parts[4]);
|
|
467
|
+
t.is(modifiedMessages[0].parts[4].fileData.fileUri, 'https://myaccount.blob.core.windows.net/container/image.jpg');
|
|
468
|
+
t.is(modifiedMessages[0].parts[4].fileData.mimeType, 'image/jpeg');
|
|
459
469
|
});
|
|
460
470
|
|
|
461
471
|
// Test edge cases for image URLs in Gemini 1.5
|