@loadmill/droid-cua 2.15.1 → 2.17.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.
@@ -1,7 +1,26 @@
1
1
  import OpenAI from "openai";
2
+ import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
2
3
  import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
3
- import { resolveCuaModel } from "./cua-models.js";
4
+ import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
5
+ import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
4
6
  import { normalizeStepDelayMs } from "../utils/step-delay.js";
7
+ export const LOADMILL_MODEL_ALIASES = Object.freeze({
8
+ DEFAULT_AGENT_MODEL: "default-agent-model",
9
+ FAST: "fast",
10
+ SMART: "smart",
11
+ COMPUTER_USE: "computer-use",
12
+ });
13
+ const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
14
+ export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
15
+ export const DEFAULT_LLM_PROVIDER_MODE = "loadmill";
16
+ export const DEFAULT_LOADMILL_BASE_URL = "https://app.loadmill.com";
17
+ const VALID_LLM_PROVIDER_MODES = new Set(LLM_PROVIDER_MODES);
18
+ export function isValidLlmProviderMode(value) {
19
+ return VALID_LLM_PROVIDER_MODES.has(value);
20
+ }
21
+ export function resolveLlmProviderMode(value) {
22
+ return isValidLlmProviderMode(value) ? value : DEFAULT_LLM_PROVIDER_MODE;
23
+ }
5
24
  export function createEmptyPromptCustomizations() {
6
25
  return {
7
26
  basePromptInstructions: "",
@@ -28,16 +47,95 @@ function trimNullableString(value) {
28
47
  const trimmed = value.trim();
29
48
  return trimmed || null;
30
49
  }
31
- export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
50
+ function normalizeLoadmillSiteBaseUrl(value) {
51
+ const trimmed = trimNullableString(value) || DEFAULT_LOADMILL_BASE_URL;
52
+ const normalizedCandidate = normalizeOptionalAutomationUrl(trimmed) || trimmed;
53
+ try {
54
+ const parsed = new URL(normalizedCandidate);
55
+ const pathname = parsed.pathname.replace(/\/+$/, "");
56
+ parsed.pathname = pathname
57
+ .replace(/\/api$/, "")
58
+ .replace(/\/llm\/v1$/, "")
59
+ .replace(/\/llm$/, "");
60
+ parsed.search = "";
61
+ parsed.hash = "";
62
+ return parsed.toString().replace(/\/$/, "");
63
+ }
64
+ catch {
65
+ return normalizedCandidate
66
+ .replace(/\/api\/?$/, "")
67
+ .replace(/\/llm\/v1\/?$/, "")
68
+ .replace(/\/llm\/?$/, "")
69
+ .replace(/\/$/, "") || null;
70
+ }
71
+ }
72
+ export function buildLoadmillLlmBaseUrl(value) {
73
+ const siteBaseUrl = normalizeLoadmillSiteBaseUrl(value);
74
+ return siteBaseUrl ? `${siteBaseUrl}/llm/v1` : null;
75
+ }
76
+ export function hasLoadmillLlmAccess(credentials = {}) {
77
+ return Boolean(trimNullableString(credentials.loadmillApiToken) &&
78
+ buildLoadmillLlmBaseUrl(credentials.loadmillBaseUrl));
79
+ }
80
+ export function hasOpenAiLlmAccess(credentials = {}) {
81
+ return Boolean(trimNullableString(credentials.openAiApiKey));
82
+ }
83
+ export function inferLlmProviderModeFromCredentials(credentials = {}) {
84
+ if (hasLoadmillLlmAccess(credentials)) {
85
+ return "loadmill";
86
+ }
87
+ if (hasOpenAiLlmAccess(credentials)) {
88
+ return "openai";
89
+ }
90
+ return DEFAULT_LLM_PROVIDER_MODE;
91
+ }
92
+ export function resolveGatewayCuaModelAlias({ cuaBackend, cuaModel } = {}) {
93
+ if (resolveCuaBackend(cuaBackend) === "fara") {
94
+ return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
95
+ }
96
+ const normalizedCuaModel = resolveCuaModel(cuaModel);
97
+ return normalizedCuaModel === "gpt-5.4-mini"
98
+ ? LOADMILL_MODEL_ALIASES.FAST
99
+ : LOADMILL_MODEL_ALIASES.SMART;
100
+ }
101
+ export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaBackend = null, cuaModel = null, } = {}) {
102
+ const providerMode = resolveLlmProviderMode(llmProviderMode);
103
+ if (providerMode === "loadmill") {
104
+ return resolveGatewayCuaModelAlias({ cuaBackend, cuaModel });
105
+ }
106
+ return resolveCuaModelProviderMode(cuaModel) === "openai"
107
+ ? resolveCuaModel(cuaModel)
108
+ : DEFAULT_OPENAI_CUA_MODEL;
109
+ }
110
+ export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel = null, } = {}) {
111
+ if (resolveLlmProviderMode(llmProviderMode) === "loadmill") {
112
+ return LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL;
113
+ }
114
+ return resolveCuaModelProviderMode(cuaModel) === "openai"
115
+ ? resolveCuaModel(cuaModel)
116
+ : DEFAULT_OPENAI_CUA_MODEL;
117
+ }
118
+ export function resolveRuntimeCredentials({ openAiApiKey = null, faraApiKey = null, faraBaseUrl = null, faraModel = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
32
119
  return {
33
120
  openAiApiKey: trimNullableString(openAiApiKey),
121
+ faraApiKey: trimNullableString(faraApiKey),
122
+ faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
123
+ faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
34
124
  loadmillApiToken: trimNullableString(loadmillApiToken),
35
- loadmillBaseUrl: trimNullableString(loadmillBaseUrl),
125
+ loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
36
126
  };
37
127
  }
38
- export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
128
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
129
+ const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
130
+ ? cuaModel
131
+ : resolveCuaModel(cuaModel);
132
+ const normalizedProviderMode = resolveLlmProviderMode(llmProviderMode);
39
133
  return {
40
- cuaModel: resolveCuaModel(cuaModel),
134
+ llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
135
+ ? "loadmill"
136
+ : normalizedProviderMode,
137
+ cuaBackend: resolveCuaBackend(cuaBackend),
138
+ cuaModel: normalizedCuaModel,
41
139
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
42
140
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
43
141
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
@@ -62,6 +160,12 @@ export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined,
62
160
  export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
63
161
  const contextOptimization = readContextOptimizationEnv(env);
64
162
  return resolveRuntimeConfig({
163
+ llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
164
+ // Only the explicit backend flag should influence CUA model routing.
165
+ // Legacy screenshot-provider env vars must not silently remap Loadmill
166
+ // model aliases like "smart" to backend-specific aliases like
167
+ // "computer-use".
168
+ cuaBackend: env.DROID_CUA_BACKEND,
65
169
  cuaModel: env.OPENAI_CUA_MODEL,
66
170
  stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
67
171
  contextOptimizationEnabled: contextOptimization.enabled,
@@ -72,6 +176,9 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
72
176
  export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
73
177
  return resolveRuntimeCredentials({
74
178
  openAiApiKey: env.OPENAI_API_KEY,
179
+ faraApiKey: env.DROID_CUA_FARA_API_KEY,
180
+ faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
181
+ faraModel: env.DROID_CUA_FARA_MODEL,
75
182
  loadmillApiToken: env.LOADMILL_API_TOKEN,
76
183
  loadmillBaseUrl: env.LOADMILL_BASE_URL,
77
184
  ...overrides,
@@ -82,14 +189,151 @@ export function createOpenAiClient(apiKey) {
82
189
  apiKey: trimNullableString(apiKey) || undefined,
83
190
  });
84
191
  }
192
+ function missingCredentialErrorMessage(llmProviderMode) {
193
+ return resolveLlmProviderMode(llmProviderMode) === "loadmill"
194
+ ? "Loadmill authentication is required to use the Loadmill model."
195
+ : "An OpenAI API key is required to use OpenAI models.";
196
+ }
197
+ function isLoadmillGatewayErrorPayload(payload) {
198
+ return Boolean(payload &&
199
+ typeof payload === "object" &&
200
+ typeof payload.message === "string" &&
201
+ typeof payload.type === "string" &&
202
+ typeof payload.code === "string");
203
+ }
204
+ function normalizeLoadmillGatewayErrorBody(body) {
205
+ const payload = body?.payload;
206
+ if (!isLoadmillGatewayErrorPayload(payload)) {
207
+ return null;
208
+ }
209
+ return {
210
+ error: {
211
+ message: payload.message,
212
+ type: payload.type,
213
+ code: payload.code,
214
+ param: payload.param ?? null,
215
+ provider: typeof payload.provider === "string" ? payload.provider : null,
216
+ },
217
+ };
218
+ }
219
+ async function normalizeLoadmillGatewayErrorResponse(response) {
220
+ const contentType = response.headers.get("content-type") || "";
221
+ if (!contentType.includes("application/json")) {
222
+ return response;
223
+ }
224
+ const responseClone = response.clone();
225
+ const bodyText = await responseClone.text();
226
+ if (!bodyText) {
227
+ return response;
228
+ }
229
+ let parsedBody;
230
+ try {
231
+ parsedBody = JSON.parse(bodyText);
232
+ }
233
+ catch {
234
+ return response;
235
+ }
236
+ const normalizedBody = normalizeLoadmillGatewayErrorBody(parsedBody);
237
+ if (!normalizedBody) {
238
+ return response;
239
+ }
240
+ const headers = new Headers(response.headers);
241
+ headers.set("content-type", "application/json");
242
+ return new Response(JSON.stringify(normalizedBody), {
243
+ status: response.status,
244
+ statusText: response.statusText,
245
+ headers,
246
+ });
247
+ }
248
+ function createLoadmillGatewayFetch(baseFetch = fetch) {
249
+ return async (input, init) => {
250
+ const response = await baseFetch(input, init);
251
+ if (response.ok) {
252
+ return response;
253
+ }
254
+ return await normalizeLoadmillGatewayErrorResponse(response);
255
+ };
256
+ }
257
+ /**
258
+ * @param {{
259
+ * loadmillApiToken?: string | null,
260
+ * loadmillBaseUrl?: string | null,
261
+ * clientName?: string,
262
+ * maxRetries?: number,
263
+ * }} [options]
264
+ */
265
+ export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName = "desktop", maxRetries = 1, } = {}) {
266
+ const apiKey = trimNullableString(loadmillApiToken);
267
+ const baseURL = buildLoadmillLlmBaseUrl(loadmillBaseUrl);
268
+ if (!apiKey || !baseURL) {
269
+ throw new Error("Loadmill authentication is required for LLM access.");
270
+ }
271
+ return new OpenAI({
272
+ apiKey,
273
+ baseURL,
274
+ maxRetries,
275
+ fetch: createLoadmillGatewayFetch(),
276
+ defaultHeaders: {
277
+ "X-Loadmill-Client": clientName,
278
+ },
279
+ });
280
+ }
281
+ export function createFaraClient({ apiKey, baseUrl }) {
282
+ return new OpenAI({
283
+ apiKey: trimNullableString(apiKey) || "",
284
+ baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
285
+ });
286
+ }
287
+ export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
288
+ const resolvedCredentials = resolveRuntimeCredentials(credentials);
289
+ const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
290
+ if (resolvedProviderMode === "loadmill") {
291
+ if (!hasLoadmillLlmAccess(resolvedCredentials)) {
292
+ throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
293
+ }
294
+ return createLlmClient({
295
+ loadmillApiToken: resolvedCredentials.loadmillApiToken,
296
+ loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
297
+ clientName,
298
+ maxRetries,
299
+ });
300
+ }
301
+ if (!hasOpenAiLlmAccess(resolvedCredentials)) {
302
+ throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
303
+ }
304
+ return createOpenAiClient(resolvedCredentials.openAiApiKey);
305
+ }
85
306
  export function createRunContext({ config, credentials }) {
86
307
  const resolvedConfig = resolveRuntimeConfig(config);
87
308
  const resolvedCredentials = resolveRuntimeCredentials(credentials);
309
+ const resolvedProviderMode = resolveLlmProviderMode(resolvedConfig.llmProviderMode);
310
+ const effectiveConfig = {
311
+ ...resolvedConfig,
312
+ llmProviderMode: resolvedProviderMode,
313
+ cuaModel: resolveEffectiveCuaModel({
314
+ llmProviderMode: resolvedProviderMode,
315
+ cuaBackend: resolvedConfig.cuaBackend,
316
+ cuaModel: resolvedConfig.cuaModel,
317
+ }),
318
+ };
319
+ const effectiveCredentials = {
320
+ ...resolvedCredentials,
321
+ };
88
322
  return {
89
- config: resolvedConfig,
90
- credentials: resolvedCredentials,
323
+ config: effectiveConfig,
324
+ credentials: effectiveCredentials,
91
325
  clients: {
92
- openai: createOpenAiClient(resolvedCredentials.openAiApiKey),
326
+ openai: createResolvedLlmClient({
327
+ llmProviderMode: resolvedProviderMode,
328
+ credentials: resolvedCredentials,
329
+ clientName: "desktop",
330
+ }),
331
+ fara: resolvedConfig.cuaBackend === "fara"
332
+ ? createFaraClient({
333
+ apiKey: resolvedCredentials.faraApiKey,
334
+ baseUrl: resolvedCredentials.faraBaseUrl,
335
+ })
336
+ : null,
93
337
  },
94
338
  };
95
339
  }
@@ -0,0 +1,85 @@
1
+ import { resolveEffectiveHelperModel } from "./runtime-config.js";
2
+ import { buildScenarioPlannerMessages } from "../prompts/scenario-planner.js";
3
+ function parseScenarioPlannerContent(content) {
4
+ try {
5
+ return JSON.parse(content);
6
+ }
7
+ catch (error) {
8
+ const message = error instanceof Error ? error.message : "Invalid JSON.";
9
+ throw new Error(`Could not parse scenario planner response: ${message}`);
10
+ }
11
+ }
12
+ function normalizeCaseName(value, fallback) {
13
+ const trimmed = typeof value === "string" ? value.trim() : "";
14
+ return trimmed || fallback;
15
+ }
16
+ function dedupeCaseNames(cases) {
17
+ const usedNames = new Set();
18
+ const baseCounts = new Map();
19
+ return cases.map((entry) => {
20
+ const baseName = entry.caseName;
21
+ let nextIndex = baseCounts.get(baseName) || 1;
22
+ let caseName = baseName;
23
+ while (usedNames.has(caseName)) {
24
+ nextIndex += 1;
25
+ caseName = `${baseName} (${nextIndex})`;
26
+ }
27
+ baseCounts.set(baseName, nextIndex);
28
+ usedNames.add(caseName);
29
+ return {
30
+ ...entry,
31
+ caseName,
32
+ };
33
+ });
34
+ }
35
+ export function validateScenarioCases(rawCases, candidateTests) {
36
+ if (!Array.isArray(rawCases) || rawCases.length === 0) {
37
+ throw new Error("Scenario planner did not return any cases.");
38
+ }
39
+ const candidateNames = new Set(candidateTests.map((test) => test.testName));
40
+ const normalizedCases = rawCases.map((rawCase, index) => {
41
+ if (!rawCase || typeof rawCase !== "object" || Array.isArray(rawCase)) {
42
+ throw new Error(`Scenario case ${index + 1} must be an object.`);
43
+ }
44
+ const testName = typeof rawCase.testName === "string" ? rawCase.testName.trim() : "";
45
+ if (!candidateNames.has(testName)) {
46
+ throw new Error(`Scenario case ${index + 1} references unknown test "${testName || "(missing)"}".`);
47
+ }
48
+ const caseName = normalizeCaseName(rawCase.caseName, `${testName} / Case ${index + 1}`);
49
+ const testDataContext = typeof rawCase.testDataContext === "string" ? rawCase.testDataContext.trim() : "";
50
+ return {
51
+ testName,
52
+ caseName,
53
+ testDataContext,
54
+ };
55
+ });
56
+ return dedupeCaseNames(normalizedCases);
57
+ }
58
+ export async function planScenarioRuns({ client, runtimeConfig, scenarioPrompt, tests, appContextText = "", testDataText = "", }) {
59
+ if (!client?.chat?.completions?.create) {
60
+ throw new Error("Scenario planning requires an LLM client.");
61
+ }
62
+ const prompt = typeof scenarioPrompt === "string" ? scenarioPrompt.trim() : "";
63
+ if (!prompt) {
64
+ throw new Error("--scenario must be a non-empty string.");
65
+ }
66
+ if (!Array.isArray(tests) || tests.length === 0) {
67
+ throw new Error("Scenario planning requires at least one candidate test.");
68
+ }
69
+ const response = await client.chat.completions.create({
70
+ model: resolveEffectiveHelperModel({
71
+ llmProviderMode: runtimeConfig?.llmProviderMode,
72
+ cuaModel: runtimeConfig?.cuaModel,
73
+ }),
74
+ messages: buildScenarioPlannerMessages({
75
+ scenarioPrompt: prompt,
76
+ tests,
77
+ appContextText,
78
+ testDataText,
79
+ }),
80
+ response_format: { type: "json_object" },
81
+ });
82
+ const content = response.choices?.[0]?.message?.content || "{}";
83
+ const parsed = parseScenarioPlannerContent(content);
84
+ return validateScenarioCases(parsed.cases, tests);
85
+ }
@@ -0,0 +1,62 @@
1
+ import path from "path";
2
+ import { resolveEffectiveHelperModel } from "./runtime-config.js";
3
+ import { buildTestDataImportMessages } from "../prompts/test-data-import.js";
4
+ export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt"];
5
+ export function isSupportedTestDataImportPath(filePath) {
6
+ return TEST_DATA_IMPORT_EXTENSIONS.includes(path.extname(String(filePath ?? "")).toLowerCase());
7
+ }
8
+ export function assertSupportedTestDataImportPath(filePath) {
9
+ if (!isSupportedTestDataImportPath(filePath)) {
10
+ throw new Error("Test data import supports CSV and TXT files only.");
11
+ }
12
+ }
13
+ function parseTestDataImportContent(content) {
14
+ try {
15
+ return JSON.parse(content);
16
+ }
17
+ catch (error) {
18
+ const message = error instanceof Error ? error.message : "Invalid JSON.";
19
+ throw new Error(`Could not parse test data import response: ${message}`);
20
+ }
21
+ }
22
+ function normalizeGeneratedContent(value) {
23
+ const content = typeof value === "string" ? value.trim() : "";
24
+ if (!content) {
25
+ throw new Error("Test data import returned empty content.");
26
+ }
27
+ if (/^```/.test(content)) {
28
+ throw new Error("Test data import returned code-fenced content instead of markdown.");
29
+ }
30
+ return content.endsWith("\n") ? content : `${content}\n`;
31
+ }
32
+ export async function importTestDataMarkdown({ client, runtimeConfig, currentContent = "", importedFileName, importedFileText, instructions = "", }) {
33
+ if (!client?.chat?.completions?.create) {
34
+ throw new Error("Test data import requires an LLM client.");
35
+ }
36
+ const fileName = typeof importedFileName === "string" ? importedFileName.trim() : "";
37
+ if (!fileName) {
38
+ throw new Error("Imported file name is required.");
39
+ }
40
+ const fileText = typeof importedFileText === "string" ? importedFileText : "";
41
+ if (!fileText.trim()) {
42
+ throw new Error("Selected import file is empty.");
43
+ }
44
+ const response = await client.chat.completions.create({
45
+ model: resolveEffectiveHelperModel({
46
+ llmProviderMode: runtimeConfig?.llmProviderMode,
47
+ cuaModel: runtimeConfig?.cuaModel,
48
+ }),
49
+ messages: buildTestDataImportMessages({
50
+ currentContent,
51
+ importedFileName: fileName,
52
+ importedFileText: fileText,
53
+ instructions,
54
+ }),
55
+ response_format: { type: "json_object" },
56
+ });
57
+ const content = response.choices?.[0]?.message?.content || "{}";
58
+ const parsed = parseTestDataImportContent(content);
59
+ return {
60
+ content: normalizeGeneratedContent(parsed.content),
61
+ };
62
+ }
@@ -0,0 +1,103 @@
1
+ import path from "path";
2
+ import { access, readFile } from "fs/promises";
3
+ import { constants as fsConstants } from "fs";
4
+ export const TEST_DATA_FILENAME = "test-data.md";
5
+ export const DEFAULT_TEST_DATA_TEMPLATE = `# Test Data
6
+ <!-- Concrete data the agent can use during Scenario Runs. Keep entries named and specific. -->
7
+ <!-- Examples below are commented out so they are guidance, not usable test data. -->
8
+
9
+ ## Accounts
10
+ <!--
11
+ ### Standard buyer
12
+ Email:
13
+ Password:
14
+ Notes: When should the agent use this account?
15
+ -->
16
+
17
+ ## Products
18
+ <!--
19
+ ### In-stock product
20
+ Name:
21
+ SKU:
22
+ Notes:
23
+ -->
24
+
25
+ ## Payment Methods
26
+ <!--
27
+ ### Valid card
28
+ Number:
29
+ Expiration:
30
+ CVV:
31
+ Notes:
32
+ -->
33
+
34
+ ## Roles and Permissions
35
+
36
+ ## Regions and Locales
37
+
38
+ ## Other Data
39
+ `;
40
+ export function getDefaultProjectTestDataPath(projectPath) {
41
+ return path.join(projectPath, TEST_DATA_FILENAME);
42
+ }
43
+ export function normalizeTestDataTemplateText(text) {
44
+ return String(text ?? "").replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "").trimEnd();
45
+ }
46
+ export function isDefaultTestDataTemplate(text) {
47
+ if (!String(text ?? "").trim()) {
48
+ return true;
49
+ }
50
+ return normalizeTestDataTemplateText(text) === normalizeTestDataTemplateText(DEFAULT_TEST_DATA_TEMPLATE);
51
+ }
52
+ export async function readTestDataFile(filePath) {
53
+ await access(filePath, fsConstants.R_OK);
54
+ return await readFile(filePath, "utf-8");
55
+ }
56
+ export async function loadTestData({ testDataPath }) {
57
+ if (!testDataPath) {
58
+ return { text: "", testDataPath: null };
59
+ }
60
+ const text = await readTestDataFile(testDataPath);
61
+ return { text, testDataPath };
62
+ }
63
+ /**
64
+ * @param {{
65
+ * testDataPath?: string | null,
66
+ * missingFileStatus?: string | null,
67
+ * }} [options]
68
+ */
69
+ export async function resolveExecutionTestData({ testDataPath = null, missingFileStatus = null, } = {}) {
70
+ if (!testDataPath) {
71
+ return {
72
+ status: "omitted",
73
+ text: "",
74
+ testDataPath: null,
75
+ message: null,
76
+ };
77
+ }
78
+ try {
79
+ const result = await loadTestData({ testDataPath });
80
+ return {
81
+ status: result.text.trim().length > 0 ? "loaded" : "empty",
82
+ text: result.text,
83
+ testDataPath: result.testDataPath,
84
+ message: null,
85
+ };
86
+ }
87
+ catch (error) {
88
+ if (missingFileStatus && error && typeof error === "object" && error.code === "ENOENT") {
89
+ return {
90
+ status: missingFileStatus,
91
+ text: "",
92
+ testDataPath,
93
+ message: null,
94
+ };
95
+ }
96
+ return {
97
+ status: "failed",
98
+ text: "",
99
+ testDataPath,
100
+ message: error instanceof Error ? error.message : "Unknown test data error",
101
+ };
102
+ }
103
+ }
@@ -1,10 +1,25 @@
1
1
  import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
2
- import { createOpenAiClient, resolveRuntimeConfig } from "../core/runtime-config.js";
2
+ import { createOpenAiClient, LOADMILL_MODEL_ALIASES, resolveEffectiveHelperModel, resolveRuntimeConfig, } from "../core/runtime-config.js";
3
3
  import { DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
4
4
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD } from "../core/context-optimization.js";
5
5
  import { logger } from "../utils/logger.js";
6
6
  import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
7
7
  const cuaDebugTracer = new CuaDebugTracer(logger);
8
+ function resolveOpenAiBaseUrl(client) {
9
+ return client?.baseURL || client?.baseUrl || client?._options?.baseURL || null;
10
+ }
11
+ function resolveOpenAiEndpointUrl(client, endpointPath) {
12
+ const baseURL = resolveOpenAiBaseUrl(client);
13
+ if (!baseURL) {
14
+ return null;
15
+ }
16
+ try {
17
+ return new URL(endpointPath.replace(/^\/+/, ''), `${String(baseURL).replace(/\/+$/, '')}/`).toString();
18
+ }
19
+ catch {
20
+ return `${String(baseURL).replace(/\/+$/, '')}/${endpointPath.replace(/^\/+/, '')}`;
21
+ }
22
+ }
8
23
  function normalizeMessageContent(content) {
9
24
  if (Array.isArray(content)) {
10
25
  return content.map((item) => {
@@ -225,14 +240,15 @@ export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_AS
225
240
  * Revise a test script based on user feedback using simple chat completion
226
241
  * @param {{
227
242
  * client: import("openai").default,
243
+ * model?: string,
228
244
  * originalScript: string,
229
245
  * revisionRequest: string,
230
246
  * }} options
231
247
  * @returns {Promise<string>} - The revised test script
232
248
  */
233
- export async function reviseTestScript({ client, originalScript, revisionRequest }) {
249
+ export async function reviseTestScript({ client, model = LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL, originalScript, revisionRequest, }) {
234
250
  const response = await client.chat.completions.create({
235
- model: "gpt-5.4",
251
+ model,
236
252
  messages: [{
237
253
  role: "system",
238
254
  content: buildTestRevisionSystemPrompt(originalScript, revisionRequest)
@@ -274,7 +290,14 @@ export async function sendCUARequest({ client, cuaModel, aimAssistMode = DEFAULT
274
290
  debugContext,
275
291
  previousResponseId
276
292
  });
293
+ trace.endpoint = {
294
+ baseURL: resolveOpenAiBaseUrl(client),
295
+ responsesURL: resolveOpenAiEndpointUrl(client, "responses"),
296
+ model: requestParams.model,
297
+ previousResponseId: requestParams.previous_response_id ?? null,
298
+ };
277
299
  logger.debug("CUA Request:", trace.requestLog);
300
+ logger.debug("CUA Request endpoint:", trace.endpoint);
278
301
  try {
279
302
  const response = await client.responses.create(requestParams);
280
303
  cuaDebugTracer.onResponse(trace, response);
@@ -311,6 +334,10 @@ export function createOpenAiExecutor({ client, config }) {
311
334
  async reviseTestScript(originalScript, revisionRequest) {
312
335
  return await reviseTestScript({
313
336
  client: openAiClient,
337
+ model: resolveEffectiveHelperModel({
338
+ llmProviderMode: runtimeConfig.llmProviderMode,
339
+ cuaModel: runtimeConfig.cuaModel,
340
+ }),
314
341
  originalScript,
315
342
  revisionRequest,
316
343
  });
@@ -4,6 +4,7 @@
4
4
  import { createLoadmillApiClient } from "./client.js";
5
5
  import { interpretLoadmillCommand, selectBestFlow } from "./interpreter.js";
6
6
  import { extractLoadmillRuntimeParameters, renderLoadmillParameterContextNote, selectLoadmillParameterCandidates, } from "./parameters.js";
7
+ import { LOADMILL_MODEL_ALIASES } from "../../core/runtime-config.js";
7
8
  import { logger } from "../../utils/logger.js";
8
9
  const POLL_INTERVAL_MS = 5000; // 5 seconds
9
10
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
@@ -62,7 +63,7 @@ async function selectRelevantParameterNames({ openAiClient, command, flowName, f
62
63
  }
63
64
  try {
64
65
  const response = await openAiClient.chat.completions.create({
65
- model: "gpt-5.4",
66
+ model: LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL,
66
67
  messages: buildParameterSelectionMessages({
67
68
  command,
68
69
  flowName,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * AI-powered text interpretation for Loadmill commands
3
3
  */
4
+ import { LOADMILL_MODEL_ALIASES } from "../../core/runtime-config.js";
4
5
  import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionMessages, } from "../../prompts/loadmill.js";
5
6
  /**
6
7
  * Interpret a natural language Loadmill command into structured data
@@ -9,7 +10,7 @@ import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionM
9
10
  */
10
11
  export async function interpretLoadmillCommand(userInput, { client }) {
11
12
  const response = await client.chat.completions.create({
12
- model: "gpt-4o-mini",
13
+ model: LOADMILL_MODEL_ALIASES.FAST,
13
14
  messages: buildLoadmillCommandInterpretationMessages(userInput),
14
15
  response_format: { type: "json_object" }
15
16
  });
@@ -41,7 +42,7 @@ export async function selectBestFlow(flows, originalQuery, { client }) {
41
42
  return `${i + 1}. ID: ${f.id}, Name: "${name}"${suite ? `, Suite: "${suite}"` : ""}`;
42
43
  }).join("\n");
43
44
  const response = await client.chat.completions.create({
44
- model: "gpt-4o-mini",
45
+ model: LOADMILL_MODEL_ALIASES.FAST,
45
46
  messages: buildLoadmillFlowSelectionMessages(originalQuery, flowList),
46
47
  response_format: { type: "json_object" }
47
48
  });