@aj-archipelago/cortex 1.3.59 → 1.3.60
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/default.example.json +15 -1
- package/config.js +42 -0
- package/lib/azureAuthTokenHelper.js +78 -0
- package/lib/entityConstants.js +5 -4
- package/package.json +1 -1
- package/pathways/bing_afagent.js +13 -0
- package/pathways/gemini_15_vision.js +4 -0
- package/pathways/system/entity/tools/sys_tool_bing_search.js +1 -1
- package/pathways/system/entity/tools/sys_tool_bing_search_afagent.js +141 -0
- package/pathways/system/entity/tools/sys_tool_browser_jina.js +1 -1
- package/pathways/system/entity/tools/sys_tool_readfile.js +4 -0
- package/pathways/system/workspaces/workspace_applet_edit.js +4 -0
- package/pathways/transcribe_gemini.js +4 -0
- package/server/modelExecutor.js +4 -0
- package/server/plugins/azureFoundryAgentsPlugin.js +372 -0
- package/server/plugins/gemini15ChatPlugin.js +3 -3
- package/tests/azureAuthTokenHelper.test.js +150 -0
- package/tests/azureFoundryAgents.test.js +212 -0
|
@@ -12,7 +12,21 @@
|
|
|
12
12
|
"requestsPerSecond": 10,
|
|
13
13
|
"maxTokenLength": 2000
|
|
14
14
|
},
|
|
15
|
-
|
|
15
|
+
"azure-bing-agent": {
|
|
16
|
+
"type": "AZURE-FOUNDRY-AGENTS",
|
|
17
|
+
"url": "https://archipelago-foundry-resource.services.ai.azure.com/api/projects/archipelago-foundry",
|
|
18
|
+
"agentId": "asst_assistantid",
|
|
19
|
+
"headers": {
|
|
20
|
+
"Content-Type": "application/json"
|
|
21
|
+
},
|
|
22
|
+
"params": {
|
|
23
|
+
"api-version": "2025-05-01"
|
|
24
|
+
},
|
|
25
|
+
"requestsPerSecond": 10,
|
|
26
|
+
"maxTokenLength": 32768,
|
|
27
|
+
"maxReturnTokens": 4096,
|
|
28
|
+
"supportsStreaming": false
|
|
29
|
+
},
|
|
16
30
|
"gemini-pro-chat": {
|
|
17
31
|
"type": "GEMINI-CHAT",
|
|
18
32
|
"url": "https://us-central1-aiplatform.googleapis.com/v1/projects/project-id/locations/us-central1/publishers/google/models/gemini-pro:streamGenerateContent",
|
package/config.js
CHANGED
|
@@ -4,6 +4,7 @@ import HandleBars from './lib/handleBars.js';
|
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
6
6
|
import GcpAuthTokenHelper from './lib/gcpAuthTokenHelper.js';
|
|
7
|
+
import AzureAuthTokenHelper from './lib/azureAuthTokenHelper.js';
|
|
7
8
|
import logger from './lib/logger.js';
|
|
8
9
|
import PathwayManager from './lib/pathwayManager.js';
|
|
9
10
|
import { readdir } from 'fs/promises';
|
|
@@ -128,6 +129,12 @@ var config = convict({
|
|
|
128
129
|
env: 'GCP_SERVICE_ACCOUNT_KEY',
|
|
129
130
|
sensitive: true
|
|
130
131
|
},
|
|
132
|
+
azureServicePrincipalCredentials: {
|
|
133
|
+
format: String,
|
|
134
|
+
default: null,
|
|
135
|
+
env: 'AZURE_SERVICE_PRINCIPAL_CREDENTIALS',
|
|
136
|
+
sensitive: true
|
|
137
|
+
},
|
|
131
138
|
models: {
|
|
132
139
|
format: Object,
|
|
133
140
|
default: {
|
|
@@ -183,6 +190,36 @@ var config = convict({
|
|
|
183
190
|
},
|
|
184
191
|
"maxTokenLength": 8192,
|
|
185
192
|
},
|
|
193
|
+
"oai-gpt5": {
|
|
194
|
+
"type": "OPENAI-REASONING-VISION",
|
|
195
|
+
"url": "https://api.openai.com/v1/chat/completions",
|
|
196
|
+
"headers": {
|
|
197
|
+
"Authorization": "Bearer {{OPENAI_API_KEY}}",
|
|
198
|
+
"Content-Type": "application/json"
|
|
199
|
+
},
|
|
200
|
+
"params": {
|
|
201
|
+
"model": "gpt-5"
|
|
202
|
+
},
|
|
203
|
+
"requestsPerSecond": 50,
|
|
204
|
+
"maxTokenLength": 1000000,
|
|
205
|
+
"maxReturnTokens": 16384,
|
|
206
|
+
"supportsStreaming": true
|
|
207
|
+
},
|
|
208
|
+
"oai-gpt5-mini": {
|
|
209
|
+
"type": "OPENAI-REASONING-VISION",
|
|
210
|
+
"url": "https://api.openai.com/v1/chat/completions",
|
|
211
|
+
"headers": {
|
|
212
|
+
"Authorization": "Bearer {{OPENAI_API_KEY}}",
|
|
213
|
+
"Content-Type": "application/json"
|
|
214
|
+
},
|
|
215
|
+
"params": {
|
|
216
|
+
"model": "gpt-5-mini"
|
|
217
|
+
},
|
|
218
|
+
"requestsPerSecond": 50,
|
|
219
|
+
"maxTokenLength": 1000000,
|
|
220
|
+
"maxReturnTokens": 16384,
|
|
221
|
+
"supportsStreaming": true
|
|
222
|
+
},
|
|
186
223
|
"oai-gpt4o": {
|
|
187
224
|
"type": "OPENAI-VISION",
|
|
188
225
|
"url": "https://api.openai.com/v1/chat/completions",
|
|
@@ -614,6 +651,11 @@ if (config.get('gcpServiceAccountKey')) {
|
|
|
614
651
|
config.set('gcpAuthTokenHelper', gcpAuthTokenHelper);
|
|
615
652
|
}
|
|
616
653
|
|
|
654
|
+
if (config.get('azureServicePrincipalCredentials')) {
|
|
655
|
+
const azureAuthTokenHelper = new AzureAuthTokenHelper(config.getProperties());
|
|
656
|
+
config.set('azureAuthTokenHelper', azureAuthTokenHelper);
|
|
657
|
+
}
|
|
658
|
+
|
|
617
659
|
// Load dynamic pathways from JSON file or cloud storage
|
|
618
660
|
const createDynamicPathwayManager = async (config, basePathway) => {
|
|
619
661
|
const { dynamicPathwayConfig } = config.getProperties();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
|
|
3
|
+
class AzureAuthTokenHelper {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
// Parse Azure credentials from config
|
|
6
|
+
const azureCredentials = config.azureServicePrincipalCredentials ? JSON.parse(config.azureServicePrincipalCredentials) : null;
|
|
7
|
+
|
|
8
|
+
if (!azureCredentials) {
|
|
9
|
+
throw new Error('AZURE_SERVICE_PRINCIPAL_CREDENTIALS is missing or undefined');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Extract required fields
|
|
13
|
+
this.tenantId = azureCredentials.tenant_id || azureCredentials.tenantId;
|
|
14
|
+
this.clientId = azureCredentials.client_id || azureCredentials.clientId;
|
|
15
|
+
this.clientSecret = azureCredentials.client_secret || azureCredentials.clientSecret;
|
|
16
|
+
this.scope = azureCredentials.scope || 'https://ai.azure.com/.default';
|
|
17
|
+
|
|
18
|
+
if (!this.tenantId || !this.clientId || !this.clientSecret) {
|
|
19
|
+
throw new Error('Azure credentials must include tenant_id, client_id, and client_secret');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.token = null;
|
|
23
|
+
this.expiry = null;
|
|
24
|
+
this.tokenUrl = `https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getAccessToken() {
|
|
28
|
+
if (!this.token || !this.isTokenValid()) {
|
|
29
|
+
await this.refreshToken();
|
|
30
|
+
}
|
|
31
|
+
return this.token;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isTokenValid() {
|
|
35
|
+
// Check if token is still valid with a 5-minute buffer
|
|
36
|
+
return !!(this.expiry && Date.now() < this.expiry.getTime() - 5 * 60 * 1000);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async refreshToken() {
|
|
40
|
+
try {
|
|
41
|
+
const formData = new URLSearchParams();
|
|
42
|
+
formData.append('client_id', this.clientId);
|
|
43
|
+
formData.append('client_secret', this.clientSecret);
|
|
44
|
+
formData.append('scope', this.scope);
|
|
45
|
+
formData.append('grant_type', 'client_credentials');
|
|
46
|
+
|
|
47
|
+
const response = await fetch(this.tokenUrl, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
51
|
+
},
|
|
52
|
+
body: formData,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const errorText = await response.text();
|
|
57
|
+
throw new Error(`Azure token request failed: ${response.status} ${response.statusText} - ${errorText}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tokenData = await response.json();
|
|
61
|
+
|
|
62
|
+
if (!tokenData.access_token) {
|
|
63
|
+
throw new Error('Azure token response missing access_token');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.token = tokenData.access_token;
|
|
67
|
+
|
|
68
|
+
// Calculate expiry time (expires_in is in seconds)
|
|
69
|
+
const expiresInMs = (tokenData.expires_in || 3600) * 1000;
|
|
70
|
+
this.expiry = new Date(Date.now() + expiresInMs);
|
|
71
|
+
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`Failed to refresh Azure token: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default AzureAuthTokenHelper;
|
package/lib/entityConstants.js
CHANGED
|
@@ -28,11 +28,12 @@ Your responses should be in {{language}} unless the user has expressed another p
|
|
|
28
28
|
|
|
29
29
|
AI_TOOLS: `# Tool Instructions
|
|
30
30
|
|
|
31
|
-
You have an extensive toolkit. Each time you call tool(s) you will get the result(s), evaluate, decide what's next, and chain as many steps as needed. Always honor user requests to use specific tools.
|
|
31
|
+
You have an extensive toolkit. Each time you call tool(s) you will get the result(s), evaluate, decide what's next, and chain as many steps as needed. Always honor user requests to use specific tools. You must always search if you are being asked questions about current events, news, fact-checking, or information requiring citation. Your search tools work best when called in parallel to save time so if you know you will need multiple searches, call the search tool(s) in parallel.
|
|
32
32
|
|
|
33
33
|
1. Search deeply & verify rigorously:
|
|
34
|
-
- Start broad and consult multiple sources, running searches in parallel
|
|
34
|
+
- Start broad and consult multiple sources, running all searches in parallel to save time.
|
|
35
35
|
- Consult all available sources and cross-reference with specific searches before responding.
|
|
36
|
+
- If a tool fails or has a technical difficulty, try the backup tool automatically before giving up or reporting the error.
|
|
36
37
|
|
|
37
38
|
2. Plan & sequence before acting:
|
|
38
39
|
- Review the toolset first.
|
|
@@ -61,7 +62,7 @@ When searching for news, you must complete the following steps:
|
|
|
61
62
|
|
|
62
63
|
1. Triangulate
|
|
63
64
|
- Run multiple, parallel queries across all applicable sources.
|
|
64
|
-
- Request
|
|
65
|
+
- Request at least double the number of results you want to share, then select the best results.
|
|
65
66
|
- Confirm that multiple sources tell the same story.
|
|
66
67
|
|
|
67
68
|
2. Check Freshness
|
|
@@ -102,7 +103,7 @@ token1 | token2 (OR operator - either token may appear (also the default
|
|
|
102
103
|
|
|
103
104
|
# AI Search Syntax
|
|
104
105
|
|
|
105
|
-
When creating a query string for your non-Bing search tools, you can use the following AI Search syntax. Important: these tools do not support AND, OR, or NOT strings as operators - you MUST use the syntax below. E.g. you cannot use "term1 AND term2", you must use "term1 + term2".
|
|
106
|
+
When creating a query string for your non-Bing, index-based search tools, you can use the following AI Search syntax. Important: these tools do not support AND, OR, or NOT strings as operators - you MUST use the syntax below. E.g. you cannot use "term1 AND term2", you must use "term1 + term2".
|
|
106
107
|
|
|
107
108
|
token1 + token2 (AND operator - both tokens must appear)
|
|
108
109
|
token1 | token2 (OR operator - either token may appear (also the default if no operator is specified))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aj-archipelago/cortex",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.60",
|
|
4
4
|
"description": "Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -17,4 +17,8 @@ export default {
|
|
|
17
17
|
useInputChunking: false,
|
|
18
18
|
enableDuplicateRequests: false,
|
|
19
19
|
timeout: 600,
|
|
20
|
+
geminiSafetySettings: [{category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
21
|
+
{category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
22
|
+
{category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
23
|
+
{category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH'}],
|
|
20
24
|
}
|
|
@@ -61,7 +61,7 @@ export default {
|
|
|
61
61
|
const errorMessages = Array.isArray(resolver.errors)
|
|
62
62
|
? resolver.errors.map(err => err.message || err)
|
|
63
63
|
: [resolver.errors.message || resolver.errors];
|
|
64
|
-
return JSON.stringify({ _type: "SearchError", value: errorMessages });
|
|
64
|
+
return JSON.stringify({ _type: "SearchError", value: errorMessages, recoveryMessage: "This tool failed. You should try the backup tool for this function." });
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const parsedResponse = JSON.parse(response);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// sys_tool_bing_search_afagent.js
|
|
2
|
+
// Tool pathway that handles Bing web search functionality with minimal parsing
|
|
3
|
+
import { callPathway } from '../../../../lib/pathwayTools.js';
|
|
4
|
+
import logger from '../../../../lib/logger.js';
|
|
5
|
+
import { config } from '../../../../config.js';
|
|
6
|
+
import { getSearchResultId } from '../../../../lib/util.js';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
prompt: [],
|
|
10
|
+
timeout: 300,
|
|
11
|
+
toolDefinition: {
|
|
12
|
+
type: "function",
|
|
13
|
+
icon: "🕸️",
|
|
14
|
+
function: {
|
|
15
|
+
name: "SearchInternetBackup",
|
|
16
|
+
description: "This tool allows you to search sources on the internet by calling another agent that has Bing search access. Use this for current events, news, fact-checking, and information requiring citation. This is a backup tool for when the other internet search tools fail - it is slower so try to use the other tools first and always call this tool in parallel if you have several searches to do.",
|
|
17
|
+
parameters: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
text: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "The complete natural language prompt describing what you want to search for. This is going to an AI agent that has Bing search access - you can be as detailed or general as you want."
|
|
23
|
+
},
|
|
24
|
+
userMessage: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "A user-friendly message that describes what you're doing with this tool"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
required: ["text", "userMessage"]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
executePathway: async ({args, runAllPrompts, resolver}) => {
|
|
35
|
+
|
|
36
|
+
// Check if Bing API key is available
|
|
37
|
+
const servicePricipalAvailable = !!config.getEnv()["AZURE_SERVICE_PRINCIPAL_CREDENTIALS"];
|
|
38
|
+
if (!servicePricipalAvailable) {
|
|
39
|
+
throw new Error("Service Principal for Bing Search Agent is not available!");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Call the Bing search pathway
|
|
44
|
+
//remove model from args as bing_afagent has model in its own
|
|
45
|
+
const { model, ...restArgs } = args;
|
|
46
|
+
const rawResponse = await callPathway('bing_afagent', {
|
|
47
|
+
...restArgs,
|
|
48
|
+
}, resolver);
|
|
49
|
+
|
|
50
|
+
// Add error handling for malformed JSON
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = JSON.parse(rawResponse);
|
|
54
|
+
} catch (parseError) {
|
|
55
|
+
logger.error(`Failed to parse bing_afagent response as JSON: ${parseError.message}`);
|
|
56
|
+
logger.error(`Raw response: ${rawResponse}`);
|
|
57
|
+
throw new Error(`Invalid JSON response from bing_afagent: ${parseError.message}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (resolver.errors && resolver.errors.length > 0) {
|
|
61
|
+
const errorMessages = Array.isArray(resolver.errors)
|
|
62
|
+
? resolver.errors.map(err => err.message || err)
|
|
63
|
+
: [resolver.errors.message || resolver.errors];
|
|
64
|
+
return JSON.stringify({ _type: "SearchError", value: errorMessages });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Transform response to match expected SearchResponse format
|
|
68
|
+
function transformToSearchResponse(response) {
|
|
69
|
+
let valueText = response.value || '';
|
|
70
|
+
const annotations = response.annotations || [];
|
|
71
|
+
|
|
72
|
+
// Create a mapping from citation text to search result IDs
|
|
73
|
+
const citationToIdMap = new Map();
|
|
74
|
+
const citationPattern = /【\d+:\d+†source】/g;
|
|
75
|
+
|
|
76
|
+
// Replace citation markers with search result IDs
|
|
77
|
+
valueText = valueText.replace(citationPattern, (match) => {
|
|
78
|
+
if (!citationToIdMap.has(match)) {
|
|
79
|
+
citationToIdMap.set(match, getSearchResultId());
|
|
80
|
+
}
|
|
81
|
+
return `:cd_source[${citationToIdMap.get(match)}]`;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Transform annotations to search result objects
|
|
85
|
+
const searchResults = annotations.map(annotation => {
|
|
86
|
+
if (annotation.type === "url_citation" && annotation.url_citation) {
|
|
87
|
+
const citationText = annotation.text;
|
|
88
|
+
const searchResultId = citationToIdMap.get(citationText) || getSearchResultId();
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
searchResultId: searchResultId,
|
|
92
|
+
title: annotation.url_citation.title || '',
|
|
93
|
+
url: annotation.url_citation.url || '',
|
|
94
|
+
content: annotation.url_citation.title || annotation.url_citation.url || '', // Individual result content
|
|
95
|
+
path: '',
|
|
96
|
+
wireid: '',
|
|
97
|
+
source: '',
|
|
98
|
+
slugline: '',
|
|
99
|
+
date: ''
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}).filter(result => result !== null);
|
|
104
|
+
|
|
105
|
+
// If no annotations, create a single search result with the content
|
|
106
|
+
if (searchResults.length === 0) {
|
|
107
|
+
searchResults.push({
|
|
108
|
+
searchResultId: getSearchResultId(),
|
|
109
|
+
title: '',
|
|
110
|
+
url: '',
|
|
111
|
+
content: valueText, // Use the full transformed text as content
|
|
112
|
+
path: '',
|
|
113
|
+
wireid: '',
|
|
114
|
+
source: '',
|
|
115
|
+
slugline: '',
|
|
116
|
+
date: ''
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
transformedText: valueText, // The full text with citations replaced
|
|
122
|
+
searchResults: searchResults // Individual search results for citation extraction
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const transformedData = transformToSearchResponse(response);
|
|
127
|
+
|
|
128
|
+
resolver.tool = JSON.stringify({ toolUsed: "SearchInternetAgent2" });
|
|
129
|
+
|
|
130
|
+
// Return the full transformed text as the main result, and include search results for citation extraction
|
|
131
|
+
return JSON.stringify({
|
|
132
|
+
_type: "SearchResponse",
|
|
133
|
+
value: transformedData.searchResults,
|
|
134
|
+
text: transformedData.transformedText // The full transformed text with citations
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
logger.error(`Error in Bing search: ${e}`);
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
@@ -12,7 +12,7 @@ export default {
|
|
|
12
12
|
icon: "🌎",
|
|
13
13
|
function: {
|
|
14
14
|
name: "FetchWebPageContentJina",
|
|
15
|
-
description: "This tool allows you to fetch and extract the text content from any webpage using the Jina API. This is a great
|
|
15
|
+
description: "This tool allows you to fetch and extract the text content from any webpage using the Jina API. This is a great backup tool for web page content if you don't get a good enough response from your other browser tool or are blocked by a website.",
|
|
16
16
|
parameters: {
|
|
17
17
|
type: "object",
|
|
18
18
|
properties: {
|
|
@@ -22,6 +22,10 @@ export default {
|
|
|
22
22
|
useInputChunking: false,
|
|
23
23
|
enableDuplicateRequests: false,
|
|
24
24
|
timeout: 600,
|
|
25
|
+
geminiSafetySettings: [{category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
26
|
+
{category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
27
|
+
{category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
28
|
+
{category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH'}],
|
|
25
29
|
toolDefinition: [{
|
|
26
30
|
type: "function",
|
|
27
31
|
icon: "📄",
|
|
@@ -182,6 +182,10 @@ export default {
|
|
|
182
182
|
},
|
|
183
183
|
// model: 'oai-gpt41',
|
|
184
184
|
model: 'gemini-pro-25-vision',
|
|
185
|
+
geminiSafetySettings: [{category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
186
|
+
{category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
187
|
+
{category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
188
|
+
{category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH'}],
|
|
185
189
|
timeout: 600,
|
|
186
190
|
stream: true,
|
|
187
191
|
}
|
|
@@ -62,6 +62,10 @@ export default {
|
|
|
62
62
|
},
|
|
63
63
|
timeout: 3600, // in seconds
|
|
64
64
|
enableDuplicateRequests: false,
|
|
65
|
+
geminiSafetySettings: [{category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
66
|
+
{category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
67
|
+
{category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH'},
|
|
68
|
+
{category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH'}],
|
|
65
69
|
|
|
66
70
|
executePathway: async ({args, runAllPrompts, resolver}) => {
|
|
67
71
|
let intervalId;
|
package/server/modelExecutor.js
CHANGED
|
@@ -33,6 +33,7 @@ import ApptekTranslatePlugin from './plugins/apptekTranslatePlugin.js';
|
|
|
33
33
|
import GoogleTranslatePlugin from './plugins/googleTranslatePlugin.js';
|
|
34
34
|
import GroqChatPlugin from './plugins/groqChatPlugin.js';
|
|
35
35
|
import VeoVideoPlugin from './plugins/veoVideoPlugin.js';
|
|
36
|
+
import AzureFoundryAgentsPlugin from './plugins/azureFoundryAgentsPlugin.js';
|
|
36
37
|
|
|
37
38
|
class ModelExecutor {
|
|
38
39
|
constructor(pathway, model) {
|
|
@@ -133,6 +134,9 @@ class ModelExecutor {
|
|
|
133
134
|
case 'VEO-VIDEO':
|
|
134
135
|
plugin = new VeoVideoPlugin(pathway, model);
|
|
135
136
|
break;
|
|
137
|
+
case 'AZURE-FOUNDRY-AGENTS':
|
|
138
|
+
plugin = new AzureFoundryAgentsPlugin(pathway, model);
|
|
139
|
+
break;
|
|
136
140
|
default:
|
|
137
141
|
throw new Error(`Unsupported model type: ${model.type}`);
|
|
138
142
|
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// AzureFoundryAgentsPlugin.js
|
|
2
|
+
import ModelPlugin from './modelPlugin.js';
|
|
3
|
+
import logger from '../../lib/logger.js';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
|
|
6
|
+
class AzureFoundryAgentsPlugin extends ModelPlugin {
|
|
7
|
+
constructor(pathway, model) {
|
|
8
|
+
super(pathway, model);
|
|
9
|
+
this.agentId = model.agentId;
|
|
10
|
+
this.projectUrl = model.url;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Convert to Azure Foundry Agents messages array format
|
|
14
|
+
convertToAzureFoundryMessages(context, examples, messages) {
|
|
15
|
+
let azureMessages = [];
|
|
16
|
+
|
|
17
|
+
// Add context as a system message if provided
|
|
18
|
+
if (context) {
|
|
19
|
+
azureMessages.push({
|
|
20
|
+
role: 'system',
|
|
21
|
+
content: context,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Add examples to the messages array
|
|
26
|
+
if (examples && examples.length > 0) {
|
|
27
|
+
examples.forEach(example => {
|
|
28
|
+
azureMessages.push({
|
|
29
|
+
role: example.input.author || 'user',
|
|
30
|
+
content: example.input.content,
|
|
31
|
+
});
|
|
32
|
+
azureMessages.push({
|
|
33
|
+
role: example.output.author || 'assistant',
|
|
34
|
+
content: example.output.content,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Add remaining messages to the messages array
|
|
40
|
+
messages.forEach(message => {
|
|
41
|
+
azureMessages.push({
|
|
42
|
+
role: message.author,
|
|
43
|
+
content: message.content,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return azureMessages;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Set up parameters specific to the Azure Foundry Agents API
|
|
51
|
+
getRequestParameters(text, parameters, prompt) {
|
|
52
|
+
const { modelPromptText, modelPromptMessages, tokenLength, modelPrompt } = this.getCompiledPrompt(text, parameters, prompt);
|
|
53
|
+
const { stream } = parameters;
|
|
54
|
+
|
|
55
|
+
// Define the model's max token length
|
|
56
|
+
const modelTargetTokenLength = this.getModelMaxPromptTokens();
|
|
57
|
+
|
|
58
|
+
let requestMessages = modelPromptMessages || [{ "role": "user", "content": modelPromptText }];
|
|
59
|
+
|
|
60
|
+
// Check if the messages are in Palm format and convert them to Azure format if necessary
|
|
61
|
+
const isPalmFormat = requestMessages.some(message => 'author' in message);
|
|
62
|
+
if (isPalmFormat) {
|
|
63
|
+
const context = modelPrompt.context || '';
|
|
64
|
+
const examples = modelPrompt.examples || [];
|
|
65
|
+
requestMessages = this.convertToAzureFoundryMessages(context, examples, modelPromptMessages);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if the token length exceeds the model's max token length
|
|
69
|
+
if (tokenLength > modelTargetTokenLength && this.promptParameters?.manageTokenLength) {
|
|
70
|
+
// Remove older messages until the token length is within the model's limit
|
|
71
|
+
requestMessages = this.truncateMessagesToTargetLength(requestMessages, modelTargetTokenLength);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const requestParameters = {
|
|
75
|
+
assistant_id: this.agentId,
|
|
76
|
+
thread: {
|
|
77
|
+
messages: requestMessages
|
|
78
|
+
},
|
|
79
|
+
stream: stream || false,
|
|
80
|
+
// Add any additional parameters that might be needed
|
|
81
|
+
...(parameters.tools && { tools: parameters.tools }),
|
|
82
|
+
...(parameters.tool_resources && { tool_resources: parameters.tool_resources }),
|
|
83
|
+
...(parameters.metadata && { metadata: parameters.metadata }),
|
|
84
|
+
...(parameters.instructions && { instructions: parameters.instructions }),
|
|
85
|
+
...(parameters.model && { model: parameters.model }),
|
|
86
|
+
...(parameters.temperature && { temperature: parameters.temperature }),
|
|
87
|
+
...(parameters.max_tokens && { max_tokens: parameters.max_tokens }),
|
|
88
|
+
...(parameters.top_p && { top_p: parameters.top_p }),
|
|
89
|
+
...(parameters.tool_choice && { tool_choice: parameters.tool_choice }),
|
|
90
|
+
...(parameters.response_format && { response_format: parameters.response_format }),
|
|
91
|
+
...(parameters.parallel_tool_calls && { parallel_tool_calls: parameters.parallel_tool_calls }),
|
|
92
|
+
...(parameters.truncation_strategy && { truncation_strategy: parameters.truncation_strategy })
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return requestParameters;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Assemble and execute the request to the Azure Foundry Agents API
|
|
99
|
+
async execute(text, parameters, prompt, cortexRequest) {
|
|
100
|
+
const requestParameters = this.getRequestParameters(text, parameters, prompt);
|
|
101
|
+
|
|
102
|
+
// Set up the request for Azure Foundry Agents
|
|
103
|
+
cortexRequest.url = this.requestUrl();
|
|
104
|
+
cortexRequest.data = requestParameters;
|
|
105
|
+
cortexRequest.params = { 'api-version': '2025-05-01' }; // Azure API version
|
|
106
|
+
|
|
107
|
+
// Get authentication token and add to headers
|
|
108
|
+
const azureAuthTokenHelper = this.config.get('azureAuthTokenHelper');
|
|
109
|
+
let authToken = null;
|
|
110
|
+
if (azureAuthTokenHelper) {
|
|
111
|
+
try {
|
|
112
|
+
authToken = await azureAuthTokenHelper.getAccessToken();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger.warn(`[Azure Foundry Agent] Failed to get auth token: ${error.message}`);
|
|
115
|
+
// Continue without auth token
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
cortexRequest.headers = {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
...cortexRequest.headers,
|
|
122
|
+
...(authToken && { 'Authorization': `Bearer ${authToken}` })
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Execute the initial request to create the run
|
|
126
|
+
const runResponse = await this.executeRequest(cortexRequest);
|
|
127
|
+
|
|
128
|
+
// If we got a run response, poll for completion and get messages
|
|
129
|
+
if (runResponse && runResponse.id && runResponse.thread_id) {
|
|
130
|
+
return await this.pollForCompletion(runResponse.thread_id, runResponse.id, cortexRequest);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return runResponse;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Poll for run completion and retrieve messages
|
|
137
|
+
async pollForCompletion(threadId, runId, cortexRequest) {
|
|
138
|
+
const maxPollingAttempts = 60; // 60 seconds max
|
|
139
|
+
const pollingInterval = 1000; // 1 second
|
|
140
|
+
let attempts = 0;
|
|
141
|
+
|
|
142
|
+
while (attempts < maxPollingAttempts) {
|
|
143
|
+
attempts++;
|
|
144
|
+
|
|
145
|
+
// Wait before polling
|
|
146
|
+
await new Promise(resolve => setTimeout(resolve, pollingInterval));
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// Add authentication token if available
|
|
150
|
+
const azureAuthTokenHelper = this.config.get('azureAuthTokenHelper');
|
|
151
|
+
let authToken = null;
|
|
152
|
+
if (azureAuthTokenHelper) {
|
|
153
|
+
try {
|
|
154
|
+
authToken = await azureAuthTokenHelper.getAccessToken();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
logger.warn(`[Azure Foundry Agent] Failed to get auth token for polling: ${error.message}`);
|
|
157
|
+
// Continue without auth token
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pollUrl = this.constructAzureUrl(`/threads/${threadId}/runs/${runId}`);
|
|
162
|
+
const pollResponse = await axios.get(pollUrl, {
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
...this.model.headers,
|
|
166
|
+
...(authToken && { 'Authorization': `Bearer ${authToken}` })
|
|
167
|
+
},
|
|
168
|
+
params: { 'api-version': '2025-05-01' }
|
|
169
|
+
});
|
|
170
|
+
const runStatus = pollResponse?.data;
|
|
171
|
+
|
|
172
|
+
if (!runStatus) {
|
|
173
|
+
logger.warn(`[Azure Foundry Agent] No run status received for run: ${runId}`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check if run is completed
|
|
178
|
+
if (runStatus.status === 'completed') {
|
|
179
|
+
logger.info(`[Azure Foundry Agent] Run completed successfully: ${runId}`);
|
|
180
|
+
return await this.retrieveMessages(threadId, cortexRequest);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check if run failed
|
|
184
|
+
if (runStatus.status === 'failed') {
|
|
185
|
+
logger.error(`[Azure Foundry Agent] Run failed: ${runId}`, runStatus.lastError);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check if run was cancelled
|
|
190
|
+
if (runStatus.status === 'cancelled') {
|
|
191
|
+
logger.warn(`[Azure Foundry Agent] Run was cancelled: ${runId}`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Continue polling for queued or in_progress status
|
|
196
|
+
if (runStatus.status === 'queued' || runStatus.status === 'in_progress') {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Unknown status
|
|
201
|
+
logger.warn(`[Azure Foundry Agent] Unknown run status: ${runStatus.status}`);
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
} catch (error) {
|
|
205
|
+
logger.error(`[Azure Foundry Agent] Error polling run status: ${error.message}`);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
logger.error(`[Azure Foundry Agent] Polling timeout after ${maxPollingAttempts} attempts for run: ${runId}`);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Retrieve messages from the completed thread
|
|
215
|
+
async retrieveMessages(threadId, cortexRequest) {
|
|
216
|
+
try {
|
|
217
|
+
// Add authentication token if available
|
|
218
|
+
const azureAuthTokenHelper = this.config.get('azureAuthTokenHelper');
|
|
219
|
+
let authToken = null;
|
|
220
|
+
if (azureAuthTokenHelper) {
|
|
221
|
+
try {
|
|
222
|
+
authToken = await azureAuthTokenHelper.getAccessToken();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
logger.warn(`[Azure Foundry Agent] Failed to get auth token for messages: ${error.message}`);
|
|
225
|
+
// Continue without auth token
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const messagesUrl = this.constructAzureUrl(`/threads/${threadId}/messages`);
|
|
230
|
+
const axiosResponse = await axios.get(messagesUrl, {
|
|
231
|
+
headers: {
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
...this.model.headers,
|
|
234
|
+
...(authToken && { 'Authorization': `Bearer ${authToken}` })
|
|
235
|
+
},
|
|
236
|
+
params: { 'api-version': '2025-05-01', order: 'asc' }
|
|
237
|
+
});
|
|
238
|
+
const messagesResponse = axiosResponse?.data;
|
|
239
|
+
|
|
240
|
+
if (!messagesResponse || !messagesResponse.data) {
|
|
241
|
+
logger.warn(`[Azure Foundry Agent] No messages received from thread: ${threadId}`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Find the last assistant message
|
|
246
|
+
const messages = messagesResponse.data;
|
|
247
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
248
|
+
const message = messages[i];
|
|
249
|
+
if (message.role === 'assistant' && message.content && Array.isArray(message.content)) {
|
|
250
|
+
const textContent = message.content.find(c => c.type === 'text' && c.text && c.text.value);
|
|
251
|
+
if (textContent) {
|
|
252
|
+
return JSON.stringify(textContent.text);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
logger.warn(`[Azure Foundry Agent] No assistant messages found in thread: ${threadId}`);
|
|
258
|
+
return null;
|
|
259
|
+
|
|
260
|
+
} catch (error) {
|
|
261
|
+
logger.error(`[Azure Foundry Agent] Error retrieving messages: ${error.message}`);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Parse the response from the Azure Foundry Agents API
|
|
267
|
+
parseResponse(data) {
|
|
268
|
+
if (!data) return "";
|
|
269
|
+
|
|
270
|
+
// If data is already a string (the final message content), return it
|
|
271
|
+
if (typeof data === 'string') {
|
|
272
|
+
return data;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Handle the run response format (for backward compatibility)
|
|
276
|
+
if (data.id && data.status) {
|
|
277
|
+
// This is a run response, we need to handle the status
|
|
278
|
+
if (data.status === 'completed') {
|
|
279
|
+
// The run completed successfully, but we need to get the messages
|
|
280
|
+
// This would typically be handled by polling for messages
|
|
281
|
+
return data;
|
|
282
|
+
} else if (data.status === 'failed') {
|
|
283
|
+
logger.error(`Azure Foundry Agent run failed: ${data.lastError?.message || data.last_error?.message || 'Unknown error'}`);
|
|
284
|
+
return null;
|
|
285
|
+
} else {
|
|
286
|
+
// Still in progress
|
|
287
|
+
return data;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Handle direct message response
|
|
292
|
+
if (data.messages && Array.isArray(data.messages)) {
|
|
293
|
+
const lastMessage = data.messages[data.messages.length - 1];
|
|
294
|
+
if (lastMessage && lastMessage.content && Array.isArray(lastMessage.content)) {
|
|
295
|
+
const textContent = lastMessage.content.find(c => c.type === 'text');
|
|
296
|
+
if (textContent && textContent.text) {
|
|
297
|
+
// Support both object { value: string } and string shapes
|
|
298
|
+
if (typeof textContent.text === 'string') {
|
|
299
|
+
return textContent.text;
|
|
300
|
+
}
|
|
301
|
+
if (typeof textContent.text.value === 'string') {
|
|
302
|
+
return textContent.text.value;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Fallback to returning the entire response
|
|
309
|
+
return data;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Override the logging function to display the messages and responses
|
|
313
|
+
logRequestData(data, responseData, prompt) {
|
|
314
|
+
const { stream, thread } = data;
|
|
315
|
+
|
|
316
|
+
if (thread && thread.messages && thread.messages.length > 1) {
|
|
317
|
+
logger.info(`[Azure Foundry Agent request sent containing ${thread.messages.length} messages]`);
|
|
318
|
+
let totalLength = 0;
|
|
319
|
+
let totalUnits;
|
|
320
|
+
|
|
321
|
+
thread.messages.forEach((message, index) => {
|
|
322
|
+
const content = message.content === undefined ? JSON.stringify(message) :
|
|
323
|
+
(Array.isArray(message.content) ? message.content.map(item => {
|
|
324
|
+
return JSON.stringify(item);
|
|
325
|
+
}).join(', ') : message.content);
|
|
326
|
+
const { length, units } = this.getLength(content);
|
|
327
|
+
const displayContent = this.shortenContent(content);
|
|
328
|
+
|
|
329
|
+
logger.verbose(`message ${index + 1}: role: ${message.role}, ${units}: ${length}, content: "${displayContent}"`);
|
|
330
|
+
totalLength += length;
|
|
331
|
+
totalUnits = units;
|
|
332
|
+
});
|
|
333
|
+
logger.info(`[Azure Foundry Agent request contained ${totalLength} ${totalUnits}]`);
|
|
334
|
+
} else if (thread && thread.messages && thread.messages.length === 1) {
|
|
335
|
+
const message = thread.messages[0];
|
|
336
|
+
const content = Array.isArray(message.content) ? message.content.map(item => {
|
|
337
|
+
return JSON.stringify(item);
|
|
338
|
+
}).join(', ') : message.content;
|
|
339
|
+
const { length, units } = this.getLength(content);
|
|
340
|
+
logger.info(`[Azure Foundry Agent request sent containing ${length} ${units}]`);
|
|
341
|
+
logger.verbose(`${this.shortenContent(content)}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (stream) {
|
|
345
|
+
logger.info(`[Azure Foundry Agent response received as an SSE stream]`);
|
|
346
|
+
} else {
|
|
347
|
+
const responseText = this.parseResponse(responseData);
|
|
348
|
+
if (responseText && typeof responseText === 'string') {
|
|
349
|
+
const { length, units } = this.getLength(responseText);
|
|
350
|
+
logger.info(`[Azure Foundry Agent response received containing ${length} ${units}]`);
|
|
351
|
+
logger.verbose(`${this.shortenContent(responseText)}`);
|
|
352
|
+
} else {
|
|
353
|
+
logger.info(`[Azure Foundry Agent response received: ${JSON.stringify(responseData)}]`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
prompt && prompt.debugInfo && (prompt.debugInfo += `\n${JSON.stringify(data)}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Override the request URL to use the Azure Foundry Agents endpoint
|
|
361
|
+
requestUrl() {
|
|
362
|
+
// The URL should be constructed as: {projectUrl}/threads/runs
|
|
363
|
+
return `${this.projectUrl}/threads/runs`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Helper method to construct Azure Foundry Agents URLs
|
|
367
|
+
constructAzureUrl(path) {
|
|
368
|
+
return `${this.projectUrl}${path}`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export default AzureFoundryAgentsPlugin;
|
|
@@ -124,9 +124,9 @@ class Gemini15ChatPlugin extends ModelPlugin {
|
|
|
124
124
|
topP: parameters.topP || 0.95,
|
|
125
125
|
topK: parameters.topK || 40,
|
|
126
126
|
},
|
|
127
|
-
safety_settings: geminiSafetySettings
|
|
128
|
-
systemInstruction: system,
|
|
129
|
-
tools: geminiTools
|
|
127
|
+
...(geminiSafetySettings ? {safety_settings: geminiSafetySettings} : {}),
|
|
128
|
+
...(system ? {systemInstruction: system} : {}),
|
|
129
|
+
...(geminiTools ? {tools: geminiTools} : {})
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
return requestParameters;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import AzureAuthTokenHelper from '../lib/azureAuthTokenHelper.js';
|
|
3
|
+
|
|
4
|
+
test('should initialize with valid credentials', (t) => {
|
|
5
|
+
const mockConfig = {
|
|
6
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
7
|
+
tenant_id: '648085d9-6878-44a9-a76b-0223882f8268',
|
|
8
|
+
client_id: '8796f9a6-cd80-45dd-91b8-9ddf384ee42c',
|
|
9
|
+
client_secret: 'test-secret',
|
|
10
|
+
scope: 'https://ai.azure.com/.default'
|
|
11
|
+
})
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
15
|
+
|
|
16
|
+
t.is(azureHelper.tenantId, '648085d9-6878-44a9-a76b-0223882f8268');
|
|
17
|
+
t.is(azureHelper.clientId, '8796f9a6-cd80-45dd-91b8-9ddf384ee42c');
|
|
18
|
+
t.is(azureHelper.clientSecret, 'test-secret');
|
|
19
|
+
t.is(azureHelper.scope, 'https://ai.azure.com/.default');
|
|
20
|
+
t.is(azureHelper.tokenUrl, 'https://login.microsoftonline.com/648085d9-6878-44a9-a76b-0223882f8268/oauth2/v2.0/token');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('should throw error when azureCredentials is missing', (t) => {
|
|
24
|
+
t.throws(() => {
|
|
25
|
+
new AzureAuthTokenHelper({});
|
|
26
|
+
}, { message: 'AZURE_SERVICE_PRINCIPAL_CREDENTIALS is missing or undefined' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('should throw error when required fields are missing', (t) => {
|
|
30
|
+
const invalidConfig = {
|
|
31
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
32
|
+
tenant_id: 'test-tenant'
|
|
33
|
+
// missing client_id and client_secret
|
|
34
|
+
})
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
t.throws(() => {
|
|
38
|
+
new AzureAuthTokenHelper(invalidConfig);
|
|
39
|
+
}, { message: 'Azure credentials must include tenant_id, client_id, and client_secret' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('should support both snake_case and camelCase field names', (t) => {
|
|
43
|
+
const camelCaseConfig = {
|
|
44
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
45
|
+
tenantId: '648085d9-6878-44a9-a76b-0223882f8268',
|
|
46
|
+
clientId: '8796f9a6-cd80-45dd-91b8-9ddf384ee42c',
|
|
47
|
+
clientSecret: 'test-secret',
|
|
48
|
+
scope: 'https://ai.azure.com/.default'
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const azureHelper = new AzureAuthTokenHelper(camelCaseConfig);
|
|
53
|
+
|
|
54
|
+
t.is(azureHelper.tenantId, '648085d9-6878-44a9-a76b-0223882f8268');
|
|
55
|
+
t.is(azureHelper.clientId, '8796f9a6-cd80-45dd-91b8-9ddf384ee42c');
|
|
56
|
+
t.is(azureHelper.clientSecret, 'test-secret');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('isTokenValid should return false when no token exists', (t) => {
|
|
60
|
+
const mockConfig = {
|
|
61
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
62
|
+
tenant_id: 'test-tenant',
|
|
63
|
+
client_id: 'test-client',
|
|
64
|
+
client_secret: 'test-secret'
|
|
65
|
+
})
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
69
|
+
t.false(azureHelper.isTokenValid());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('isTokenValid should return false when token is expired', (t) => {
|
|
73
|
+
const mockConfig = {
|
|
74
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
75
|
+
tenant_id: 'test-tenant',
|
|
76
|
+
client_id: 'test-client',
|
|
77
|
+
client_secret: 'test-secret'
|
|
78
|
+
})
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
82
|
+
azureHelper.token = 'test-token';
|
|
83
|
+
azureHelper.expiry = new Date(Date.now() - 1000); // expired 1 second ago
|
|
84
|
+
t.false(azureHelper.isTokenValid());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('isTokenValid should return true when token is valid with buffer', (t) => {
|
|
88
|
+
const mockConfig = {
|
|
89
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
90
|
+
tenant_id: 'test-tenant',
|
|
91
|
+
client_id: 'test-client',
|
|
92
|
+
client_secret: 'test-secret'
|
|
93
|
+
})
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
97
|
+
azureHelper.token = 'test-token';
|
|
98
|
+
azureHelper.expiry = new Date(Date.now() + 10 * 60 * 1000); // expires in 10 minutes
|
|
99
|
+
t.true(azureHelper.isTokenValid());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('isTokenValid should return false when token expires within buffer time', (t) => {
|
|
103
|
+
const mockConfig = {
|
|
104
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
105
|
+
tenant_id: 'test-tenant',
|
|
106
|
+
client_id: 'test-client',
|
|
107
|
+
client_secret: 'test-secret'
|
|
108
|
+
})
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
112
|
+
azureHelper.token = 'test-token';
|
|
113
|
+
azureHelper.expiry = new Date(Date.now() + 3 * 60 * 1000); // expires in 3 minutes (within 5-minute buffer)
|
|
114
|
+
t.false(azureHelper.isTokenValid());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('getAccessToken should return existing token if valid', async (t) => {
|
|
118
|
+
const mockConfig = {
|
|
119
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
120
|
+
tenant_id: 'test-tenant',
|
|
121
|
+
client_id: 'test-client',
|
|
122
|
+
client_secret: 'test-secret'
|
|
123
|
+
})
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
127
|
+
azureHelper.token = 'existing-token';
|
|
128
|
+
azureHelper.expiry = new Date(Date.now() + 10 * 60 * 1000);
|
|
129
|
+
|
|
130
|
+
const token = await azureHelper.getAccessToken();
|
|
131
|
+
t.is(token, 'existing-token');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('getAccessToken should throw error when token is invalid and no network available', async (t) => {
|
|
135
|
+
const mockConfig = {
|
|
136
|
+
azureServicePrincipalCredentials: JSON.stringify({
|
|
137
|
+
tenant_id: 'test-tenant',
|
|
138
|
+
client_id: 'test-client',
|
|
139
|
+
client_secret: 'test-secret'
|
|
140
|
+
})
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const azureHelper = new AzureAuthTokenHelper(mockConfig);
|
|
144
|
+
// No token set, so it will try to refresh and fail
|
|
145
|
+
|
|
146
|
+
await t.throwsAsync(
|
|
147
|
+
azureHelper.getAccessToken(),
|
|
148
|
+
{ message: /Failed to refresh Azure token/ }
|
|
149
|
+
);
|
|
150
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// azureFoundryAgents.test.js
|
|
2
|
+
import test from 'ava';
|
|
3
|
+
import AzureFoundryAgentsPlugin from '../server/plugins/azureFoundryAgentsPlugin.js';
|
|
4
|
+
|
|
5
|
+
test.beforeEach(t => {
|
|
6
|
+
const mockPathway = {
|
|
7
|
+
name: 'test-pathway',
|
|
8
|
+
temperature: 0.7,
|
|
9
|
+
prompt: {
|
|
10
|
+
context: 'You are a helpful assistant.',
|
|
11
|
+
examples: []
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mockModel = {
|
|
16
|
+
name: 'azure-foundry-agents',
|
|
17
|
+
type: 'AZURE-FOUNDRY-AGENTS',
|
|
18
|
+
url: 'https://archipelago-foundry-resource.services.ai.azure.com/api/projects/archipelago-foundry',
|
|
19
|
+
agentId: 'asst_testid',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json'
|
|
22
|
+
},
|
|
23
|
+
maxTokenLength: 32768,
|
|
24
|
+
maxReturnTokens: 4096,
|
|
25
|
+
supportsStreaming: true
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
t.context.plugin = new AzureFoundryAgentsPlugin(mockPathway, mockModel);
|
|
29
|
+
t.context.mockPathway = mockPathway;
|
|
30
|
+
t.context.mockModel = mockModel;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('should initialize with correct agent ID and project URL', t => {
|
|
34
|
+
const { plugin } = t.context;
|
|
35
|
+
t.is(plugin.agentId, 'asst_testid');
|
|
36
|
+
t.is(plugin.projectUrl, 'https://archipelago-foundry-resource.services.ai.azure.com/api/projects/archipelago-foundry');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('should convert Palm format messages to Azure format', t => {
|
|
40
|
+
const { plugin } = t.context;
|
|
41
|
+
const context = 'You are a helpful assistant.';
|
|
42
|
+
const examples = [
|
|
43
|
+
{
|
|
44
|
+
input: { author: 'user', content: 'Hello' },
|
|
45
|
+
output: { author: 'assistant', content: 'Hi there!' }
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
const messages = [
|
|
49
|
+
{ author: 'user', content: 'How are you?' }
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const result = plugin.convertToAzureFoundryMessages(context, examples, messages);
|
|
53
|
+
|
|
54
|
+
t.deepEqual(result, [
|
|
55
|
+
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
56
|
+
{ role: 'user', content: 'Hello' },
|
|
57
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
58
|
+
{ role: 'user', content: 'How are you?' }
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('should handle empty examples and context', t => {
|
|
63
|
+
const { plugin } = t.context;
|
|
64
|
+
const messages = [
|
|
65
|
+
{ author: 'user', content: 'Hello' }
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const result = plugin.convertToAzureFoundryMessages('', [], messages);
|
|
69
|
+
|
|
70
|
+
t.deepEqual(result, [
|
|
71
|
+
{ role: 'user', content: 'Hello' }
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('should create correct request parameters', t => {
|
|
76
|
+
const { plugin } = t.context;
|
|
77
|
+
const text = 'Hello, can you help me?';
|
|
78
|
+
const parameters = { stream: false };
|
|
79
|
+
const prompt = {
|
|
80
|
+
context: 'You are helpful.',
|
|
81
|
+
examples: [],
|
|
82
|
+
messages: [{ role: 'user', content: text }]
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const result = plugin.getRequestParameters(text, parameters, prompt);
|
|
86
|
+
|
|
87
|
+
t.is(result.assistant_id, 'asst_testid');
|
|
88
|
+
t.deepEqual(result.thread.messages, [{ role: 'user', content: text }]);
|
|
89
|
+
t.is(result.stream, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('should parse completed run response', t => {
|
|
93
|
+
const { plugin } = t.context;
|
|
94
|
+
const mockResponse = {
|
|
95
|
+
id: 'run_123',
|
|
96
|
+
status: 'completed',
|
|
97
|
+
thread_id: 'thread_456'
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const result = plugin.parseResponse(mockResponse);
|
|
101
|
+
t.deepEqual(result, mockResponse);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('should parse string response (final message content)', t => {
|
|
105
|
+
const { plugin } = t.context;
|
|
106
|
+
const mockResponse = 'Hello! How can I help you?';
|
|
107
|
+
|
|
108
|
+
const result = plugin.parseResponse(mockResponse);
|
|
109
|
+
t.is(result, 'Hello! How can I help you?');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('should handle failed run response', t => {
|
|
113
|
+
const { plugin } = t.context;
|
|
114
|
+
const mockResponse = {
|
|
115
|
+
id: 'run_123',
|
|
116
|
+
status: 'failed',
|
|
117
|
+
lastError: { message: 'Something went wrong' }
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const result = plugin.parseResponse(mockResponse);
|
|
121
|
+
t.is(result, null);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('should parse message content from response', t => {
|
|
125
|
+
const { plugin } = t.context;
|
|
126
|
+
const mockResponse = {
|
|
127
|
+
messages: [
|
|
128
|
+
{
|
|
129
|
+
role: 'assistant',
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: { value: 'Hello! How can I help you?' }
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const result = plugin.parseResponse(mockResponse);
|
|
141
|
+
t.is(result, 'Hello! How can I help you?');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('should return empty string for null response', t => {
|
|
145
|
+
const { plugin } = t.context;
|
|
146
|
+
const result = plugin.parseResponse(null);
|
|
147
|
+
t.is(result, '');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('should return correct Azure Foundry Agents endpoint', t => {
|
|
151
|
+
const { plugin } = t.context;
|
|
152
|
+
const url = plugin.requestUrl();
|
|
153
|
+
t.is(url, 'https://archipelago-foundry-resource.services.ai.azure.com/api/projects/archipelago-foundry/threads/runs');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('should be able to access azureAuthTokenHelper from config', (t) => {
|
|
157
|
+
// Mock config with azureAuthTokenHelper
|
|
158
|
+
const mockConfig = {
|
|
159
|
+
get: (key) => {
|
|
160
|
+
if (key === 'azureAuthTokenHelper') {
|
|
161
|
+
return {
|
|
162
|
+
getAccessToken: async () => 'mock-token'
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Mock pathway and model
|
|
170
|
+
const mockPathway = {};
|
|
171
|
+
const mockModel = {
|
|
172
|
+
url: 'https://test.azure.com/api/projects/test',
|
|
173
|
+
agentId: 'test-agent-id',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' }
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Create plugin instance
|
|
178
|
+
const plugin = new AzureFoundryAgentsPlugin(mockPathway, mockModel);
|
|
179
|
+
|
|
180
|
+
// Mock the config property
|
|
181
|
+
plugin.config = mockConfig;
|
|
182
|
+
|
|
183
|
+
// Test that we can access the auth helper
|
|
184
|
+
const authHelper = plugin.config.get('azureAuthTokenHelper');
|
|
185
|
+
t.truthy(authHelper);
|
|
186
|
+
t.is(typeof authHelper.getAccessToken, 'function');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('should handle missing azureAuthTokenHelper gracefully', (t) => {
|
|
190
|
+
// Mock config without azureAuthTokenHelper
|
|
191
|
+
const mockConfig = {
|
|
192
|
+
get: (key) => null
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Mock pathway and model
|
|
196
|
+
const mockPathway = {};
|
|
197
|
+
const mockModel = {
|
|
198
|
+
url: 'https://test.azure.com/api/projects/test',
|
|
199
|
+
agentId: 'test-agent-id',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' }
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Create plugin instance
|
|
204
|
+
const plugin = new AzureFoundryAgentsPlugin(mockPathway, mockModel);
|
|
205
|
+
|
|
206
|
+
// Mock the config property
|
|
207
|
+
plugin.config = mockConfig;
|
|
208
|
+
|
|
209
|
+
// Test that we can access the auth helper (should be null)
|
|
210
|
+
const authHelper = plugin.config.get('azureAuthTokenHelper');
|
|
211
|
+
t.is(authHelper, null);
|
|
212
|
+
});
|