Skip to content

Commit 81f4d5d

Browse files
committed
feat(cli): add model check command
Add model check with dotenv loading and core capability verification. Print formatted failures and generated curl requests for API debugging. Update runtime callers and docs for the model connectivity workflow.
1 parent ed14465 commit 81f4d5d

13 files changed

Lines changed: 584 additions & 45 deletions

File tree

apps/chrome-extension/src/extension/recorder/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ export const generateRecordTitle = async (
321321
const response = await callAIWithObjectResponse<{
322322
title: string;
323323
description: string;
324-
}>([prompt[0], prompt[1]], modelConfig);
324+
}>([prompt[0], prompt[1]], getModelRuntime(modelConfig));
325325
if (response?.content) {
326326
return {
327327
title: response.content.title as string,
Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
## Troubleshooting model service connectivity issues
22

3-
If you want to troubleshoot connectivity issues, you can use the 'connectivity-test' folder in our example project: [https://github.com/web-infra-dev/midscene-example/tree/main/connectivity-test](https://github.com/web-infra-dev/midscene-example/tree/main/connectivity-test)
3+
Midscene includes a built-in model check command for troubleshooting model service connectivity issues and basic compatibility issues.
44

5-
Put your `.env` file in the `connectivity-test` folder, and run the test with `npm i && npm run test`.
5+
Put your model configuration in a `.env` file, then run the following model check command to verify whether the current model configuration can support Midscene:
6+
7+
```bash
8+
# If the current project has @midscene/cli installed, use the local midscene command
9+
npx midscene model check
10+
11+
# If the current project does not have @midscene/cli installed, or you want to use the latest version
12+
npx @midscene/cli@latest model check
13+
```
14+
15+
The command reads the `.env` file in the current working directory. Dotenv debug logging is enabled by default, and values from `.env` do not override existing shell environment variables.
16+
17+
To isolate basic model service connectivity issues, you can also run the following minimal `curl` request.
18+
19+
```bash
20+
MIDSCENE_MODEL_BASE_URL='replace with your baseUrl'
21+
MIDSCENE_MODEL_API_KEY='replace with your API key'
22+
MIDSCENE_MODEL_NAME='replace with your model name'
23+
24+
curl -X POST "${MIDSCENE_MODEL_BASE_URL%/}/chat/completions" \
25+
-H "Authorization: Bearer ${MIDSCENE_MODEL_API_KEY}" \
26+
-H "Content-Type: application/json" \
27+
-d '{
28+
"model": "'"${MIDSCENE_MODEL_NAME}"'",
29+
"messages": [
30+
{
31+
"role": "user",
32+
"content": "What is 1+1?"
33+
}
34+
]
35+
}'
36+
```
Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
## 模型服务连接问题排查
22

3-
如果你想排查模型服务的连通性问题,可以使用我们示例项目中的 'connectivity-test' 文件夹:[https://github.com/web-infra-dev/midscene-example/tree/main/connectivity-test](https://github.com/web-infra-dev/midscene-example/tree/main/connectivity-test)
3+
Midscene 内置了一个模型检查命令,用于排查模型服务的连通性问题和基础的兼容性问题。
44

5-
将你的 `.env` 文件放在 `connectivity-test` 文件夹中,然后运行 `npm i && npm run test` 来进行测试。
5+
将你的模型配置放在 `.env` 文件中,然后运行下面的模型检查命令,验证当前模型配置是否能支撑 Midscene 正常运行:
6+
7+
```bash
8+
# 如果当前项目已安装 @midscene/cli,可以使用本地的 midscene 命令
9+
npx midscene model check
10+
11+
# 如果当前项目未安装 @midscene/cli,或想要使用最新版
12+
npx @midscene/cli@latest model check
13+
```
14+
15+
这个命令会读取当前工作目录下的 `.env` 文件,同时打开 Dotenv 的 debug 日志,且 `.env` 中的变量不会覆盖已有的 shell 环境变量。
16+
17+
为了单独排查模型服务的基础连接性问题,你也可以直接运行下面这段最小化的 `curl` 请求。
18+
19+
```bash
20+
MIDSCENE_MODEL_BASE_URL='替换为你的 baseUrl'
21+
MIDSCENE_MODEL_API_KEY='替换为你的 API Key'
22+
MIDSCENE_MODEL_NAME='替换为你的 model name'
23+
24+
curl -X POST "${MIDSCENE_MODEL_BASE_URL%/}/chat/completions" \
25+
-H "Authorization: Bearer ${MIDSCENE_MODEL_API_KEY}" \
26+
-H "Content-Type: application/json" \
27+
-d '{
28+
"model": "'"${MIDSCENE_MODEL_NAME}"'",
29+
"messages": [
30+
{
31+
"role": "user",
32+
"content": "What is 1+1?"
33+
}
34+
]
35+
}'
36+
```

packages/cli/src/dotenv-loader.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { existsSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import dotenv from 'dotenv';
4+
5+
export interface DotenvLoadOptions {
6+
dotenvOverride?: boolean;
7+
dotenvDebug?: boolean;
8+
cwd?: string;
9+
log?: (message: string) => void;
10+
}
11+
12+
export function loadDotenvConfig(options: DotenvLoadOptions = {}) {
13+
const dotEnvConfigFile = join(options.cwd ?? process.cwd(), '.env');
14+
if (!existsSync(dotEnvConfigFile)) {
15+
return;
16+
}
17+
18+
options.log?.(` Env file: ${dotEnvConfigFile}`);
19+
dotenv.config({
20+
path: dotEnvConfigFile,
21+
debug: options.dotenvDebug,
22+
override: options.dotenvOverride,
23+
});
24+
}

packages/cli/src/index.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { existsSync } from 'node:fs';
2-
import { join } from 'node:path';
31
import { createReportCliCommands } from '@midscene/core';
42
import type { BaseMidsceneTools } from '@midscene/shared/agent-tools/base-tools';
53
import { runToolsCLI } from '@midscene/shared/cli';
6-
import dotenv from 'dotenv';
74
import { version } from '../package.json';
85
import { matchYamlFiles, parseProcessArgs } from './cli-utils';
96
import { createConfig, createFilesConfig } from './config-factory';
7+
import { loadDotenvConfig } from './dotenv-loader';
108
import { runFrameworkTestConfig } from './framework';
9+
import { runModelCommand } from './model-command';
1110

1211
Promise.resolve(
1312
(async () => {
@@ -30,6 +29,11 @@ Promise.resolve(
3029
return;
3130
}
3231

32+
if (firstArg === 'model') {
33+
const exitCode = await runModelCommand(rawArgs);
34+
process.exit(exitCode);
35+
}
36+
3337
const { options, path, files: cmdFiles } = await parseProcessArgs();
3438

3539
const welcome = `\nWelcome to @midscene/cli v${version}\n`;
@@ -90,15 +94,11 @@ Promise.resolve(
9094
process.exit(1);
9195
}
9296

93-
const dotEnvConfigFile = join(process.cwd(), '.env');
94-
if (existsSync(dotEnvConfigFile)) {
95-
console.log(` Env file: ${dotEnvConfigFile}`);
96-
dotenv.config({
97-
path: dotEnvConfigFile,
98-
debug: config.dotenvDebug,
99-
override: config.dotenvOverride,
100-
});
101-
}
97+
loadDotenvConfig({
98+
dotenvDebug: config.dotenvDebug,
99+
dotenvOverride: config.dotenvOverride,
100+
log: console.log,
101+
});
102102

103103
const exitCode = await runFrameworkTestConfig(config);
104104

packages/cli/src/model-command.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { runConnectivityTest } from '@midscene/core';
2+
import {
3+
type IModelConfig,
4+
type TIntent,
5+
globalModelConfigManager,
6+
} from '@midscene/shared/env';
7+
import chalk from 'chalk';
8+
import { loadDotenvConfig } from './dotenv-loader';
9+
10+
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
11+
const MODEL_CHECK_SEPARATOR = '────────────────────────────────────────';
12+
const MODEL_COMMAND_USAGE = `Usage:
13+
midscene model check
14+
midscene model eval
15+
`;
16+
17+
interface ModelCommandIO {
18+
stdout: (message: string) => void;
19+
stderr: (message: string) => void;
20+
}
21+
22+
interface ModelCommandDeps {
23+
loadDotenv: () => void;
24+
getModelConfig: (intent: TIntent) => IModelConfig;
25+
checkModel: typeof runConnectivityTest;
26+
}
27+
28+
export interface CurlCommandItem {
29+
intents: TIntent[];
30+
curl: string;
31+
usesDefaultBaseURL: boolean;
32+
}
33+
34+
function assertNoModelCheckOptions(args: string[]) {
35+
for (const arg of args) {
36+
throw new Error(`Unknown option for midscene model check: ${arg}`);
37+
}
38+
}
39+
40+
function shellSingleQuote(value: string): string {
41+
return `'${value.replace(/'/g, `'\\''`)}'`;
42+
}
43+
44+
function buildChatCompletionsUrl(baseURL?: string): string {
45+
const normalizedBaseURL = (baseURL || DEFAULT_OPENAI_BASE_URL).replace(
46+
/\/+$/,
47+
'',
48+
);
49+
if (normalizedBaseURL.endsWith('/chat/completions')) {
50+
return normalizedBaseURL;
51+
}
52+
return `${normalizedBaseURL}/chat/completions`;
53+
}
54+
55+
function buildCurlCommand(modelConfig: IModelConfig): string {
56+
const payload = {
57+
model: modelConfig.modelName,
58+
messages: [{ role: 'user', content: 'What is 1+1?' }],
59+
};
60+
61+
return [
62+
`curl -X POST ${shellSingleQuote(buildChatCompletionsUrl(modelConfig.openaiBaseURL))} \\`,
63+
` -H ${shellSingleQuote(`Authorization: Bearer ${modelConfig.openaiApiKey || ''}`)} \\`,
64+
` -H ${shellSingleQuote('Content-Type: application/json')} \\`,
65+
` -d ${shellSingleQuote(JSON.stringify(payload, null, 2))}`,
66+
].join('\n');
67+
}
68+
69+
function buildCurlDedupKey(modelConfig: IModelConfig): string {
70+
return JSON.stringify({
71+
baseURL: modelConfig.openaiBaseURL || DEFAULT_OPENAI_BASE_URL,
72+
apiKey: modelConfig.openaiApiKey || '',
73+
modelName: modelConfig.modelName,
74+
});
75+
}
76+
77+
export function buildModelCheckCurlCommands(
78+
configs: Array<{ intent: TIntent; modelConfig: IModelConfig }>,
79+
): CurlCommandItem[] {
80+
const commandMap = new Map<string, CurlCommandItem>();
81+
82+
for (const item of configs) {
83+
const key = buildCurlDedupKey(item.modelConfig);
84+
const existing = commandMap.get(key);
85+
if (existing) {
86+
existing.intents.push(item.intent);
87+
continue;
88+
}
89+
commandMap.set(key, {
90+
intents: [item.intent],
91+
curl: buildCurlCommand(item.modelConfig),
92+
usesDefaultBaseURL: !item.modelConfig.openaiBaseURL,
93+
});
94+
}
95+
96+
return [...commandMap.values()];
97+
}
98+
99+
function formatModelCheckFailureOutput(
100+
message: string | undefined,
101+
curlCommands: CurlCommandItem[],
102+
): string {
103+
const details = message?.trim() || 'No failure details were generated.';
104+
const curlSection = curlCommands
105+
.map((item) => {
106+
const baseUrlNote = item.usesDefaultBaseURL
107+
? ' (base URL not configured; using OpenAI SDK default)'
108+
: '';
109+
return chalk.gray(
110+
`# ${item.intents.join(', ')}${baseUrlNote}\n${item.curl}`,
111+
);
112+
})
113+
.join('\n\n');
114+
115+
return [
116+
chalk.red.bold('❌ Model check failed with messages:'),
117+
MODEL_CHECK_SEPARATOR,
118+
'',
119+
details,
120+
'',
121+
MODEL_CHECK_SEPARATOR,
122+
'Generated curl requests for basic API connectivity:',
123+
'If the error is a basic connectivity issue, use these requests to test the base URL, API key, and model name directly.',
124+
'These commands contain your API key. Do not share them publicly.',
125+
'',
126+
curlSection,
127+
].join('\n');
128+
}
129+
130+
function getDefaultDeps(io: ModelCommandIO): ModelCommandDeps {
131+
return {
132+
loadDotenv: () => {
133+
loadDotenvConfig({
134+
dotenvDebug: true,
135+
dotenvOverride: false,
136+
log: io.stdout,
137+
});
138+
},
139+
getModelConfig: (intent) => globalModelConfigManager.getModelConfig(intent),
140+
checkModel: runConnectivityTest,
141+
};
142+
}
143+
144+
async function runModelCheckCommand(
145+
args: string[],
146+
deps: ModelCommandDeps,
147+
io: ModelCommandIO,
148+
): Promise<number> {
149+
try {
150+
if (args.includes('--help') || args.includes('-h')) {
151+
io.stdout(MODEL_COMMAND_USAGE);
152+
return 0;
153+
}
154+
155+
assertNoModelCheckOptions(args);
156+
io.stdout('Model check started. This usually takes about 5 seconds.\n');
157+
deps.loadDotenv();
158+
io.stdout('');
159+
160+
const defaultModelConfig = deps.getModelConfig('default');
161+
const planningModelConfig = deps.getModelConfig('planning');
162+
const insightModelConfig = deps.getModelConfig('insight');
163+
const curlCommands = buildModelCheckCurlCommands([
164+
{ intent: 'default', modelConfig: defaultModelConfig },
165+
{ intent: 'planning', modelConfig: planningModelConfig },
166+
{ intent: 'insight', modelConfig: insightModelConfig },
167+
]);
168+
169+
const result = await deps.checkModel({
170+
defaultModelConfig,
171+
planningModelConfig,
172+
insightModelConfig,
173+
});
174+
175+
if (result.passed) {
176+
io.stdout('✅ Model check passed.');
177+
return 0;
178+
}
179+
180+
io.stderr(formatModelCheckFailureOutput(result.message, curlCommands));
181+
return 1;
182+
} catch (error) {
183+
const message = error instanceof Error ? error.message : String(error);
184+
io.stderr(
185+
[
186+
MODEL_CHECK_SEPARATOR,
187+
'❌ Model check failed with messages:',
188+
'',
189+
message,
190+
MODEL_CHECK_SEPARATOR,
191+
].join('\n'),
192+
);
193+
return 1;
194+
}
195+
}
196+
197+
export async function runModelCommand(
198+
rawArgs: string[],
199+
deps?: Partial<ModelCommandDeps>,
200+
io: ModelCommandIO = {
201+
stdout: console.log,
202+
stderr: console.error,
203+
},
204+
): Promise<number> {
205+
const [, action, ...restArgs] = rawArgs;
206+
const mergedDeps = {
207+
...getDefaultDeps(io),
208+
...deps,
209+
};
210+
211+
if (!action || action === '--help' || action === '-h') {
212+
io.stdout(MODEL_COMMAND_USAGE);
213+
return 0;
214+
}
215+
216+
if (action === 'check') {
217+
return runModelCheckCommand(restArgs, mergedDeps, io);
218+
}
219+
220+
if (action === 'eval') {
221+
io.stderr(
222+
'midscene model eval is not implemented yet. It is reserved for future model evaluation suites.',
223+
);
224+
return 1;
225+
}
226+
227+
io.stderr(
228+
`Unknown midscene model command: ${action}\n\n${MODEL_COMMAND_USAGE}`,
229+
);
230+
return 1;
231+
}

0 commit comments

Comments
 (0)