@loadmill/droid-cua 2.15.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/build/index.js +5 -0
- package/build/src/cli/headless-execution-config.js +28 -2
- package/build/src/cli/ink-shell.js +1 -0
- package/build/src/commands/help.js +2 -1
- package/build/src/commands/loadmill.js +52 -25
- package/build/src/core/cua-backends.js +11 -0
- package/build/src/core/cua-models.js +47 -2
- package/build/src/core/runtime-config.js +252 -8
- package/build/src/device/openai.js +30 -3
- package/build/src/integrations/loadmill/executor.js +2 -1
- package/build/src/integrations/loadmill/interpreter.js +3 -2
- package/build/src/modes/execution-mode.js +45 -0
- package/build/src/utils/cua-debug-tracer.js +15 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -46,9 +46,9 @@ Under the hood, `droid-cua` uses an AI agent to explore your app, execute action
|
|
|
46
46
|
|
|
47
47
|
Open the desktop app and choose the platform you want to test.
|
|
48
48
|
|
|
49
|
-
**3.
|
|
49
|
+
**3. Choose your AI provider**
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
In Settings, choose the CUA model you want to run. `Loadmill - Smart` uses your Loadmill sign-in and usage tracking. The OpenAI models use your own OpenAI API key.
|
|
52
52
|
|
|
53
53
|
**4. Connect a target device**
|
|
54
54
|
|
|
@@ -75,7 +75,7 @@ Desktop app support:
|
|
|
75
75
|
## Requirements
|
|
76
76
|
|
|
77
77
|
**All platforms:**
|
|
78
|
-
- OpenAI API key
|
|
78
|
+
- Loadmill sign-in for `Loadmill - Smart`, or an OpenAI API key for the OpenAI models
|
|
79
79
|
|
|
80
80
|
**Android:**
|
|
81
81
|
- Android Debug Bridge (ADB)
|
package/build/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const targetConfig = resolveHeadlessTarget(args);
|
|
|
29
29
|
let avdName = targetConfig.avdName;
|
|
30
30
|
let platform = targetConfig.platform || null; // 'ios' or 'android'
|
|
31
31
|
const recordScreenshots = args["record"] || false;
|
|
32
|
+
const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ? args.url.trim() : null;
|
|
32
33
|
const instructionsFile = args.instructions || args.i || null;
|
|
33
34
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
34
35
|
const debugMode = args["debug"] || false;
|
|
@@ -149,6 +150,7 @@ async function main() {
|
|
|
149
150
|
let deviceInfo;
|
|
150
151
|
if (isBrowserTarget) {
|
|
151
152
|
targetId = await connectToBrowser({
|
|
153
|
+
url: startupUrl,
|
|
152
154
|
headless: true,
|
|
153
155
|
browserFamily: targetConfig.browserFamily,
|
|
154
156
|
sessionMode: targetConfig.sessionMode
|
|
@@ -219,6 +221,7 @@ async function main() {
|
|
|
219
221
|
const appContext = preloadedAppContext;
|
|
220
222
|
const appContextText = appContext.text;
|
|
221
223
|
const runtimeContext = createCliRuntimeContext({
|
|
224
|
+
llmProviderMode: headlessConfig.llmProviderMode,
|
|
222
225
|
cuaModel: headlessConfig.cuaModel,
|
|
223
226
|
promptCustomizations: headlessConfig.promptCustomizations,
|
|
224
227
|
contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
|
|
@@ -231,6 +234,8 @@ async function main() {
|
|
|
231
234
|
message: appContext.message,
|
|
232
235
|
},
|
|
233
236
|
});
|
|
237
|
+
console.log(`Using AI provider: ${runtimeContext.config.llmProviderMode}`);
|
|
238
|
+
console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
|
|
234
239
|
const result = await runHeadlessInstructionRuns({
|
|
235
240
|
runs: headlessInstructionRuns,
|
|
236
241
|
instructionsTarget: preloadedHeadlessTarget,
|
|
@@ -2,7 +2,8 @@ import path from "path";
|
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
3
|
import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
|
|
4
4
|
import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
|
|
5
|
-
import { CUA_MODELS, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
|
|
5
|
+
import { CUA_MODELS, DEFAULT_OPENAI_CUA_MODEL, isOpenAiCuaModel, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
|
|
6
|
+
import { LLM_PROVIDER_MODES, isValidLlmProviderMode, resolveLlmProviderMode } from "../core/runtime-config.js";
|
|
6
7
|
function createEmptyPromptCustomizations() {
|
|
7
8
|
return {
|
|
8
9
|
basePromptInstructions: "",
|
|
@@ -30,8 +31,25 @@ function validateAimAssistMode(value, label) {
|
|
|
30
31
|
}
|
|
31
32
|
return value;
|
|
32
33
|
}
|
|
34
|
+
function validateLlmProviderMode(value, label) {
|
|
35
|
+
if (typeof value !== "string" || !isValidLlmProviderMode(value)) {
|
|
36
|
+
throw new Error(`${label} must be one of: ${LLM_PROVIDER_MODES.join(", ")}.`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
33
40
|
function readEnvDefaultCuaModel() {
|
|
34
|
-
|
|
41
|
+
const envValue = process.env.OPENAI_CUA_MODEL;
|
|
42
|
+
// OPENAI_CUA_MODEL only selects OpenAI models. If it is set but not a valid
|
|
43
|
+
// OpenAI model, stay on the OpenAI default rather than crossing over to the
|
|
44
|
+
// global (Loadmill) default.
|
|
45
|
+
if (typeof envValue === "string" && envValue.trim()) {
|
|
46
|
+
const trimmed = envValue.trim();
|
|
47
|
+
return isOpenAiCuaModel(trimmed) ? trimmed : DEFAULT_OPENAI_CUA_MODEL;
|
|
48
|
+
}
|
|
49
|
+
return resolveCuaModel(envValue);
|
|
50
|
+
}
|
|
51
|
+
function readEnvDefaultLlmProviderMode() {
|
|
52
|
+
return resolveLlmProviderMode(process.env.DROID_CUA_LLM_PROVIDER);
|
|
35
53
|
}
|
|
36
54
|
function parseContextOptimizationThreshold(rawValue, label) {
|
|
37
55
|
const numericValue = (() => {
|
|
@@ -107,6 +125,7 @@ async function loadConfigFromFile(configPath) {
|
|
|
107
125
|
const configDir = path.dirname(absoluteConfigPath);
|
|
108
126
|
const normalized = {
|
|
109
127
|
configPath: absoluteConfigPath,
|
|
128
|
+
llmProviderMode: undefined,
|
|
110
129
|
cuaModel: undefined,
|
|
111
130
|
aimAssistMode: undefined,
|
|
112
131
|
promptCustomizations: createEmptyPromptCustomizations(),
|
|
@@ -115,6 +134,9 @@ async function loadConfigFromFile(configPath) {
|
|
|
115
134
|
contextOptimizationEnabled: undefined,
|
|
116
135
|
contextOptimizationThreshold: undefined,
|
|
117
136
|
};
|
|
137
|
+
if ("llmProviderMode" in rawConfig) {
|
|
138
|
+
normalized.llmProviderMode = validateLlmProviderMode(rawConfig.llmProviderMode, "config.llmProviderMode");
|
|
139
|
+
}
|
|
118
140
|
if ("cuaModel" in rawConfig) {
|
|
119
141
|
normalized.cuaModel = validateCuaModel(rawConfig.cuaModel, "config.cuaModel");
|
|
120
142
|
}
|
|
@@ -174,6 +196,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
174
196
|
};
|
|
175
197
|
const resolved = {
|
|
176
198
|
configPath: fileConfig?.configPath || null,
|
|
199
|
+
llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
|
|
177
200
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
178
201
|
aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
|
|
179
202
|
promptCustomizations,
|
|
@@ -182,6 +205,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
182
205
|
contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
|
|
183
206
|
contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
|
|
184
207
|
};
|
|
208
|
+
if (typeof args["llm-provider"] === "string") {
|
|
209
|
+
resolved.llmProviderMode = validateLlmProviderMode(args["llm-provider"], "--llm-provider");
|
|
210
|
+
}
|
|
185
211
|
if (typeof args["cua-model"] === "string") {
|
|
186
212
|
resolved.cuaModel = validateCuaModel(args["cua-model"], "--cua-model");
|
|
187
213
|
}
|
|
@@ -35,6 +35,7 @@ export async function startInkShell(session, executionEngine, options = {}) {
|
|
|
35
35
|
engine: executionEngine,
|
|
36
36
|
appContextPath: options.appContextPath ?? null,
|
|
37
37
|
runtimeContext: options.runtimeContext ?? null,
|
|
38
|
+
runtimeConfig: options.runtimeContext?.config ?? null,
|
|
38
39
|
openAiClient: options.runtimeContext?.openAiClient ?? null,
|
|
39
40
|
loadmillClient: options.runtimeContext?.loadmillClient ?? null,
|
|
40
41
|
runtimeCredentials: options.runtimeContext?.credentials ?? null
|
|
@@ -28,7 +28,8 @@ export async function handleHelp(args, session, context) {
|
|
|
28
28
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
29
29
|
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
30
30
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
31
|
-
addOutput({ type: 'info', text: ' --
|
|
31
|
+
addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
|
|
32
|
+
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
|
|
32
33
|
addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
|
|
33
34
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
|
|
34
35
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Loadmill command handler
|
|
3
3
|
*/
|
|
4
4
|
import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
6
6
|
/**
|
|
7
7
|
* Handle /loadmill command
|
|
8
8
|
* @param {string} args - Command arguments (natural language command)
|
|
@@ -48,33 +48,60 @@ export async function handleLoadmill(args, session, context) {
|
|
|
48
48
|
if (context.setAgentWorking) {
|
|
49
49
|
context.setAgentWorking(true, 'Running Loadmill flow...');
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
try {
|
|
52
|
+
const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
|
|
53
|
+
const runtimeConfig = context.runtimeConfig || resolveRuntimeConfigFromEnv();
|
|
54
|
+
const loadmillClient = context.loadmillClient || createLoadmillApiClient({
|
|
55
|
+
token: runtimeCredentials.loadmillApiToken,
|
|
56
|
+
baseUrl: runtimeCredentials.loadmillBaseUrl,
|
|
57
|
+
});
|
|
58
|
+
const openAiClient = context.openAiClient || createResolvedLlmClient({
|
|
59
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
60
|
+
credentials: runtimeCredentials,
|
|
61
|
+
clientName: "cli",
|
|
62
|
+
});
|
|
63
|
+
// Execute the command
|
|
64
|
+
const result = await executeLoadmillCommand(command, {
|
|
65
|
+
loadmillClient,
|
|
66
|
+
openAiClient,
|
|
67
|
+
onProgress: ({ message }) => {
|
|
68
|
+
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// Display results
|
|
72
|
+
if (result.success) {
|
|
73
|
+
if (result.action === "search") {
|
|
74
|
+
addOutput({ type: 'success', text: `Found ${result.result.flows.length} flow(s):` });
|
|
75
|
+
result.result.flows.forEach((flow, i) => {
|
|
76
|
+
const name = flow.description || flow.name || "Unknown";
|
|
77
|
+
const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
|
|
78
|
+
addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
|
|
79
|
+
addOutput({ type: 'info', text: ` ID: ${flow.id}` });
|
|
80
|
+
});
|
|
81
|
+
if (result.result.selectedFlow) {
|
|
82
|
+
const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
|
|
83
|
+
addOutput({ type: 'info', text: '' });
|
|
84
|
+
addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
89
|
+
if (result.runId) {
|
|
90
|
+
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (context.setAgentWorking) {
|
|
67
|
-
context.setAgentWorking(false);
|
|
68
|
-
}
|
|
69
|
-
// Display results
|
|
70
|
-
if (result.success) {
|
|
71
|
-
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
72
|
-
if (result.runId) {
|
|
73
|
-
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
94
|
+
else {
|
|
95
|
+
addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
|
-
|
|
77
|
-
addOutput({ type: 'error', text:
|
|
98
|
+
catch (error) {
|
|
99
|
+
addOutput({ type: 'error', text: error instanceof Error ? error.message : 'Loadmill command failed.' });
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (context.setAgentWorking) {
|
|
103
|
+
context.setAgentWorking(false);
|
|
104
|
+
}
|
|
78
105
|
}
|
|
79
106
|
return true; // Continue loop
|
|
80
107
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CUA_BACKENDS = ["openai", "fara"];
|
|
2
|
+
export const DEFAULT_CUA_BACKEND = "openai";
|
|
3
|
+
export const DEFAULT_FARA_BASE_URL = "http://localhost:5000/v1";
|
|
4
|
+
export const DEFAULT_FARA_MODEL = "Fara-7B";
|
|
5
|
+
const VALID_CUA_BACKENDS = new Set(CUA_BACKENDS);
|
|
6
|
+
export function isValidCuaBackend(value) {
|
|
7
|
+
return VALID_CUA_BACKENDS.has(value);
|
|
8
|
+
}
|
|
9
|
+
export function resolveCuaBackend(value) {
|
|
10
|
+
return isValidCuaBackend(value) ? value : DEFAULT_CUA_BACKEND;
|
|
11
|
+
}
|
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const
|
|
1
|
+
export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
|
|
2
|
+
export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
|
|
3
|
+
export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
|
|
4
|
+
export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
|
|
5
|
+
export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
|
|
6
|
+
export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
|
|
7
|
+
export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
8
|
+
Object.freeze({
|
|
9
|
+
id: "loadmill-smart",
|
|
10
|
+
providerMode: "loadmill",
|
|
11
|
+
providerLabel: "Loadmill",
|
|
12
|
+
name: "Loadmill Smart",
|
|
13
|
+
label: "Loadmill Smart (recommended)",
|
|
14
|
+
}),
|
|
15
|
+
Object.freeze({
|
|
16
|
+
id: "gpt-5.4",
|
|
17
|
+
providerMode: "openai",
|
|
18
|
+
providerLabel: "OpenAI",
|
|
19
|
+
name: "GPT-5.4",
|
|
20
|
+
label: "OpenAI GPT-5.4",
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({
|
|
23
|
+
id: "gpt-5.4-mini",
|
|
24
|
+
providerMode: "openai",
|
|
25
|
+
providerLabel: "OpenAI",
|
|
26
|
+
name: "GPT-5.4 mini",
|
|
27
|
+
label: "OpenAI GPT-5.4 mini (faster)",
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
3
30
|
const VALID_CUA_MODELS = new Set(CUA_MODELS);
|
|
31
|
+
const OPENAI_CUA_MODEL_SET = new Set(OPENAI_CUA_MODELS);
|
|
32
|
+
const LOADMILL_CUA_MODEL_SET = new Set(LOADMILL_CUA_MODELS);
|
|
33
|
+
const CUA_MODEL_PROVIDER_MODE_MAP = new Map(CUA_MODEL_OPTIONS.map((option) => [option.id, option.providerMode]));
|
|
4
34
|
export function isValidCuaModel(value) {
|
|
5
35
|
return VALID_CUA_MODELS.has(value);
|
|
6
36
|
}
|
|
37
|
+
export function isOpenAiCuaModel(value) {
|
|
38
|
+
return OPENAI_CUA_MODEL_SET.has(value);
|
|
39
|
+
}
|
|
40
|
+
export function isLoadmillCuaModel(value) {
|
|
41
|
+
return LOADMILL_CUA_MODEL_SET.has(value);
|
|
42
|
+
}
|
|
7
43
|
export function resolveCuaModel(value) {
|
|
8
44
|
return isValidCuaModel(value) ? value : DEFAULT_CUA_MODEL;
|
|
9
45
|
}
|
|
46
|
+
export function resolveCuaModelProviderMode(value) {
|
|
47
|
+
const normalized = resolveCuaModel(value);
|
|
48
|
+
return CUA_MODEL_PROVIDER_MODE_MAP.get(normalized) || "openai";
|
|
49
|
+
}
|
|
50
|
+
export function resolveDefaultCuaModelForProvider(providerMode) {
|
|
51
|
+
return providerMode === "loadmill"
|
|
52
|
+
? DEFAULT_LOADMILL_CUA_MODEL
|
|
53
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
54
|
+
}
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
90
|
-
credentials:
|
|
323
|
+
config: effectiveConfig,
|
|
324
|
+
credentials: effectiveCredentials,
|
|
91
325
|
clients: {
|
|
92
|
-
openai:
|
|
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
|
}
|
|
@@ -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
|
|
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:
|
|
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:
|
|
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:
|
|
45
|
+
model: LOADMILL_MODEL_ALIASES.FAST,
|
|
45
46
|
messages: buildLoadmillFlowSelectionMessages(originalQuery, flowList),
|
|
46
47
|
response_format: { type: "json_object" }
|
|
47
48
|
});
|
|
@@ -8,6 +8,31 @@ import { isAssertion } from "../device/assertions.js";
|
|
|
8
8
|
import { logger } from "../utils/logger.js";
|
|
9
9
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
10
10
|
import { printCliOutput } from "../utils/console-output.js";
|
|
11
|
+
// Mirrors the codes emitted by the Loadmill LLM gateway (see services/app/private/llm
|
|
12
|
+
// in the server repo): a transient burst limit plus day/week/fixed-budget request and
|
|
13
|
+
// token quotas. Free plans run on the fixed-budget quota.
|
|
14
|
+
const LLM_USAGE_LIMIT_ERROR_CODES = new Set([
|
|
15
|
+
"llm_rate_limit_exceeded",
|
|
16
|
+
"llm_day_quota_exceeded",
|
|
17
|
+
"llm_week_quota_exceeded",
|
|
18
|
+
"llm_fixed_budget_quota_exceeded",
|
|
19
|
+
"llm_day_token_quota_exceeded",
|
|
20
|
+
"llm_week_token_quota_exceeded",
|
|
21
|
+
"llm_fixed_budget_token_quota_exceeded",
|
|
22
|
+
]);
|
|
23
|
+
const LOADMILL_UPGRADE_URL = "https://www.loadmill.com/book-a-demo";
|
|
24
|
+
function isLlmUsageLimitError(err) {
|
|
25
|
+
if (!err || typeof err !== "object") {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (typeof err.code === "string" && LLM_USAGE_LIMIT_ERROR_CODES.has(err.code)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return err.status === 429 && err.type === "rate_limit_error";
|
|
32
|
+
}
|
|
33
|
+
function getLlmUsageLimitMessage() {
|
|
34
|
+
return `Execution stopped: you've reached the AI usage limit for this account. Contact the Loadmill team to upgrade: ${LOADMILL_UPGRADE_URL}`;
|
|
35
|
+
}
|
|
11
36
|
/**
|
|
12
37
|
* Execution Mode - Run test scripts line-by-line
|
|
13
38
|
* Each instruction is executed in isolation (messages cleared after each turn)
|
|
@@ -267,6 +292,26 @@ export class ExecutionMode {
|
|
|
267
292
|
stack: err.stack
|
|
268
293
|
});
|
|
269
294
|
const addOutput = context.addOutput || printCliOutput;
|
|
295
|
+
if (isLlmUsageLimitError(err)) {
|
|
296
|
+
const stopMessage = getLlmUsageLimitMessage();
|
|
297
|
+
logger.debug('Stopping execution because an LLM usage limit was reached', {
|
|
298
|
+
instruction,
|
|
299
|
+
message: err.message,
|
|
300
|
+
status: err.status,
|
|
301
|
+
code: err.code,
|
|
302
|
+
type: err.type,
|
|
303
|
+
});
|
|
304
|
+
this.emit(addOutput, 'error', stopMessage, context, stepContext, {
|
|
305
|
+
eventType: 'error',
|
|
306
|
+
payload: {
|
|
307
|
+
message: stopMessage,
|
|
308
|
+
status: err.status,
|
|
309
|
+
code: err.code,
|
|
310
|
+
type: err.type,
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
return { success: false, error: stopMessage, usageLimitReached: true };
|
|
314
|
+
}
|
|
270
315
|
// Check if we've exceeded max retries
|
|
271
316
|
if (retryCount >= MAX_RETRIES) {
|
|
272
317
|
emitDesktopDebug("reconnect.attempt", "device", {
|
|
@@ -23,6 +23,17 @@ function safeHeadersSnapshot(headers) {
|
|
|
23
23
|
}
|
|
24
24
|
return out;
|
|
25
25
|
}
|
|
26
|
+
function summarizeErrorCause(error) {
|
|
27
|
+
const cause = error?.cause;
|
|
28
|
+
if (!cause || typeof cause !== "object") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
name: typeof cause.name === "string" ? cause.name : null,
|
|
33
|
+
message: typeof cause.message === "string" ? cause.message : String(cause),
|
|
34
|
+
code: typeof cause.code === "string" ? cause.code : null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
26
37
|
function summarizeContent(content) {
|
|
27
38
|
if (typeof content === "string") {
|
|
28
39
|
return truncateForDebug(content, 1600);
|
|
@@ -394,6 +405,7 @@ export class CuaDebugTracer {
|
|
|
394
405
|
: null;
|
|
395
406
|
const requestId = err?.request_id || responseError?.request_id || requestIdFromHeaders || null;
|
|
396
407
|
const headers = safeHeadersSnapshot(err?.headers);
|
|
408
|
+
const provider = typeof responseError?.provider === "string" ? responseError.provider : null;
|
|
397
409
|
emitDesktopDebug("device.error", "device", trace.ids, {
|
|
398
410
|
localRequestId: trace.localRequestId,
|
|
399
411
|
operation: "cua.request",
|
|
@@ -403,8 +415,11 @@ export class CuaDebugTracer {
|
|
|
403
415
|
code: err?.code ?? responseError?.code ?? null,
|
|
404
416
|
type: responseError?.type ?? null,
|
|
405
417
|
param: responseError?.param ?? null,
|
|
418
|
+
provider,
|
|
406
419
|
requestId,
|
|
407
420
|
headers,
|
|
421
|
+
endpoint: trace.endpoint || null,
|
|
422
|
+
cause: summarizeErrorCause(err),
|
|
408
423
|
chain: {
|
|
409
424
|
lastResponseId: trace.lastResponseMeta?.id ?? null,
|
|
410
425
|
lastResponseOutputTypes: trace.lastResponseMeta?.outputTypes ?? [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "tsx index.js",
|
|
18
|
-
"bench": "
|
|
18
|
+
"bench": "node scripts/benchmark.js",
|
|
19
19
|
"bench:preflight": "node scripts/benchmark-preflight.js",
|
|
20
20
|
"bench:run": "tsx index.js --platform android --instructions benchmarks/benchmark.dcua --context benchmarks/context.md --debug",
|
|
21
21
|
"bench:report": "node scripts/benchmark-report.js",
|
|
22
|
+
"bench:report:web": "node scripts/benchmark-report-web.js",
|
|
22
23
|
"execution:profile": "node scripts/execution-profile.js",
|
|
23
24
|
"test": "node --test unit-tests/*.test.js",
|
|
24
25
|
"build": "tsc",
|