feat(ai-tools): 新增供应商与模型配置探针

This commit is contained in:
Syngnat
2026-06-08 20:59:15 +08:00
parent 4ac6a9e798
commit 5ce5d03d69
11 changed files with 331 additions and 1 deletions

View File

@@ -28,6 +28,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_database_bundle');
expect(markup).toContain('查看 AI 当前能力');
expect(markup).toContain('inspect_ai_runtime');
expect(markup).toContain('排查供应商与模型');
expect(markup).toContain('inspect_ai_providers');
expect(markup).toContain('排查 MCP 接入状态');
expect(markup).toContain('inspect_mcp_setup');
expect(markup).toContain('查看当前提示与 Skills');

View File

@@ -42,6 +42,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_ai_runtime → inspect_ai_context / inspect_current_connection',
description: '适合先确认当前模型、安全级别、上下文级别、Skills 和 MCP 工具,再决定让 AI 走哪条探针链路。',
},
{
title: '排查供应商与模型',
steps: 'inspect_ai_providers → inspect_ai_runtime',
description: '适合先确认当前到底配置了哪些供应商、哪个在生效、有没有缺密钥或没选模型,再解释为什么 AI 不能发送、为什么模型列表为空。',
},
{
title: '排查 MCP 接入状态',
steps: 'inspect_mcp_setup → inspect_ai_runtime',

View File

@@ -224,6 +224,60 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).toContain('"builtinToolCount":');
});
it('returns the current ai provider snapshot so the model can inspect provider readiness and model selection', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_ai_providers', {}),
connections: [buildConnection()],
mcpTools: [],
dynamicModels: ['gpt-5.4', 'gpt-4.1-mini'],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
getAIRuntimeState: vi.fn().mockResolvedValue({
activeProviderId: 'provider-1',
providers: [
{
id: 'provider-1',
type: 'openai',
name: 'OpenAI 主账号',
apiKey: '',
hasSecret: true,
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5.4',
models: ['gpt-5.4', 'gpt-4.1'],
maxTokens: 32000,
temperature: 0.2,
},
{
id: 'provider-2',
type: 'custom',
name: '自建代理',
apiKey: '',
hasSecret: false,
baseUrl: '',
model: '',
models: [],
headers: {
Authorization: 'Bearer secret-token',
},
maxTokens: 16000,
temperature: 0.7,
},
],
}),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"providerCount":2');
expect(result.content).toContain('"missingSecretCount":1');
expect(result.content).toContain('"name":"OpenAI 主账号"');
expect(result.content).toContain('"name":"自建代理"');
expect(result.content).toContain('"issues":["missing_secret","missing_base_url","missing_selected_model","missing_declared_models"]');
expect(result.content).not.toContain('secret-token');
});
it('returns the current mcp setup snapshot so the model can inspect configured servers and client install state', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_mcp_setup', {}),

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import type { AIProviderConfig } from '../../types';
import { buildAIProviderSnapshot } from './aiProviderInsights';
describe('aiProviderInsights', () => {
it('returns a sanitized provider snapshot with missing-secret and missing-model diagnostics', () => {
const providers: AIProviderConfig[] = [
{
id: 'provider-1',
type: 'openai',
name: 'OpenAI 主账号',
apiKey: '',
hasSecret: true,
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5.4',
models: ['gpt-5.4', 'gpt-4.1'],
maxTokens: 32000,
temperature: 0.2,
},
{
id: 'provider-2',
type: 'custom',
name: '自建代理',
apiKey: 'sk-secret',
hasSecret: false,
baseUrl: '',
model: '',
models: [],
headers: {
Authorization: 'Bearer secret-token',
},
apiFormat: 'openai',
maxTokens: 16000,
temperature: 0.7,
},
];
const snapshot = buildAIProviderSnapshot({
providers,
activeProviderId: 'provider-1',
dynamicModels: ['gpt-5.4', 'gpt-4.1-mini'],
});
expect(snapshot).toMatchObject({
hasActiveProvider: true,
providerCount: 2,
readyProviderCount: 1,
providersNeedingAttentionCount: 1,
missingSecretCount: 1,
missingSelectedModelCount: 1,
missingBaseUrlCount: 1,
dynamicModelCount: 2,
});
expect(snapshot.activeProvider).toMatchObject({
id: 'provider-1',
name: 'OpenAI 主账号',
baseUrlHost: 'api.openai.com',
status: 'ready',
});
expect(snapshot.providers[1]).toMatchObject({
id: 'provider-2',
hasSecret: false,
hasHeaders: true,
headerKeys: ['Authorization'],
issues: ['missing_secret', 'missing_base_url', 'missing_selected_model', 'missing_declared_models'],
status: 'needs_attention',
});
expect(snapshot.message).toContain('正在使用 OpenAI 主账号');
expect(JSON.stringify(snapshot)).not.toContain('apiKey');
expect(JSON.stringify(snapshot)).not.toContain('secret-token');
});
});

View File

@@ -0,0 +1,140 @@
import type { AIProviderConfig } from '../../types';
const DECLARED_MODEL_PREVIEW_LIMIT = 12;
const DYNAMIC_MODEL_PREVIEW_LIMIT = 20;
const sliceList = <T,>(items: T[], limit: number) => {
const list = Array.isArray(items) ? items : [];
return {
items: list.slice(0, limit),
truncated: list.length > limit,
total: list.length,
};
};
const trimText = (value: unknown): string => String(value || '').trim();
const hasProviderSecret = (provider: AIProviderConfig): boolean =>
provider.hasSecret ?? Boolean(provider.secretRef || provider.apiKey);
const getProviderHost = (baseUrl: string): string => {
const normalized = trimText(baseUrl);
if (!normalized) {
return '';
}
try {
return new URL(normalized).host;
} catch {
return '';
}
};
const buildProviderIssues = (provider: AIProviderConfig): string[] => {
const issues: string[] = [];
const hasSecret = hasProviderSecret(provider);
const baseUrl = trimText(provider.baseUrl);
const model = trimText(provider.model);
const declaredModels = Array.isArray(provider.models)
? provider.models.map((item) => trimText(item)).filter(Boolean)
: [];
if (!hasSecret) {
issues.push('missing_secret');
}
if (!baseUrl) {
issues.push('missing_base_url');
}
if (!model) {
issues.push('missing_selected_model');
}
if (declaredModels.length === 0) {
issues.push('missing_declared_models');
}
return issues;
};
export const buildAIProviderSnapshot = (params: {
providers?: AIProviderConfig[];
activeProviderId?: string | null;
dynamicModels?: string[];
}) => {
const providers = Array.isArray(params.providers) ? params.providers : [];
const activeProviderId = trimText(params.activeProviderId);
const dynamicModelPreview = sliceList(
(Array.isArray(params.dynamicModels) ? params.dynamicModels : [])
.map((item) => trimText(item))
.filter(Boolean),
DYNAMIC_MODEL_PREVIEW_LIMIT,
);
const providerSummaries = providers.map((provider) => {
const declaredModelPreview = sliceList(
(Array.isArray(provider.models) ? provider.models : [])
.map((item) => trimText(item))
.filter(Boolean),
DECLARED_MODEL_PREVIEW_LIMIT,
);
const headerKeys = Object.keys(provider.headers || {})
.map((item) => trimText(item))
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
const issues = buildProviderIssues(provider);
return {
id: provider.id,
name: trimText(provider.name),
type: provider.type,
apiFormat: trimText(provider.apiFormat) || 'openai',
active: provider.id === activeProviderId,
baseUrl: trimText(provider.baseUrl),
baseUrlHost: getProviderHost(provider.baseUrl),
model: trimText(provider.model),
declaredModelCount: declaredModelPreview.total,
declaredModels: declaredModelPreview.items,
declaredModelsTruncated: declaredModelPreview.truncated,
hasSecret: hasProviderSecret(provider),
hasHeaders: headerKeys.length > 0,
headerKeys,
maxTokens: Number(provider.maxTokens) || 0,
temperature: Number(provider.temperature) || 0,
issues,
issueCount: issues.length,
status: issues.length === 0 ? 'ready' : 'needs_attention',
};
});
const activeProvider = providerSummaries.find((provider) => provider.active) || null;
const providersNeedingAttention = providerSummaries.filter((provider) => provider.issueCount > 0);
const providerHosts = Array.from(
new Set(
providerSummaries
.map((provider) => provider.baseUrlHost)
.filter(Boolean),
),
).sort((left, right) => left.localeCompare(right));
return {
hasActiveProvider: Boolean(activeProvider),
activeProviderId,
activeProvider,
providerCount: providerSummaries.length,
readyProviderCount: providerSummaries.length - providersNeedingAttention.length,
providersNeedingAttentionCount: providersNeedingAttention.length,
missingSecretCount: providerSummaries.filter((provider) => provider.issues.includes('missing_secret')).length,
missingSelectedModelCount: providerSummaries.filter((provider) => provider.issues.includes('missing_selected_model')).length,
missingBaseUrlCount: providerSummaries.filter((provider) => provider.issues.includes('missing_base_url')).length,
providers: providerSummaries,
providerHosts,
dynamicModelCount: dynamicModelPreview.total,
dynamicModels: dynamicModelPreview.items,
dynamicModelsTruncated: dynamicModelPreview.truncated,
message: providerSummaries.length === 0
? '当前没有配置 AI 供应商'
: activeProvider
? activeProvider.issueCount > 0
? `当前正在使用 ${activeProvider.name || activeProvider.id},但还有 ${activeProvider.issueCount} 项待检查`
: `当前共配置 ${providerSummaries.length} 个供应商,正在使用 ${activeProvider.name || activeProvider.id}`
: `当前已配置 ${providerSummaries.length} 个供应商,但尚未选择活动供应商`,
};
};

View File

@@ -18,6 +18,7 @@ import { buildAIContextSnapshot } from './aiContextInsights';
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
import { buildMCPSetupSnapshot } from './aiMCPInsights';
import { buildAIGuidanceSnapshot } from './aiPromptInsights';
import { buildAIProviderSnapshot } from './aiProviderInsights';
import { buildAIRuntimeSnapshot } from './aiRuntimeInsights';
import {
buildSavedQueriesSnapshot,
@@ -108,6 +109,19 @@ export async function executeSnapshotInspectionToolCall(
success: true,
};
}
case 'inspect_ai_providers': {
const runtimeState = typeof runtime?.getAIRuntimeState === 'function'
? await runtime.getAIRuntimeState()
: undefined;
return {
content: JSON.stringify(buildAIProviderSnapshot({
providers: Array.isArray(runtimeState?.providers) ? runtimeState.providers : [],
activeProviderId: runtimeState?.activeProviderId || '',
dynamicModels,
})),
success: true,
};
}
case 'inspect_mcp_setup': {
const [mcpServers, mcpClientInstallStatuses] = await Promise.all([
typeof runtime?.getMCPServers === 'function' ? runtime.getMCPServers() : Promise.resolve(undefined),
@@ -210,6 +224,7 @@ export async function executeSnapshotInspectionToolCall(
} catch (error: any) {
const label = {
inspect_ai_runtime: '读取当前 AI 运行状态失败',
inspect_ai_providers: '读取当前 AI 供应商配置失败',
inspect_mcp_setup: '读取 MCP 配置状态失败',
inspect_ai_guidance: '读取当前 AI 提示与技能配置失败',
inspect_current_connection: '读取当前连接失败',

View File

@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
connections: [connections[0]],
tabs: [],
activeTabId: null,
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'],
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_providers', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'],
skills,
userPromptSettings,
});
@@ -76,6 +76,7 @@ describe('buildAISystemContextMessages', () => {
const joined = messages.map((message) => message.content).join('\n');
expect(joined).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(joined).toContain('inspect_ai_runtime 读取当前 AI 运行状态');
expect(joined).toContain('inspect_ai_providers 读取真实供应商配置');
expect(joined).toContain('inspect_mcp_setup 读取真实 MCP 配置');
expect(joined).toContain('inspect_ai_guidance 读取真实提示与技能配置');
expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文');

View File

@@ -108,6 +108,19 @@ const appendAIRuntimeInspectionGuidance = (
});
};
const appendAIProviderInspectionGuidance = (
messages: AISystemContextMessage[],
availableToolNames: string[],
) => {
if (!availableToolNames.includes('inspect_ai_providers')) {
return;
}
messages.push({
role: 'system',
content: '如果用户提到“当前配了哪些供应商”“为什么模型列表为空”“API Key 有没有配”“为什么现在不能发送/没选中模型”,优先调用 inspect_ai_providers 读取真实供应商配置,不要凭记忆猜测。',
});
};
const appendMCPSetupInspectionGuidance = (
messages: AISystemContextMessage[],
availableToolNames: string[],
@@ -351,6 +364,7 @@ SELECT * FROM users WHERE status = 1;
});
}
appendAIRuntimeInspectionGuidance(systemMessages, availableToolNames);
appendAIProviderInspectionGuidance(systemMessages, availableToolNames);
appendMCPSetupInspectionGuidance(systemMessages, availableToolNames);
appendAIGuidanceInspectionGuidance(systemMessages, availableToolNames);
if (availableToolNames.includes('inspect_current_connection')) {

View File

@@ -24,6 +24,7 @@ interface AIToolCallingBlockProps {
const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_ai_runtime: '读取当前 AI 运行状态',
inspect_ai_providers: '读取当前 AI 供应商与模型配置',
inspect_mcp_setup: '读取当前 MCP 配置状态',
inspect_ai_guidance: '读取当前 AI 提示与技能配置',
get_connections: '获取可用连接信息',

View File

@@ -17,6 +17,13 @@ describe('aiToolRegistry', () => {
expect(info?.tool.function.description).toContain('外部客户端');
});
it('registers the ai-provider inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_providers');
expect(info).toBeTruthy();
expect(info?.desc).toContain('供应商与模型配置');
expect(info?.tool.function.description).toContain('模型列表为空');
});
it('registers the ai-guidance inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_guidance');
expect(info).toBeTruthy();
@@ -58,6 +65,7 @@ describe('aiToolRegistry', () => {
}]);
expect(tools.some((item) => item.function.name === 'inspect_ai_runtime')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_providers')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_setup')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_guidance')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true);

View File

@@ -326,6 +326,23 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_ai_providers",
icon: "🪪",
desc: "查看当前 AI 供应商与模型配置",
detail:
"返回当前配置了哪些 AI 供应商、哪个正在生效、各自的 baseUrl、已选模型、声明模型列表、密钥是否存在、自定义请求头 key以及缺少密钥/模型/地址等待检查项。适合用户问“为什么没有模型”“API Key 有没有配”“当前到底配了哪些供应商”时先读真实配置。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_providers",
description:
"读取当前 AI 供应商配置快照,包括供应商列表、活动供应商、接口地址、已选模型、声明模型列表、是否存在密钥、自定义请求头 key以及缺少密钥/模型/地址等待检查项。适用于用户提到当前供应商、模型列表为空、API Key 是否配置、为什么 AI 不能正常发起请求时,先读取真实配置再解释。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_mcp_setup",
icon: "🪛",