@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 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. Add your credentials**
49
+ **3. Choose your AI provider**
50
50
 
51
- Set your OpenAI API key in the app Settings screen, or log in with your Loadmill account.
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
- return resolveCuaModel(process.env.OPENAI_CUA_MODEL);
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: ' --cua-model <model> Headless CUA model override: gpt-5.4 or gpt-5.4-mini' });
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 { createOpenAiClient, resolveRuntimeCredentialsFromEnv } from "../core/runtime-config.js";
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
- const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
52
- const loadmillClient = context.loadmillClient || createLoadmillApiClient({
53
- token: runtimeCredentials.loadmillApiToken,
54
- baseUrl: runtimeCredentials.loadmillBaseUrl,
55
- });
56
- const openAiClient = context.openAiClient || createOpenAiClient(runtimeCredentials.openAiApiKey);
57
- // Execute the command
58
- const result = await executeLoadmillCommand(command, {
59
- loadmillClient,
60
- openAiClient,
61
- onProgress: ({ message }) => {
62
- addOutput({ type: 'info', text: `[Loadmill] ${message}` });
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
- // Clear agent working status
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
- else {
77
- addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
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 CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
2
- export const DEFAULT_CUA_MODEL = "gpt-5.4";
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
- 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
  }
@@ -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
  });
@@ -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.15.1",
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": "npm run bench:preflight && npm run bench:run && npm run bench:report",
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",