feat(ai): add Claude Code CLI provider

This commit is contained in:
Guoyu Su 2026-07-17 01:17:06 +08:00 committed by GitHub
parent 89fca90dbc
commit d3947d246c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1723 additions and 176 deletions

View File

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@ -54,6 +54,8 @@ import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
@ -229,12 +231,14 @@ const modelSearchQuery = ref("");
// Configured providers for quick switching - get from aiConfigs
const configuredProviders = computed(() => {
const providers = settings.aiConfigs.filter((c) => {
// Check directly if config has required fields
const preset = AI_PROVIDER_PRESETS[c.provider];
if (c.provider === "codex-cli") return true;
return !!c.endpoint?.trim() && !!c.model?.trim() && (!preset.requiresApiKey || !!c.apiKey?.trim());
});
const providers = orderAiConfigsForDisplay(
settings.aiConfigs.filter((c) => {
// Check directly if config has required fields
const preset = AI_PROVIDER_PRESETS[c.provider];
if (c.provider === "codex-cli" || c.provider === "claude-code-cli") return true;
return !!c.endpoint?.trim() && !!c.model?.trim() && (!preset.requiresApiKey || !!c.apiKey?.trim());
}),
);
// Apply search filter - hide providers with no matching models
if (modelSearchQuery.value.trim()) {
const query = modelSearchQuery.value.trim().toLowerCase();
@ -250,7 +254,15 @@ const activeFullConfig = computed(() => {
if (!settings.activeModel) return null;
const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId);
if (!item) return null;
return normalizeAiConfig({ ...item, model: settings.activeModel!.modelId });
const modelId = settings.activeModel.modelId;
const config = normalizeAiConfig({ ...item, model: modelId });
if (config.provider === "claude-code-cli") {
config.reasoningLevel = normalizeClaudeCodeReasoningLevel(
config.reasoningLevel,
item.models?.find((model) => model.name === modelId),
);
}
return config;
});
function getModelsForConfig(configId: string): string[] {

View File

@ -32,6 +32,8 @@ import {
type AiProvider,
type AiApiStyle,
type AiAuthMethod,
type AiConfiguredModel,
type AiEffortLevel,
type AiReasoningLevel,
type EditorTheme,
type DesktopIconTheme,
@ -44,6 +46,8 @@ import {
type CustomTheme,
} from "@/stores/settingsStore";
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
import DangerConfirmDialog from "./DangerConfirmDialog.vue";
@ -74,6 +78,7 @@ import {
webdavSyncTest,
webdavSyncUpload,
type AppSupportInfo,
type AiModelInfo,
type McpServerStatus,
type SnippetProvider,
type SnippetSyncConfig,
@ -1821,7 +1826,7 @@ watch(
await refreshSnippetTokenStatus();
syncAiEditState();
if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus();
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
if (activeSettingsTab.value === "about") void refreshAppSupportInfo();
await scrollToInitialSettingsSection();
}
@ -1865,7 +1870,7 @@ watch(snippetProvider, (provider) => {
watch(activeSettingsTab, (tab) => {
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
if (tab === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo();
if (tab === "appearance") {
checkLayoutDescTruncation();
@ -1925,22 +1930,24 @@ async function changePassword() {
const aiConfigListMode = ref<"list" | "edit">("list");
const aiEditConfigName = ref("");
const aiEditConfigId = ref<string | null>(null);
const displayedAiConfigs = computed(() => orderAiConfigsForDisplay(settingsStore.aiConfigs));
// AI Config Delete Confirmation
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
// Model list management
const aiEditModels = ref<Array<{ name: string; label?: string }>>([]);
const aiEditModels = ref<AiConfiguredModel[]>([]);
const aiModelListLoading = ref(false);
let aiModelListRequestToken = 0;
// AI Model Multi-Select
const aiModelMultiSelectOpen = ref(false);
const aiModelMultiSelectSearch = ref("");
const aiFetchedModels = ref<Array<{ id: string; displayName?: string }>>([]);
const aiFetchedModels = ref<AiModelInfo[]>([]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || provider.provider !== "codex-cli"));
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli"]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
const aiEditProvider = ref<AiProvider>("claude");
@ -1956,18 +1963,27 @@ const aiEditReasoningLevel = ref<AiReasoningLevel>("default");
const aiEditContextWindow = ref<number | undefined>(undefined);
const aiEditCodexCliPath = ref("");
const aiEditCodexCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditClaudeCodeCliPath = ref("");
const aiEditClaudeCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiModelError = ref("");
const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions");
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
const aiReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [
const codexReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [
{ value: "default", labelKey: "ai.reasoningLevelDefault" },
{ value: "minimal", labelKey: "ai.reasoningLevelMinimal" },
{ value: "low", labelKey: "ai.reasoningLevelLow" },
{ value: "medium", labelKey: "ai.reasoningLevelMedium" },
{ value: "high", labelKey: "ai.reasoningLevelHigh" },
];
const effortLevelLabelKeys: Record<AiEffortLevel, string> = {
low: "ai.reasoningLevelLow",
medium: "ai.reasoningLevelMedium",
high: "ai.reasoningLevelHigh",
xhigh: "ai.reasoningLevelXhigh",
max: "ai.reasoningLevelMax",
};
const aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
@ -1975,8 +1991,24 @@ const aiTestError = ref("");
const aiTestLatency = ref<number | null>(null);
const aiTestErrorCopied = ref(false);
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
watch(aiIsCodexCli, (isCodex) => {
if (isCodex) void ensureCodexMcpStatus();
const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli");
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => (aiIsClaudeCodeCli.value ? "claude" : "codex"));
const aiCliLoginCommand = computed(() => (aiIsClaudeCodeCli.value ? "claude auth login" : "codex login"));
const aiEditCliPath = computed({
get: () => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliPath.value : aiEditCodexCliPath.value),
set: (value: string) => {
if (aiIsClaudeCodeCli.value) {
aiEditClaudeCodeCliPath.value = value;
} else {
aiEditCodexCliPath.value = value;
}
},
});
const aiEditCliEnvRows = computed(() => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliEnvRows.value : aiEditCodexCliEnvRows.value));
watch(aiIsCliProvider, (isCliProvider) => {
if (isCliProvider) void ensureCliMcpStatus();
});
const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey);
const aiSupportsAuthMethod = computed(() => aiEditProvider.value === "claude" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value));
@ -2004,35 +2036,35 @@ const aiEndpointHint = computed(() => {
}
return "";
});
const aiSupportsApiStyle = computed(() => !aiIsCodexCli.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom"));
const aiSupportsApiStyle = computed(() => !aiIsCliProvider.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom"));
const aiSupportsAnthropicApiStyle = computed(() => aiEditProvider.value === "custom");
const aiCodexMcpNeedsInstall = computed(() => aiIsCodexCli.value && (!mcpStatus.value || !mcpStatus.value.installed));
const aiCodexMcpCanInstall = computed(() => {
const aiCliMcpNeedsInstall = computed(() => aiIsCliProvider.value && (!mcpStatus.value || !mcpStatus.value.installed));
const aiCliMcpCanInstall = computed(() => {
const status = mcpStatus.value;
return !mcpInstalling.value && !!status?.npm_available && (!status.installed || status.update_available);
});
const aiCodexMcpActionLabel = computed(() => {
const aiCliMcpActionLabel = computed(() => {
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
if (mcpStatus.value.update_available) return t("settings.mcpUpdateButton");
return t("settings.mcpUpToDate");
});
const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini");
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCodexCli.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
const aiCodexEnvError = computed(() => codexEnvValidationError());
const aiCodexPathError = computed(() => {
const path = aiEditCodexCliPath.value.trim();
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCliProvider.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
const aiCliEnvError = computed(() => cliEnvValidationError());
const aiCliPathError = computed(() => {
const path = aiEditCliPath.value.trim();
const firstToken = path.split(/\s+/)[0] || "";
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(firstToken) ? t("ai.codexCliPathEnvError") : "";
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(firstToken) ? t("ai.cliPathEnvError", { provider: aiCliProviderLabel.value }) : "";
});
const aiCodexValidationError = computed(() => (aiIsCodexCli.value ? aiCodexPathError.value || aiCodexEnvError.value : ""));
const aiCliValidationError = computed(() => (aiIsCliProvider.value ? aiCliPathError.value || aiCliEnvError.value : ""));
function aiEnvRowsFromConfig(env: unknown): AiEnvRow[] {
return Object.entries(normalizeAiEnv(env)).map(([key, value]) => ({ id: uuid(), key, value }));
}
function codexEnvFromRows(): Record<string, string> {
function cliEnvFromRows(rows = aiEditCliEnvRows.value): Record<string, string> {
const result: Record<string, string> = {};
for (const row of aiEditCodexCliEnvRows.value) {
for (const row of rows) {
const key = row.key.trim();
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.toUpperCase().startsWith("DBX_MCP_")) continue;
result[key] = row.value;
@ -2040,24 +2072,29 @@ function codexEnvFromRows(): Record<string, string> {
return result;
}
function codexEnvValidationError(): string {
for (const row of aiEditCodexCliEnvRows.value) {
function cliEnvValidationError(): string {
for (const row of aiEditCliEnvRows.value) {
const key = row.key.trim();
if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.codexCliEnvInvalidName", { name: key });
if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.codexCliEnvReservedName", { name: key });
if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key });
if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.cliEnvReservedName", { name: key });
}
return "";
}
function addCodexEnvRow() {
aiEditCodexCliEnvRows.value.push({ id: uuid(), key: "", value: "" });
function addCliEnvRow() {
aiEditCliEnvRows.value.push({ id: uuid(), key: "", value: "" });
}
function removeCodexEnvRow(id: string) {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
function removeCliEnvRow(id: string) {
if (aiIsClaudeCodeCli.value) {
aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id);
} else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
}
}
function currentAiEditConfig() {
const reasoningLevel = aiIsClaudeCodeCli.value ? normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value) : aiEditReasoningLevel.value;
return {
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value,
@ -2068,10 +2105,12 @@ function currentAiEditConfig() {
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
reasoningLevel: aiEditReasoningLevel.value,
reasoningLevel,
contextWindow: aiEditContextWindow.value || undefined,
codexCliPath: aiEditCodexCliPath.value.trim() || undefined,
codexCliEnv: aiIsCodexCli.value ? codexEnvFromRows() : {},
codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {},
claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim() || undefined,
claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {},
};
}
@ -2083,7 +2122,7 @@ function syncAiEditState() {
}
function aiSelectProvider(provider: AiProvider) {
if (isWeb && provider === "codex-cli") return;
if (isWeb && CLI_AI_PROVIDERS.has(provider)) return;
if (provider === aiEditProvider.value) return;
// Apply new provider's preset defaults to edit state
@ -2094,9 +2133,14 @@ function aiSelectProvider(provider: AiProvider) {
aiEditEndpoint.value = preset.endpoint;
aiEditModel.value = preset.model;
aiEditApiStyle.value = preset.apiStyle;
aiEditReasoningLevel.value = "default";
aiEditModels.value = [];
aiFetchedModels.value = [];
if (provider === "codex-cli") void ensureCodexMcpStatus();
aiLastModelFetchSignature = "";
aiModelListRequestToken += 1;
aiModelListLoading.value = false;
if (CLI_AI_PROVIDERS.has(provider)) void ensureCliMcpStatus();
if (provider === "claude-code-cli") void aiFetchModelList();
}
function aiSelectApiStyle(style: AiApiStyle) {
@ -2132,7 +2176,9 @@ function aiEnterEditMode(configId?: string) {
aiEditContextWindow.value = config.contextWindow;
aiEditCodexCliPath.value = config.codexCliPath ?? "";
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv);
aiEditModels.value = config.models ? [...config.models] : [];
aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? "";
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
aiEditModels.value = config.models ? config.models.map((model) => ({ ...model, supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined })) : [];
}
} else {
aiEditConfigName.value = "";
@ -2150,7 +2196,12 @@ function aiEnterEditMode(configId?: string) {
aiEditContextWindow.value = undefined;
aiEditCodexCliPath.value = "";
aiEditCodexCliEnvRows.value = [];
aiEditClaudeCodeCliPath.value = "";
aiEditClaudeCodeCliEnvRows.value = [];
}
aiFetchedModels.value = [];
aiLastModelFetchSignature = "";
if (aiIsClaudeCodeCli.value) void aiFetchModelList();
}
function aiAddModel() {
@ -2165,6 +2216,7 @@ async function aiFetchModelList() {
if (aiModelListLoading.value) return;
if (!aiCanListModels.value) return;
const token = ++aiModelListRequestToken;
const signature = aiModelFetchSignature.value;
aiModelListLoading.value = true;
aiModelError.value = "";
try {
@ -2181,18 +2233,26 @@ async function aiFetchModelList() {
reasoningLevel: aiEditReasoningLevel.value,
contextWindow: aiEditContextWindow.value,
codexCliPath: aiEditCodexCliPath.value,
codexCliEnv: codexEnvFromRows(),
codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value),
claudeCodeCliPath: aiEditClaudeCodeCliPath.value,
claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value),
});
if (token !== aiModelListRequestToken) return;
const seen = new Set<string>();
aiFetchedModels.value = models
.filter((m: any) => {
const id = m.id?.trim();
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
})
.map((m: any) => ({ id: m.id.trim(), displayName: m.displayName?.trim() || undefined }));
aiFetchedModels.value = normalizeAiModelOptions(models);
aiLastModelFetchSignature = signature;
const fetchedById = new Map(aiFetchedModels.value.map((model) => [model.id, model]));
aiEditModels.value = aiEditModels.value.map((model) => {
const fetched = fetchedById.get(model.name);
if (!fetched) return model;
return {
name: model.name,
label: fetched.displayName || model.label,
supportedEffortLevels: fetched.supportedEffortLevels,
};
});
if (aiIsClaudeCodeCli.value) {
aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value);
}
} catch (e: any) {
if (token === aiModelListRequestToken) {
aiModelError.value = e?.message || String(e);
@ -2206,12 +2266,16 @@ function aiIsModelSelected(modelId: string): boolean {
return aiEditModels.value.some((m) => m.name === modelId);
}
function aiToggleModel(model: { id: string; displayName?: string }) {
function aiToggleModel(model: AiModelInfo) {
const index = aiEditModels.value.findIndex((m) => m.name === model.id);
if (index >= 0) {
aiEditModels.value.splice(index, 1);
} else {
aiEditModels.value.push({ name: model.id, label: model.displayName || model.id });
aiEditModels.value.push({
name: model.id,
label: model.displayName || model.id,
supportedEffortLevels: normalizeAiModelEffortLevels(model.supportedEffortLevels),
});
}
}
@ -2228,16 +2292,97 @@ const aiModelFetchSignature = computed(() =>
apiKey: aiEditApiKey.value.trim(),
authMethod: aiEditAuthMethod.value,
apiStyle: aiEditApiStyle.value,
codexCliPath: aiEditCodexCliPath.value.trim(),
codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value),
claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim(),
claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value),
}),
);
let aiLastModelFetchSignature = "";
function normalizeAiModelOptions(models: AiModelInfo[]): AiModelInfo[] {
const seen = new Set<string>();
const normalized: AiModelInfo[] = [];
for (const model of models) {
const id = model.id?.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels);
normalized.push({
id,
displayName: model.displayName?.trim() || undefined,
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
});
}
return normalized;
}
const aiSelectedModelInfo = computed<AiModelInfo | undefined>(() => {
if (aiModelFetchSignature.value === aiLastModelFetchSignature) {
const fetched = aiFetchedModels.value.find((model) => model.id === aiEditModel.value);
if (fetched) return fetched;
}
const configured = aiEditModels.value.find((model) => model.name === aiEditModel.value);
if (!configured) return undefined;
return {
id: configured.name,
displayName: configured.label,
supportedEffortLevels: configured.supportedEffortLevels,
};
});
const aiClaudeCodeEffortLevels = computed(() => normalizeAiModelEffortLevels(aiSelectedModelInfo.value?.supportedEffortLevels));
const aiReasoningLevelOptions = computed<Array<{ value: AiReasoningLevel; labelKey: string }>>(() => {
if (!aiIsClaudeCodeCli.value) return codexReasoningLevelOptions;
return [{ value: "default", labelKey: "ai.reasoningLevelDefault" }, ...aiClaudeCodeEffortLevels.value.map((value) => ({ value, labelKey: effortLevelLabelKeys[value] }))];
});
const aiReasoningLevelDisabled = computed(() => aiIsClaudeCodeCli.value && aiClaudeCodeEffortLevels.value.length === 0);
const aiReasoningLevelHint = computed(() => {
if (!aiIsClaudeCodeCli.value) return t("ai.reasoningLevelHint");
if (aiModelListLoading.value && aiClaudeCodeEffortLevels.value.length === 0) return t("ai.loadingModels");
return aiClaudeCodeEffortLevels.value.length ? t("ai.claudeCodeEffortHint") : t("ai.claudeCodeEffortUnavailable");
});
watch([aiIsClaudeCodeCli, aiSelectedModelInfo], () => {
if (!aiIsClaudeCodeCli.value) return;
aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value);
});
function aiModelsForSave(): AiConfiguredModel[] | undefined {
const models = aiEditModels.value
.map((model) => {
const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels);
return {
name: model.name.trim(),
label: model.label?.trim() || undefined,
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
};
})
.filter((model) => model.name);
if (aiIsClaudeCodeCli.value && aiEditModel.value.trim() && aiSelectedModelInfo.value) {
const modelId = aiEditModel.value.trim();
const supportedEffortLevels = normalizeAiModelEffortLevels(aiSelectedModelInfo.value.supportedEffortLevels);
const configuredModel = models.find((model) => model.name === modelId);
if (configuredModel) {
configuredModel.label = aiSelectedModelInfo.value.displayName || configuredModel.label;
configuredModel.supportedEffortLevels = supportedEffortLevels.length ? supportedEffortLevels : undefined;
} else {
models.unshift({
name: modelId,
label: aiSelectedModelInfo.value.displayName,
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
});
}
}
return models.length ? models : undefined;
}
function aiOnModelPopoverOpen(open: boolean) {
if (!open) return;
if (aiModelFetchSignature.value !== aiLastModelFetchSignature) {
aiFetchedModels.value = [];
aiLastModelFetchSignature = aiModelFetchSignature.value;
if (aiCanListModels.value) void aiFetchModelList();
} else if (aiFetchedModels.value.length === 0 && aiCanListModels.value) {
void aiFetchModelList();
@ -2255,23 +2400,14 @@ async function aiSaveConfig() {
return;
}
const editConfig = currentAiEditConfig();
aiEditReasoningLevel.value = editConfig.reasoningLevel;
const models = aiModelsForSave();
const config: AiConfigItem = {
id: aiEditConfigId.value || generateId(),
name: aiEditConfigName.value,
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value,
authMethod: aiEditAuthMethod.value,
endpoint: aiEditEndpoint.value,
model: aiEditModel.value,
models: aiEditModels.value.length > 0 ? aiEditModels.value.filter((m) => m.name.trim()) : undefined,
apiStyle: aiEditApiStyle.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
reasoningLevel: aiEditReasoningLevel.value,
contextWindow: aiEditContextWindow.value,
codexCliPath: aiEditCodexCliPath.value,
codexCliEnv: codexEnvFromRows(),
...editConfig,
models,
};
try {
@ -2318,10 +2454,10 @@ async function aiSetDefaultConfig(id: string) {
}
async function aiTestConn() {
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) || (!aiIsCodexCli.value && !aiEditModel.value.trim())) return;
if (aiCodexValidationError.value) {
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim()) || (!aiIsCliProvider.value && !aiEditModel.value.trim())) return;
if (aiCliValidationError.value) {
aiTestResult.value = "error";
aiTestError.value = aiCodexValidationError.value;
aiTestError.value = aiCliValidationError.value;
return;
}
aiTesting.value = true;
@ -2350,8 +2486,8 @@ async function copyAiTestError() {
}, 1500);
}
async function ensureCodexMcpStatus() {
if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCodexCli.value || mcpStatus.value || mcpStatusLoading.value) return;
async function ensureCliMcpStatus() {
if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCliProvider.value || mcpStatus.value || mcpStatusLoading.value) return;
await refreshMcpStatus();
}
@ -4293,7 +4429,7 @@ onUnmounted(cleanupPreviewEditor);
</div>
<div v-else class="space-y-2">
<div v-for="config in settingsStore.aiConfigs" :key="config.id" class="flex items-center justify-between rounded-md border p-3" :class="{ 'border-primary bg-primary/5': config.isDefault }">
<div v-for="config in displayedAiConfigs" :key="config.id" class="flex items-center justify-between rounded-md border p-3" :class="{ 'border-primary bg-primary/5': config.isDefault }">
<div class="flex items-center gap-3">
<AiProviderLogo :provider="config.provider" :label="config.provider" :icon-slug="AI_PROVIDER_PRESETS[config.provider].iconSlug" />
<div>
@ -4354,21 +4490,21 @@ onUnmounted(cleanupPreviewEditor);
</Select>
</div>
<!-- Codex CLI MCP Status -->
<div v-if="aiIsCodexCli && !isWeb" class="rounded-md border px-3 py-2.5 text-xs" :class="aiCodexMcpNeedsInstall ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'">
<!-- CLI MCP Status -->
<div v-if="aiIsCliProvider && !isWeb" class="rounded-md border px-3 py-2.5 text-xs" :class="aiCliMcpNeedsInstall ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex min-w-0 items-center gap-2 font-medium">
<Loader2 v-if="mcpStatusLoading" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<AlertTriangle v-else-if="aiCodexMcpNeedsInstall || mcpStatus?.error || mcpStatusError" class="h-3.5 w-3.5 shrink-0" />
<AlertTriangle v-else-if="aiCliMcpNeedsInstall || mcpStatus?.error || mcpStatusError" class="h-3.5 w-3.5 shrink-0" />
<CheckCircle2 v-else class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("ai.codexMcpRequiredTitle") }}</span>
<span>{{ t("ai.cliMcpRequiredTitle") }}</span>
<Badge variant="outline" class="h-5 shrink-0 rounded-md border-current/30 px-1.5 text-[11px] font-normal">
{{ mcpStatusLabel }}
</Badge>
</div>
<p class="leading-relaxed">
{{ t("ai.codexMcpRequiredDescription") }}
{{ t("ai.cliMcpRequiredDescription", { provider: aiCliProviderLabel }) }}
</p>
<p v-if="mcpStatus?.error || mcpStatusError" class="select-text leading-relaxed">
{{ mcpStatusError || mcpStatus?.error }}
@ -4380,16 +4516,16 @@ onUnmounted(cleanupPreviewEditor);
<RefreshCw v-else class="mr-1 h-3 w-3" />
{{ t("settings.mcpRefresh") }}
</Button>
<Button v-if="aiCodexMcpNeedsInstall || mcpStatus?.update_available" type="button" size="sm" class="h-7 px-2 text-xs" :disabled="!aiCodexMcpCanInstall" @click="installMcp">
<Button v-if="aiCliMcpNeedsInstall || mcpStatus?.update_available" type="button" size="sm" class="h-7 px-2 text-xs" :disabled="!aiCliMcpCanInstall" @click="installMcp">
<Loader2 v-if="mcpInstalling" class="mr-1 h-3 w-3 animate-spin" />
{{ mcpInstalling ? t("settings.mcpInstalling") : aiCodexMcpActionLabel }}
{{ mcpInstalling ? t("settings.mcpInstalling") : aiCliMcpActionLabel }}
</Button>
</div>
</div>
</div>
<!-- Authentication -->
<div v-if="!aiIsCodexCli && aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCliProvider && aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Authentication</Label>
<Select v-model="aiEditAuthMethod">
<SelectTrigger class="col-span-2" inputClass="h-8 text-xs">
@ -4403,13 +4539,13 @@ onUnmounted(cleanupPreviewEditor);
</div>
<!-- API Key -->
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ aiCredentialLabel }}</Label>
<PasswordInput v-model="aiEditApiKey" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
</div>
<!-- Endpoint -->
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">Endpoint</Label>
<div class="col-span-2 space-y-1.5">
<Input v-model="aiEditEndpoint" :placeholder="aiEndpointPlaceholder" autocomplete="off" class="h-8 text-xs" />
@ -4417,35 +4553,35 @@ onUnmounted(cleanupPreviewEditor);
</div>
</div>
<!-- Codex CLI Path -->
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.codexCliPath") }}</Label>
<!-- CLI Path -->
<div v-if="aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.cliPath", { provider: aiCliProviderLabel }) }}</Label>
<div class="col-span-2 space-y-1.5">
<Input v-model="aiEditCodexCliPath" autocomplete="off" class="h-8 text-xs" placeholder="codex" />
<p class="text-[11px] text-muted-foreground">{{ t("ai.codexCliPathHint") }}</p>
<p v-if="aiCodexPathError" class="text-[11px] text-destructive">{{ aiCodexPathError }}</p>
<Input v-model="aiEditCliPath" autocomplete="off" class="h-8 text-xs" :placeholder="aiCliCommandName" />
<p class="text-[11px] text-muted-foreground">{{ t("ai.cliPathHint", { command: aiCliCommandName, loginCommand: aiCliLoginCommand }) }}</p>
<p v-if="aiCliPathError" class="text-[11px] text-destructive">{{ aiCliPathError }}</p>
</div>
</div>
<!-- Codex CLI Env -->
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.codexCliEnv") }}</Label>
<!-- CLI Env -->
<div v-if="aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.cliEnv") }}</Label>
<div class="col-span-2 space-y-2">
<div class="space-y-1.5">
<div v-for="row in aiEditCodexCliEnvRows" :key="row.id" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_2rem] gap-2">
<Input v-model="row.key" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.codexCliEnvKeyPlaceholder')" />
<Input v-model="row.value" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.codexCliEnvValuePlaceholder')" />
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :title="t('common.remove')" :aria-label="t('common.remove')" @click="removeCodexEnvRow(row.id)">
<div v-for="row in aiEditCliEnvRows" :key="row.id" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_2rem] gap-2">
<Input v-model="row.key" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.cliEnvKeyPlaceholder')" />
<Input v-model="row.value" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.cliEnvValuePlaceholder')" />
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :title="t('common.remove')" :aria-label="t('common.remove')" @click="removeCliEnvRow(row.id)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="addCodexEnvRow">
<Button type="button" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="addCliEnvRow">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.codexCliEnvAdd") }}
{{ t("ai.cliEnvAdd") }}
</Button>
<p v-if="aiCodexEnvError" class="text-[11px] text-destructive">{{ aiCodexEnvError }}</p>
<p v-else class="text-[11px] text-muted-foreground">{{ t("ai.codexCliEnvHint") }}</p>
<p v-if="aiCliEnvError" class="text-[11px] text-destructive">{{ aiCliEnvError }}</p>
<p v-else class="text-[11px] text-muted-foreground">{{ t("ai.cliEnvHint", { provider: aiCliProviderLabel }) }}</p>
</div>
</div>
@ -4506,10 +4642,10 @@ onUnmounted(cleanupPreviewEditor);
</div>
<!-- Reasoning Level -->
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<div v-if="aiIsCodexCli || aiIsClaudeCodeCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.reasoningLevel") }}</Label>
<div class="col-span-2 space-y-1.5">
<Select v-model="aiEditReasoningLevel">
<Select v-model="aiEditReasoningLevel" :disabled="aiReasoningLevelDisabled">
<SelectTrigger inputClass="h-8 text-xs">
<SelectValue />
</SelectTrigger>
@ -4519,7 +4655,7 @@ onUnmounted(cleanupPreviewEditor);
</SelectItem>
</SelectContent>
</Select>
<p class="text-[11px] text-muted-foreground">{{ t("ai.reasoningLevelHint") }}</p>
<p class="text-[11px] text-muted-foreground">{{ aiReasoningLevelHint }}</p>
</div>
</div>
@ -4534,7 +4670,7 @@ onUnmounted(cleanupPreviewEditor);
</div>
<!-- Enable Thinking -->
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.enableThinking") }}</Label>
<div class="col-span-2 flex items-center gap-2">
<label class="flex items-center gap-2 text-xs text-muted-foreground">
@ -4553,7 +4689,7 @@ onUnmounted(cleanupPreviewEditor);
</div>
<!-- Context Window -->
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
<div class="col-span-2">
<Input v-model.number="aiEditContextWindow" type="number" min="1000" step="1000" class="h-8 text-xs" :placeholder="t('ai.contextWindowAuto')" />
@ -4562,7 +4698,7 @@ onUnmounted(cleanupPreviewEditor);
</div>
<!-- Proxy -->
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="aiEditProxyEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
@ -4571,7 +4707,7 @@ onUnmounted(cleanupPreviewEditor);
</div>
<!-- Proxy URL -->
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
<Input v-model="aiEditProxyUrl" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" placeholder="socks5://127.0.0.1:7890" :disabled="!aiEditProxyEnabled" />
</div>
@ -4958,7 +5094,7 @@ onUnmounted(cleanupPreviewEditor);
</template>
<template v-else>
<div class="flex flex-1 items-center gap-2">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCodexValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCodexCli && !aiEditEndpoint?.trim()) || (!aiIsCodexCli && !aiEditModel?.trim())" @click="aiTestConn">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim()) || (!aiIsCliProvider && !aiEditModel?.trim())" @click="aiTestConn">
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
@ -4983,7 +5119,7 @@ onUnmounted(cleanupPreviewEditor);
</span>
</div>
<Button variant="outline" @click="aiEnterListMode()">{{ t("common.cancel") }}</Button>
<Button :disabled="!aiEditConfigName.trim() || !!aiCodexValidationError" @click="aiSaveConfig">{{ t("settings.apply") }}</Button>
<Button :disabled="!aiEditConfigName.trim() || !!aiCliValidationError" @click="aiSaveConfig">{{ t("settings.apply") }}</Button>
</template>
</DialogFooter>

View File

@ -1466,6 +1466,18 @@ export default {
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "Invalid environment variable name: {name}",
codexCliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.",
cliMcpRequiredTitle: "DBX MCP Server required",
cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.",
cliPath: "{provider} Path",
cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.",
cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.",
cliEnv: "Environment variables",
cliEnvAdd: "Add variable",
cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "Invalid environment variable name: {name}",
cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.",
codexMcpRequiredTitle: "DBX MCP Server required",
codexMcpRequiredDescription: "Codex CLI uses the DBX MCP Server to access database schema and query tools. Install it before using Codex in the AI assistant.",
reasoningLevel: "Reasoning level",
@ -1474,7 +1486,11 @@ export default {
reasoningLevelLow: "Low",
reasoningLevelMedium: "Medium",
reasoningLevelHigh: "High",
reasoningLevelXhigh: "Extra high",
reasoningLevelMax: "Maximum",
reasoningLevelHint: "Controls Codex CLI model_reasoning_effort. Default uses your Codex config.",
claudeCodeEffortHint: "Available levels are reported by the selected model through your local Claude Code CLI.",
claudeCodeEffortUnavailable: "The selected model does not report configurable effort levels. Claude Code will use its default.",
actions: {
general: "General",
generate: "Generate SQL",

View File

@ -1325,13 +1325,29 @@ export default withEnglishFallback({
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "Nombre de variable de entorno no válido: {name}",
codexCliEnvReservedName: "{name} lo administra DBX para MCP Server y no se puede configurar aquí.",
cliMcpRequiredTitle: "DBX MCP Server required",
cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.",
cliPath: "{provider} Path",
cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.",
cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.",
cliEnv: "Environment variables",
cliEnvAdd: "Add variable",
cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "Invalid environment variable name: {name}",
cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.",
reasoningLevel: "Nivel de razonamiento",
reasoningLevelDefault: "Predeterminado",
reasoningLevelMinimal: "Mínimo",
reasoningLevelLow: "Bajo",
reasoningLevelMedium: "Medio",
reasoningLevelHigh: "Alto",
reasoningLevelXhigh: "Muy alto",
reasoningLevelMax: "Máximo",
reasoningLevelHint: "Controla model_reasoning_effort de Codex CLI. Predeterminado usa tu configuración de Codex.",
claudeCodeEffortHint: "Los niveles disponibles los proporciona dinámicamente tu Claude Code CLI local para el modelo seleccionado.",
claudeCodeEffortUnavailable: "El modelo seleccionado no informa niveles de esfuerzo configurables. Claude Code usará su valor predeterminado.",
run: "Ejecutar",
readingSchema: "Leyendo esquema",
noConnection: "No hay una conexión disponible para esta pestaña",

View File

@ -1406,6 +1406,18 @@ export default withEnglishFallback({
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "Nome variabile d'ambiente non valido: {name}",
codexCliEnvReservedName: "{name} è gestita da DBX per MCP Server e non può essere impostata qui.",
cliMcpRequiredTitle: "DBX MCP Server required",
cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.",
cliPath: "{provider} Path",
cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.",
cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.",
cliEnv: "Environment variables",
cliEnvAdd: "Add variable",
cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "Invalid environment variable name: {name}",
cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.",
codexMcpRequiredTitle: "DBX MCP Server richiesto",
codexMcpRequiredDescription: "Codex CLI usa il DBX MCP Server per accedere allo schema del database e agli strumenti di query. Installalo prima di usare Codex nell'assistente AI.",
reasoningLevel: "Livello di ragionamento",
@ -1414,7 +1426,11 @@ export default withEnglishFallback({
reasoningLevelLow: "Basso",
reasoningLevelMedium: "Medio",
reasoningLevelHigh: "Alto",
reasoningLevelXhigh: "Molto alto",
reasoningLevelMax: "Massimo",
reasoningLevelHint: "Controlla model_reasoning_effort di Codex CLI. Predefinito usa la configurazione Codex.",
claudeCodeEffortHint: "I livelli disponibili vengono forniti dinamicamente dal Claude Code CLI locale per il modello selezionato.",
claudeCodeEffortUnavailable: "Il modello selezionato non segnala livelli di effort configurabili. Claude Code userà il valore predefinito.",
actions: {
general: "Generale",
generate: "Genera SQL",

View File

@ -1324,6 +1324,18 @@ export default withEnglishFallback({
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "無効な環境変数名: {name}",
codexCliEnvReservedName: "{name}はMCP Server用にDBXが管理しているため、ここでは設定できません。",
cliMcpRequiredTitle: "DBX MCP Server required",
cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.",
cliPath: "{provider} Path",
cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.",
cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.",
cliEnv: "Environment variables",
cliEnvAdd: "Add variable",
cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "Invalid environment variable name: {name}",
cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.",
contextWindow: "コンテキストウィンドウ",
contextWindowAuto: "自動(モデル名から検出)",
contextWindowHint: "トークン数。空欄で自動検出。ローカル/カスタムモデルは手動設定してください。",
@ -1335,7 +1347,11 @@ export default withEnglishFallback({
reasoningLevelLow: "低",
reasoningLevelMedium: "中",
reasoningLevelHigh: "高",
reasoningLevelXhigh: "非常に高い",
reasoningLevelMax: "最大",
reasoningLevelHint: "Codex CLIのmodel_reasoning_effortを制御します。デフォルトではCodex設定を使用します。",
claudeCodeEffortHint: "利用可能なレベルは、選択したモデルについてローカルのClaude Code CLIから動的に取得されます。",
claudeCodeEffortUnavailable: "選択したモデルは設定可能なeffortレベルを返していません。Claude Codeのデフォルトを使用します。",
run: "実行",
readingSchema: "スキーマを読み取り中",
noConnection: "このタブに利用可能な接続がありません",

View File

@ -1327,13 +1327,29 @@ export default withEnglishFallback({
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "Nome de variável de ambiente inválido: {name}",
codexCliEnvReservedName: "{name} é gerenciada pelo DBX para o MCP Server e não pode ser definida aqui.",
cliMcpRequiredTitle: "DBX MCP Server required",
cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.",
cliPath: "{provider} Path",
cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.",
cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.",
cliEnv: "Environment variables",
cliEnvAdd: "Add variable",
cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "Invalid environment variable name: {name}",
cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.",
reasoningLevel: "Nível de raciocínio",
reasoningLevelDefault: "Padrão",
reasoningLevelMinimal: "Mínimo",
reasoningLevelLow: "Baixo",
reasoningLevelMedium: "Médio",
reasoningLevelHigh: "Alto",
reasoningLevelXhigh: "Muito alto",
reasoningLevelMax: "Máximo",
reasoningLevelHint: "Controla model_reasoning_effort do Codex CLI. Padrão usa sua configuração do Codex.",
claudeCodeEffortHint: "Os níveis disponíveis são informados dinamicamente pelo Claude Code CLI local para o modelo selecionado.",
claudeCodeEffortUnavailable: "O modelo selecionado não informa níveis de effort configuráveis. O Claude Code usará o padrão.",
run: "Executar",
readingSchema: "Lendo schema",
noConnection: "Nenhuma conexão disponível para esta aba",

View File

@ -1466,6 +1466,18 @@ export default withEnglishFallback({
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "环境变量名称无效:{name}",
codexCliEnvReservedName: "{name} 由 DBX 用于 MCP Server不能在这里设置。",
cliMcpRequiredTitle: "需要 DBX MCP Server",
cliMcpRequiredDescription: "{provider} 会通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用该提供商。",
cliPath: "{provider} 路径",
cliPathHint: "留空则使用 PATH 中的 {command}。请先在终端执行 {loginCommand} 登录。",
cliPathEnvError: "{provider} 路径只能填写可执行文件路径。请在下方添加环境变量。",
cliEnv: "环境变量",
cliEnvAdd: "添加变量",
cliEnvHint: "传递给 {provider} 的可选变量,例如 HTTPS_PROXY、NO_PROXY 或 SSL_CERT_FILE。",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "环境变量名称无效:{name}",
cliEnvReservedName: "{name} 由 DBX 用于 MCP Server不能在这里设置。",
codexMcpRequiredTitle: "需要 DBX MCP Server",
codexMcpRequiredDescription: "Codex CLI 通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用 Codex。",
reasoningLevel: "推理级别",
@ -1474,7 +1486,11 @@ export default withEnglishFallback({
reasoningLevelLow: "低",
reasoningLevelMedium: "中",
reasoningLevelHigh: "高",
reasoningLevelXhigh: "极高",
reasoningLevelMax: "最高",
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。默认使用你的 Codex 配置。",
claudeCodeEffortHint: "可用级别由本地 Claude Code CLI 根据所选模型动态提供。",
claudeCodeEffortUnavailable: "所选模型未提供可配置的推理级别,将使用 Claude Code 的默认设置。",
actions: {
general: "通用问答",
generate: "生成 SQL",

View File

@ -1410,13 +1410,29 @@ export default withEnglishFallback({
codexCliEnvValuePlaceholder: "http://proxy:9800",
codexCliEnvInvalidName: "環境變數名稱無效:{name}",
codexCliEnvReservedName: "{name} 由 DBX 用於 MCP Server不能在這裡設定。",
cliMcpRequiredTitle: "需要 DBX MCP Server",
cliMcpRequiredDescription: "{provider} 会通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用该提供商。",
cliPath: "{provider} 路径",
cliPathHint: "留空则使用 PATH 中的 {command}。请先在终端执行 {loginCommand} 登录。",
cliPathEnvError: "{provider} 路径只能填写可执行文件路径。请在下方添加环境变量。",
cliEnv: "环境变量",
cliEnvAdd: "添加变量",
cliEnvHint: "传递给 {provider} 的可选变量,例如 HTTPS_PROXY、NO_PROXY 或 SSL_CERT_FILE。",
cliEnvKeyPlaceholder: "HTTPS_PROXY",
cliEnvValuePlaceholder: "http://proxy:9800",
cliEnvInvalidName: "环境变量名称无效:{name}",
cliEnvReservedName: "{name} 由 DBX 用于 MCP Server不能在这里设置。",
reasoningLevel: "推理層級",
reasoningLevelDefault: "預設",
reasoningLevelMinimal: "最小",
reasoningLevelLow: "低",
reasoningLevelMedium: "中",
reasoningLevelHigh: "高",
reasoningLevelXhigh: "極高",
reasoningLevelMax: "最高",
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。預設會使用你的 Codex 設定。",
claudeCodeEffortHint: "可用層級由本機 Claude Code CLI 根據所選模型動態提供。",
claudeCodeEffortUnavailable: "所選模型未提供可設定的推理層級,將使用 Claude Code 的預設設定。",
actions: {
general: "通用問答",
generate: "產生 SQL",

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import type { AiModelInfo } from "@/lib/backend/tauri";
describe("normalizeAiModelEffortLevels", () => {
it("preserves the CLI order while removing duplicates and unknown levels", () => {
expect(normalizeAiModelEffortLevels(["low", "high", "xhigh", "high", "future", null, "max"])).toEqual(["low", "high", "xhigh", "max"]);
});
it("returns no configurable levels when metadata is unavailable", () => {
expect(normalizeAiModelEffortLevels(undefined)).toEqual([]);
});
});
describe("normalizeClaudeCodeReasoningLevel", () => {
const model: AiModelInfo = {
id: "claude-sonnet-5",
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
it("keeps an effort level reported by the selected model", () => {
expect(normalizeClaudeCodeReasoningLevel("xhigh", model)).toBe("xhigh");
});
it("falls back to default for unsupported or missing model metadata", () => {
expect(normalizeClaudeCodeReasoningLevel("minimal", model)).toBe("default");
expect(normalizeClaudeCodeReasoningLevel("max", { id: "default" })).toBe("default");
});
});

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import type { AiProvider } from "@/types/ai";
interface TestConfig {
id: string;
provider: AiProvider;
}
describe("orderAiConfigsForDisplay", () => {
it("matches the canonical provider order", () => {
const configs: TestConfig[] = [
{ id: "claude-code-1", provider: "claude-code-cli" },
{ id: "claude", provider: "claude" },
{ id: "openai", provider: "openai" },
{ id: "codex", provider: "codex-cli" },
{ id: "custom", provider: "custom" },
];
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "custom"]);
});
it("preserves creation order for configs from the same provider", () => {
const configs: TestConfig[] = [
{ id: "codex-1", provider: "codex-cli" },
{ id: "claude-code-1", provider: "claude-code-cli" },
{ id: "codex-2", provider: "codex-cli" },
{ id: "claude-code-2", provider: "claude-code-cli" },
];
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude-code-1", "claude-code-2", "codex-1", "codex-2"]);
});
});

View File

@ -0,0 +1,15 @@
import { AI_PROVIDER_PRESETS } from "@/stores/settingsStore";
import type { AiProvider } from "@/types/ai";
const AI_PROVIDER_DISPLAY_RANK = new Map<AiProvider, number>((Object.keys(AI_PROVIDER_PRESETS) as AiProvider[]).map((provider, index) => [provider, index]));
/**
* Match configured-provider lists to the canonical provider picker order while
* preserving creation order between configs that use the same provider.
*/
export function orderAiConfigsForDisplay<T extends { provider: AiProvider }>(configs: readonly T[]): T[] {
return configs
.map((config, index) => ({ config, index }))
.sort((a, b) => (AI_PROVIDER_DISPLAY_RANK.get(a.config.provider) ?? Number.MAX_SAFE_INTEGER) - (AI_PROVIDER_DISPLAY_RANK.get(b.config.provider) ?? Number.MAX_SAFE_INTEGER) || a.index - b.index)
.map(({ config }) => config);
}

View File

@ -0,0 +1,28 @@
import type { AiEffortLevel, AiReasoningLevel } from "@/types/ai";
interface AiModelEffortMetadata {
supportedEffortLevels?: unknown;
}
const AI_EFFORT_LEVELS = new Set<AiEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
export function normalizeAiModelEffortLevels(value: unknown): AiEffortLevel[] {
if (!Array.isArray(value)) return [];
const seen = new Set<AiEffortLevel>();
const levels: AiEffortLevel[] = [];
for (const level of value) {
if (typeof level !== "string" || !AI_EFFORT_LEVELS.has(level as AiEffortLevel)) continue;
const normalized = level as AiEffortLevel;
if (seen.has(normalized)) continue;
seen.add(normalized);
levels.push(normalized);
}
return levels;
}
export function normalizeClaudeCodeReasoningLevel(reasoningLevel: AiReasoningLevel | undefined, model: AiModelEffortMetadata | undefined): AiReasoningLevel {
if (!reasoningLevel || reasoningLevel === "default") return "default";
const supported = normalizeAiModelEffortLevels(model?.supportedEffortLevels);
return supported.includes(reasoningLevel as AiEffortLevel) ? reasoningLevel : "default";
}

View File

@ -41,8 +41,7 @@ import type {
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
import type { AiConfigItem } from "@/types/ai";
import type { AiConfig, AiConfigItem, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import type {
@ -336,6 +335,7 @@ export interface AiCompletionRequest {
export interface AiModelInfo {
id: string;
displayName?: string;
supportedEffortLevels?: AiEffortLevel[];
}
export async function aiComplete(request: AiCompletionRequest): Promise<string> {

View File

@ -15,9 +15,9 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates";
import { DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import type { AiProvider, AiApiStyle, AiAuthMethod, AiReasoningLevel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai";
import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai";
export type { AiProvider, AiApiStyle, AiAuthMethod, AiReasoningLevel, AiConfig, AiTestConnectionResult, AiConfigItem };
export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem };
export interface DesktopSettings {
show_tray_icon: boolean;
@ -164,6 +164,16 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
authMethod: "bearer",
requiresApiKey: true,
},
"claude-code-cli": {
label: "Claude Code CLI",
iconSlug: "claudecode",
provider: "claude-code-cli",
endpoint: "",
model: "default",
apiStyle: "completions",
authMethod: "bearer",
requiresApiKey: false,
},
"codex-cli": {
label: "Codex CLI",
iconSlug: "codex",
@ -192,7 +202,7 @@ const defaultConfigs: Record<AiProvider, Omit<AiConfig, "apiKey">> = Object.from
}),
) as Record<AiProvider, Omit<AiConfig, "apiKey">>;
const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high"];
const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high", "xhigh", "max"];
const AI_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function normalizeAiReasoningLevel(value: unknown): AiReasoningLevel {
@ -226,6 +236,8 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
contextWindow: config?.contextWindow ?? undefined,
codexCliPath: config?.codexCliPath?.trim() || undefined,
codexCliEnv: normalizeAiEnv(config?.codexCliEnv),
claudeCodeCliPath: config?.claudeCodeCliPath?.trim() || undefined,
claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv),
};
}
@ -1022,7 +1034,7 @@ export const useSettingsStore = defineStore("settings", () => {
const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
if (!config) return false;
const preset = AI_PROVIDER_PRESETS[config.provider];
if (config.provider === "codex-cli") return true;
if (config.provider === "codex-cli" || config.provider === "claude-code-cli") return true;
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
});

View File

@ -1,7 +1,14 @@
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom";
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "codex-cli" | "custom";
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer";
export type AiReasoningLevel = "default" | "minimal" | "low" | "medium" | "high";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
export type AiReasoningLevel = "default" | "minimal" | AiEffortLevel;
export interface AiConfiguredModel {
name: string;
label?: string;
supportedEffortLevels?: AiEffortLevel[];
}
export interface AiConfig {
provider: AiProvider;
@ -9,7 +16,7 @@ export interface AiConfig {
authMethod: AiAuthMethod;
endpoint: string;
model: string;
models?: Array<{ name: string; label?: string }>;
models?: AiConfiguredModel[];
apiStyle: AiApiStyle;
proxyEnabled?: boolean;
proxyUrl?: string;
@ -18,6 +25,8 @@ export interface AiConfig {
contextWindow?: number;
codexCliPath?: string | null;
codexCliEnv?: Record<string, string>;
claudeCodeCliPath?: string | null;
claudeCodeCliEnv?: Record<string, string>;
}
export interface AiTestConnectionResult {

View File

@ -101,7 +101,7 @@ pub async fn run_agent_loop(
let contract_system_prompt = augment_system_prompt_with_task_contract(system_prompt, task_contract, is_agent_mode);
let system_prompt = contract_system_prompt.as_str();
if matches!(config.provider, AiProvider::CodexCli) {
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
let connection_name = {
let configs = agent_ctx.state.configs.read().await;
configs
@ -109,24 +109,27 @@ pub async fn run_agent_loop(
.map(|config| config.name.clone())
.unwrap_or_else(|| agent_ctx.connection_id.clone())
};
let options = crate::ai_cli_agent::CliAgentRunOptions {
connection_id: agent_ctx.connection_id.clone(),
connection_name,
database: agent_ctx.database.clone(),
agent_mode: is_agent_mode,
allow_writes: agent_ctx.sql_permissions.allow_writes,
allow_dangerous: agent_ctx.sql_permissions.allow_dangerous,
mcp_server_command: agent_ctx.cli_mcp_server_command.clone(),
};
if matches!(config.provider, AiProvider::ClaudeCodeCli) {
let prompt = crate::ai_claude_code_cli::build_claude_code_prompt(
system_prompt,
messages,
agent_ctx.sql_permissions.allow_writes,
);
return crate::ai_claude_code_cli::run_claude_code_agent(config, &prompt, options, cancelled, on_event)
.await;
}
let prompt =
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
return crate::ai_codex_cli::run_codex_agent(
config,
&prompt,
crate::ai_codex_cli::CodexRunOptions {
connection_id: agent_ctx.connection_id.clone(),
connection_name,
database: agent_ctx.database.clone(),
agent_mode: is_agent_mode,
allow_writes: agent_ctx.sql_permissions.allow_writes,
allow_dangerous: agent_ctx.sql_permissions.allow_dangerous,
mcp_server_command: agent_ctx.cli_mcp_server_command.clone(),
},
cancelled,
on_event,
)
.await;
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
}
// Auto-degrade: providers without function calling fall back to text-only completion.

View File

@ -61,6 +61,8 @@ pub enum AiProvider {
OpenaiCompatible,
#[serde(rename = "codex-cli")]
CodexCli,
#[serde(rename = "claude-code-cli")]
ClaudeCodeCli,
Custom,
}
@ -74,6 +76,7 @@ impl AiProvider {
AiProvider::Qwen => "qwen",
AiProvider::Ollama => "ollama",
AiProvider::OpenaiCompatible => "openai-compatible",
AiProvider::ClaudeCodeCli => "claude-code-cli",
AiProvider::CodexCli => "codex-cli",
AiProvider::Custom => "custom",
}
@ -107,6 +110,8 @@ pub enum AiReasoningLevel {
Low,
Medium,
High,
Xhigh,
Max,
}
impl AiReasoningLevel {
@ -117,6 +122,43 @@ impl AiReasoningLevel {
AiReasoningLevel::Low => Some("low"),
AiReasoningLevel::Medium => Some("medium"),
AiReasoningLevel::High => Some("high"),
AiReasoningLevel::Xhigh | AiReasoningLevel::Max => None,
}
}
pub fn as_claude_code_effort(&self) -> Option<&'static str> {
match self {
AiReasoningLevel::Default | AiReasoningLevel::Minimal => None,
AiReasoningLevel::Low => Some("low"),
AiReasoningLevel::Medium => Some("medium"),
AiReasoningLevel::High => Some("high"),
AiReasoningLevel::Xhigh => Some("xhigh"),
AiReasoningLevel::Max => Some("max"),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum AiEffortLevel {
Low,
Medium,
High,
Xhigh,
Max,
}
impl std::str::FromStr for AiEffortLevel {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" => Ok(Self::Xhigh),
"max" => Ok(Self::Max),
_ => Err(()),
}
}
}
@ -144,6 +186,8 @@ pub struct AiModelListItem {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supported_effort_levels: Vec<AiEffortLevel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -176,6 +220,10 @@ pub struct AiConfig {
pub codex_cli_path: Option<String>,
#[serde(default)]
pub codex_cli_env: HashMap<String, String>,
#[serde(default)]
pub claude_code_cli_path: Option<String>,
#[serde(default)]
pub claude_code_cli_env: HashMap<String, String>,
}
fn default_enable_thinking() -> bool {
@ -273,6 +321,14 @@ pub struct AiModelInfo {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supported_effort_levels: Vec<AiEffortLevel>,
}
impl AiModelInfo {
pub fn new(id: impl Into<String>, display_name: Option<String>) -> Self {
Self { id: id.into(), display_name, supported_effort_levels: Vec::new() }
}
}
/// Result of an AI connection test (mirrors CC-Switch's StreamCheckResult).
@ -358,7 +414,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
format!("{base}/chat/completions")
}
}
AiProvider::Claude | AiProvider::CodexCli | AiProvider::Gemini => unreachable!(),
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::Gemini => unreachable!(),
}
}
@ -746,7 +802,7 @@ fn emit_responses_function_call_item(
// ---------------------------------------------------------------------------
fn validate_config(config: &AiConfig) -> Result<(), String> {
if matches!(config.provider, AiProvider::CodexCli) {
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
return Ok(());
}
if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() {
@ -762,7 +818,7 @@ fn validate_config(config: &AiConfig) -> Result<(), String> {
}
fn validate_model_list_config(config: &AiConfig) -> Result<(), String> {
if matches!(config.provider, AiProvider::CodexCli) {
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
return Ok(());
}
if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() {
@ -854,7 +910,7 @@ fn parse_model_list_response(data: &serde_json::Value) -> Result<Vec<AiModelInfo
.filter(|name| !name.trim().is_empty() && *name != id)
.map(ToString::to_string);
models.push(AiModelInfo { id: id.to_string(), display_name });
models.push(AiModelInfo::new(id, display_name));
}
Ok(models)
@ -901,6 +957,9 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
if matches!(config.provider, AiProvider::CodexCli) {
return crate::ai_codex_cli::list_codex_models(config).await;
}
if matches!(config.provider, AiProvider::ClaudeCodeCli) {
return crate::ai_claude_code_cli::list_claude_code_models(config).await;
}
validate_model_list_config(config)?;
let client = build_ai_http_client(config, 30)?;
@ -919,7 +978,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
list_openai_compatible_models(&client, config).await
}
}
AiProvider::CodexCli => unreachable!(),
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(),
AiProvider::Gemini => {
Err("Model listing is only supported for OpenAI-compatible and Claude providers".to_string())
}
@ -1133,6 +1192,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionR
if matches!(config.provider, AiProvider::CodexCli) {
return crate::ai_codex_cli::test_codex_connection(config).await;
}
if matches!(config.provider, AiProvider::ClaudeCodeCli) {
return crate::ai_claude_code_cli::test_claude_code_connection(config).await;
}
validate_config(config)?;
let client = build_ai_http_client(config, 15)?;
@ -1308,8 +1370,8 @@ fn classify_error(msg: &str) -> &'static str {
pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
validate_config(&request.config)?;
if matches!(request.config.provider, AiProvider::CodexCli) {
return Err("Codex CLI provider is only supported in DBX AI agent mode".to_string());
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
}
let client = build_ai_http_client(&request.config, 60)?;
@ -1317,7 +1379,7 @@ pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
match request.config.provider {
AiProvider::Claude => call_claude(&client, request.clone()).await,
AiProvider::Gemini => call_gemini(&client, request.clone()).await,
AiProvider::CodexCli => unreachable!(),
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(),
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@ -1353,8 +1415,8 @@ pub async fn stream(
) -> Result<(), String> {
validate_config(&request.config)?;
if matches!(request.config.provider, AiProvider::CodexCli) {
return Err("Codex CLI provider is only supported in DBX AI agent mode".to_string());
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
}
let stream_timeout = if request.config.enable_thinking { 600 } else { 120 };
@ -1363,7 +1425,7 @@ pub async fn stream(
match request.config.provider {
AiProvider::Claude => stream_claude(&client, session_id, request, cancelled, &on_chunk).await,
AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await,
AiProvider::CodexCli => unreachable!(),
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(),
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@ -2422,6 +2484,9 @@ pub async fn stream_with_tools(
on_chunk: impl Fn(AiStreamChunk),
) -> Result<(Vec<crate::agent_events::ToolCall>, Option<TokenUsage>), String> {
validate_config(config)?;
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
return Err("CLI providers are only supported through the DBX AI agent loop".to_string());
}
let stream_timeout = if config.enable_thinking { 600 } else { 120 };
let client = build_ai_http_client(config, stream_timeout)?;
@ -2606,6 +2671,8 @@ mod tests {
assert_eq!(config.proxy_url, "");
assert!(config.enable_thinking);
assert_eq!(config.auth_method, AiAuthMethod::ApiKey);
assert!(config.claude_code_cli_path.is_none());
assert!(config.claude_code_cli_env.is_empty());
assert!(config.codex_cli_env.is_empty());
}
@ -2626,6 +2693,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let err = build_ai_http_client(&config, 1).unwrap_err();
@ -2650,6 +2719,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@ -2672,6 +2743,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@ -2694,6 +2767,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
assert_eq!(
@ -2720,6 +2795,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
@ -2743,6 +2820,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
@ -2761,6 +2840,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
}
@ -2782,6 +2863,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
assert!(uses_anthropic_messages_api(&config));
@ -2827,6 +2910,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
@ -2884,6 +2969,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let api_key_headers = claude_headers(&config).unwrap();
@ -2923,11 +3010,8 @@ mod tests {
assert_eq!(
parse_model_list_response(&data).unwrap(),
vec![
AiModelInfo { id: "gpt-4o-mini".to_string(), display_name: None },
AiModelInfo {
id: "claude-sonnet-4-20250514".to_string(),
display_name: Some("Claude Sonnet 4".to_string())
},
AiModelInfo::new("gpt-4o-mini", None),
AiModelInfo::new("claude-sonnet-4-20250514", Some("Claude Sonnet 4".to_string())),
]
);
}
@ -3173,6 +3257,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let mut body = serde_json::json!({
@ -3239,6 +3325,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@ -3270,6 +3358,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@ -3307,6 +3397,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@ -3338,6 +3430,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,

View File

@ -0,0 +1,855 @@
use crate::agent_events::AgentEvent;
use crate::ai::{AiConfig, AiEffortLevel, AiModelInfo, AiTestConnectionResult};
use crate::ai_cli_agent::{
build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos, parse_cli_jsonl_event,
run_cli_jsonl_agent, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
};
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use tokio::sync::Notify;
const DEFAULT_CLAUDE_CODE_MODELS: &[&str] = &["default", "sonnet", "opus", "fable"];
const CLAUDE_CODE_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
const CLAUDE_CODE_SETTING_SOURCES: &str = "user";
pub type ClaudeCodeRunOptions = CliAgentRunOptions;
pub type ClaudeCodeCommandSpec = CliAgentCommandSpec;
struct ClaudeCodeIsolatedCwd {
path: PathBuf,
}
impl ClaudeCodeIsolatedCwd {
fn create() -> Result<Self, String> {
let path = env::temp_dir().join(format!("dbx-claude-code-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&path).map_err(|error| {
format!("[claudeCodeRunFailed] Failed to create isolated Claude Code directory: {error}")
})?;
Ok(Self { path })
}
}
impl Drop for ClaudeCodeIsolatedCwd {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn append_claude_code_isolation_args(args: &mut Vec<String>) {
// User settings retain authentication and preferences without loading project hooks or local overrides.
args.extend(["--setting-sources".to_string(), CLAUDE_CODE_SETTING_SOURCES.to_string()]);
}
fn claude_code_program(config: &AiConfig) -> String {
config
.claude_code_cli_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.unwrap_or("claude")
.to_string()
}
fn claude_code_process_env(
config: &AiConfig,
command: &ClaudeCodeCommandSpec,
) -> Result<Vec<(String, String)>, String> {
let mut env = BTreeMap::from_iter(claude_code_cli_env(config)?);
if let Some(dir) = command_parent_dir(command) {
let user_path = env.get("PATH").map(String::as_str);
env.insert("PATH".to_string(), merged_path_with_dir(&dir, user_path));
}
Ok(env.into_iter().collect())
}
fn command_parent_dir(command: &ClaudeCodeCommandSpec) -> Option<String> {
Path::new(&command.program)
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(|parent| parent.to_string_lossy().to_string())
}
fn merged_path_with_dir(dir: &str, user_path: Option<&str>) -> String {
let mut seen = BTreeSet::new();
let mut dirs = vec![PathBuf::from(dir)];
if let Some(path) = user_path {
dirs.extend(env::split_paths(path));
}
dirs.extend(common_executable_dirs());
let paths = dirs.into_iter().filter(|path| seen.insert(path.clone())).collect::<Vec<_>>();
env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string()
}
fn common_executable_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(path) = env::var("PATH") {
dirs.extend(env::split_paths(&path));
}
#[cfg(windows)]
{
if let Ok(app_data) = env::var("APPDATA") {
dirs.push(PathBuf::from(app_data).join("npm"));
}
}
#[cfg(not(windows))]
{
dirs.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
PathBuf::from("/usr/sbin"),
PathBuf::from("/sbin"),
]);
}
dirs
}
fn validate_claude_code_program(config: &AiConfig) -> Result<String, String> {
let program = claude_code_program(config);
if starts_with_env_assignment(&program) {
return Err("[claudeCodeCliPathInvalid] Claude Code CLI path should contain only the executable path. Add environment variables in the Claude Code CLI environment variables section.".to_string());
}
if is_path_like_program(&program) {
let expanded = crate::path_utils::expand_tilde(&program);
let path = Path::new(&expanded);
if path.is_dir() {
return launchable_program_in_dir(path, "claude").ok_or_else(|| {
"[claudeCodeCliPathInvalid] Claude Code CLI path should point to the claude executable or a directory containing claude."
.to_string()
});
}
return Ok(expanded);
}
Ok(program)
}
fn launchable_program_in_dir(dir: &Path, program: &str) -> Option<String> {
program_path_candidates(dir, program)
.into_iter()
.find(|candidate| is_launchable_program_path(candidate) && candidate.is_file())
.map(|path| path.to_string_lossy().to_string())
}
#[cfg(not(windows))]
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
vec![dir.join(program)]
}
#[cfg(windows)]
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
let path = Path::new(program);
if path.extension().is_some() {
return vec![dir.join(program)];
}
[".cmd", ".exe", ".bat", ".com", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect()
}
#[cfg(not(windows))]
fn is_launchable_program_path(_path: &Path) -> bool {
true
}
#[cfg(windows)]
fn is_launchable_program_path(path: &Path) -> bool {
matches!(
path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(),
Some("exe" | "cmd" | "bat" | "com")
)
}
fn is_path_like_program(program: &str) -> bool {
program.contains('/') || program.contains('\\') || program.starts_with('~')
}
fn starts_with_env_assignment(program: &str) -> bool {
let Some(first_token) = program.split_whitespace().next() else {
return false;
};
let Some((key, _)) = first_token.split_once('=') else {
return false;
};
is_env_var_name(key)
}
fn is_env_var_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn is_reserved_dbx_mcp_env_name(name: &str) -> bool {
name.to_ascii_uppercase().starts_with("DBX_MCP_")
}
pub fn claude_code_cli_env(config: &AiConfig) -> Result<Vec<(String, String)>, String> {
let mut env = BTreeMap::new();
for (key, value) in &config.claude_code_cli_env {
let key = key.trim();
if key.is_empty() {
continue;
}
if !is_env_var_name(key) {
return Err(format!(
"[claudeCodeEnvInvalid] Invalid Claude Code CLI environment variable name `{key}`. Use names like HTTPS_PROXY."
));
}
if is_reserved_dbx_mcp_env_name(key) {
return Err(format!(
"[claudeCodeEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here."
));
}
env.insert(key.to_string(), value.clone());
}
Ok(env.into_iter().collect())
}
pub fn claude_code_enabled_tools(agent_mode: bool) -> Vec<String> {
dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("mcp__dbx__{tool}")).collect()
}
fn claude_code_mcp_config(options: &ClaudeCodeRunOptions) -> String {
let mcp_command =
options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server");
let mut server = Map::new();
server.insert("command".to_string(), Value::String(mcp_command.to_string()));
if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) {
server.insert("args".to_string(), json!(command.args));
}
let env = dbx_mcp_scope_env(options)
.into_iter()
.map(|(name, value)| (name.to_string(), Value::String(value)))
.collect::<Map<_, _>>();
server.insert("env".to_string(), Value::Object(env));
json!({
"mcpServers": {
"dbx": Value::Object(server)
}
})
.to_string()
}
pub fn build_claude_code_command(
config: &AiConfig,
_prompt: &str,
options: &ClaudeCodeRunOptions,
) -> ClaudeCodeCommandSpec {
let enabled_tools = claude_code_enabled_tools(options.agent_mode);
let mut args = vec![
"--print".to_string(),
"--output-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
"--input-format".to_string(),
"text".to_string(),
"--no-session-persistence".to_string(),
"--permission-mode".to_string(),
"dontAsk".to_string(),
"--mcp-config".to_string(),
claude_code_mcp_config(options),
"--strict-mcp-config".to_string(),
];
append_claude_code_isolation_args(&mut args);
args.push("--tools".to_string());
args.extend(enabled_tools.iter().cloned());
args.push("--allowedTools".to_string());
args.extend(enabled_tools);
let model = config.model.trim();
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
args.push("--model".to_string());
args.push(model.to_string());
}
if let Some(effort) = config.reasoning_level.as_claude_code_effort() {
args.push("--effort".to_string());
args.push(effort.to_string());
}
ClaudeCodeCommandSpec { program: claude_code_program(config), args }
}
pub fn build_claude_code_prompt(
system_prompt: &str,
messages: &[crate::ai::AiMessage],
allow_write_sql: bool,
) -> String {
build_cli_agent_prompt("Claude Code", system_prompt, messages, allow_write_sql)
}
pub async fn list_claude_code_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
let program = validate_claude_code_program(config)?;
Ok(discover_claude_code_models(config, program).await.unwrap_or_else(|| model_infos(DEFAULT_CLAUDE_CODE_MODELS)))
}
async fn discover_claude_code_models(config: &AiConfig, program: String) -> Option<Vec<AiModelInfo>> {
let mut command = ClaudeCodeCommandSpec {
program,
args: vec![
"--print".to_string(),
"--output-format".to_string(),
"stream-json".to_string(),
"--input-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
],
};
append_claude_code_isolation_args(&mut command.args);
let env = claude_code_process_env(config, &command).ok()?;
let isolated_cwd = ClaudeCodeIsolatedCwd::create().ok()?;
let mut process = cli_command(&command.program);
process
.args(command.args.iter().map(String::as_str))
.envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str())))
.env_remove("CLAUDECODE")
.current_dir(&isolated_cwd.path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = process.spawn().ok()?;
let mut stdin = child.stdin.take()?;
let mut request = serde_json::to_vec(&json!({
"type": "control_request",
"request_id": "dbx_model_discovery",
"request": { "subtype": "initialize" }
}))
.ok()?;
request.push(b'\n');
stdin.write_all(&request).await.ok()?;
drop(stdin);
let output =
tokio::time::timeout(CLAUDE_CODE_MODEL_DISCOVERY_TIMEOUT, child.wait_with_output()).await.ok()?.ok()?;
parse_claude_code_models(&String::from_utf8_lossy(&output.stdout))
}
fn parse_claude_code_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
for line in stdout.lines() {
let Ok(event) = serde_json::from_str::<Value>(line.trim()) else {
continue;
};
if event.get("type").and_then(Value::as_str) != Some("control_response") {
continue;
}
let Some(response) = event.get("response") else {
continue;
};
let data = response.get("response").unwrap_or(response);
let Some(models) = data.get("models").and_then(Value::as_array) else {
continue;
};
let mut seen = BTreeSet::new();
let mut result = Vec::new();
for model in models {
let Some(id) = model
.get("value")
.and_then(Value::as_str)
.or_else(|| model.get("id").and_then(Value::as_str))
.map(str::trim)
.filter(|id| !id.is_empty())
else {
continue;
};
if !seen.insert(id.to_string()) {
continue;
}
let display_name = model
.get("displayName")
.and_then(Value::as_str)
.or_else(|| model.get("display_name").and_then(Value::as_str))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string);
let mut info = AiModelInfo::new(id, display_name);
info.supported_effort_levels = parse_claude_code_effort_levels(model);
result.push(info);
}
if result.is_empty() {
return None;
}
if seen.insert("default".to_string()) {
result.insert(0, AiModelInfo::new("default", Some("Default".to_string())));
}
return Some(result);
}
None
}
fn parse_claude_code_effort_levels(model: &Value) -> Vec<AiEffortLevel> {
if model.get("supportsEffort").or_else(|| model.get("supports_effort")).and_then(Value::as_bool) == Some(false) {
return Vec::new();
}
let Some(levels) =
model.get("supportedEffortLevels").or_else(|| model.get("supported_effort_levels")).and_then(Value::as_array)
else {
return Vec::new();
};
let mut seen = BTreeSet::new();
levels
.iter()
.filter_map(Value::as_str)
.filter_map(|level| level.parse::<AiEffortLevel>().ok())
.filter(|level| seen.insert(*level))
.collect()
}
pub async fn test_claude_code_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
let start = Instant::now();
let claude_command = ClaudeCodeCommandSpec { program: validate_claude_code_program(config)?, args: Vec::new() };
let mut command = cli_command(&claude_command.program);
command.args(claude_command.args.iter().map(String::as_str));
command.args(["auth", "status"]);
command.envs(
claude_code_process_env(config, &claude_command)?.iter().map(|(key, value)| (key.as_str(), value.as_str())),
);
let output = command.output().await.map_err(|e| classify_claude_code_spawn_error(&e.to_string()))?;
if output.status.success() {
Ok(AiTestConnectionResult {
success: true,
message: format!("OK - {}ms", start.elapsed().as_millis()),
latency_ms: Some(start.elapsed().as_millis() as u64),
model_used: config.model.trim().to_string(),
error_category: None,
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let message =
[stderr.trim(), stdout.trim()].into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join("\n");
Err(classify_claude_code_run_error(&message))
}
}
fn classify_claude_code_spawn_error(message: &str) -> String {
if message.contains("No such file") || message.contains("not found") {
"[claudeCodeNotInstalled] Claude Code CLI was not found. Install Claude Code or set the Claude Code CLI path in DBX AI settings."
.to_string()
} else if is_command_line_too_long_error(message) {
"[claudeCodeCommandLineTooLong] Claude Code CLI command line is too long.".to_string()
} else {
format!("[claudeCodeRunFailed] Failed to start Claude Code CLI: {message}")
}
}
fn is_command_line_too_long_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
message.contains("os error 206")
|| message.contains("文件名或扩展名太长")
|| lower.contains("filename or extension is too long")
|| lower.contains("the filename or extension is too long")
}
fn classify_claude_code_run_error(stderr: &str) -> String {
let lower = stderr.to_ascii_lowercase();
if lower.contains("not authenticated") || lower.contains("login") || lower.contains("auth") {
format!(
"[claudeCodeNotAuthenticated] Claude Code CLI is not authenticated. Run `claude auth login` and try again. {stderr}"
)
} else if lower.contains("dbx-mcp-server") || lower.contains("enoent") {
format!("[dbxMcpMissing] DBX MCP server was not found. Install @dbx-app/mcp-server and try again. {stderr}")
} else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) {
format!("[claudeCodeMcpStartupFailed] Claude Code could not start the DBX MCP server. {stderr}")
} else {
format!("[claudeCodeRunFailed] Claude Code CLI failed. {stderr}")
}
}
pub fn parse_claude_code_jsonl_event(line: &str) -> Option<Vec<AgentEvent>> {
parse_cli_jsonl_event(line, CliAgentJsonlDialect::ClaudeCodePrint)
}
pub async fn run_claude_code_agent(
config: &AiConfig,
prompt: &str,
options: ClaudeCodeRunOptions,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<String, String> {
let program = validate_claude_code_program(config)?;
let mut command = build_claude_code_command(config, prompt, &options);
command.program = program;
let env = claude_code_process_env(config, &command)?;
let isolated_cwd = ClaudeCodeIsolatedCwd::create()?;
let result = run_cli_jsonl_agent(
CliAgentProcessSpec {
command,
env,
current_dir: Some(isolated_cwd.path.clone()),
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::ClaudeCodePrint,
classify_spawn_error: classify_claude_code_spawn_error,
classify_run_error: classify_claude_code_run_error,
},
cancelled,
on_event,
)
.await;
result
}
#[cfg(test)]
mod tests {
use super::{
build_claude_code_command, claude_code_cli_env, claude_code_enabled_tools, parse_claude_code_jsonl_event,
parse_claude_code_models, validate_claude_code_program, ClaudeCodeRunOptions, DEFAULT_CLAUDE_CODE_MODELS,
};
#[cfg(unix)]
use super::{list_claude_code_models, run_claude_code_agent};
use crate::agent_events::AgentEvent;
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortLevel, AiModelInfo, AiProvider, AiReasoningLevel};
use crate::ai_cli_agent::{model_infos, CliAgentCommandSpec};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use tokio::sync::Notify;
fn claude_code_config(model: &str) -> AiConfig {
AiConfig {
provider: AiProvider::ClaudeCodeCli,
api_key: String::new(),
auth_method: AiAuthMethod::Bearer,
endpoint: String::new(),
model: model.to_string(),
models: Vec::new(),
api_style: AiApiStyle::Completions,
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
}
}
fn run_options() -> ClaudeCodeRunOptions {
ClaudeCodeRunOptions {
connection_id: "conn-1".to_string(),
connection_name: "local".to_string(),
database: "demo".to_string(),
agent_mode: true,
allow_writes: false,
allow_dangerous: false,
mcp_server_command: None,
}
}
#[cfg(unix)]
fn isolated_cli_test_config() -> (AiConfig, std::path::PathBuf, std::path::PathBuf) {
let project_dir = std::env::temp_dir().join(format!("dbx-claude-project-test-{}", uuid::Uuid::new_v4()));
let claude_dir = project_dir.join(".claude");
let user_config_dir = project_dir.join("user-config");
let hook_marker = project_dir.join("project-hook-loaded");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::create_dir_all(&user_config_dir).unwrap();
std::fs::write(user_config_dir.join("auth-marker"), "authenticated").unwrap();
std::fs::write(
claude_dir.join("settings.json"),
serde_json::to_vec(&serde_json::json!({
"model": "project-model-must-not-load",
"hooks": {
"SessionStart": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": format!("printf loaded > {}", hook_marker.display())
}]
}]
}
}))
.unwrap(),
)
.unwrap();
let executable = project_dir.join("claude");
std::fs::write(
&executable,
r#"#!/bin/sh
user_settings=false
previous=""
for arg in "$@"; do
if [ "$previous" = "--setting-sources" ] && [ "$arg" = "user" ]; then
user_settings=true
fi
previous="$arg"
done
if [ "$user_settings" != "true" ] || [ "$PWD" = "$DBX_TEST_PROJECT_DIR" ]; then
printf loaded > "$DBX_TEST_HOOK_MARKER"
fi
if [ ! -f "$CLAUDE_CONFIG_DIR/auth-marker" ]; then
exit 9
fi
input=$(cat)
case " $* " in
*" --input-format stream-json "*)
printf '%s\n' '{"type":"control_response","response":{"response":{"models":[{"value":"claude-user-model","displayName":"User Model"}]}}}'
;;
*)
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"isolated execution"}]}}'
printf '%s\n' '{"type":"result","subtype":"success"}'
;;
esac
"#,
)
.unwrap();
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
let mut config = claude_code_config("default");
config.claude_code_cli_path = Some(executable.to_string_lossy().to_string());
config
.claude_code_cli_env
.insert("CLAUDE_CONFIG_DIR".to_string(), user_config_dir.to_string_lossy().to_string());
config
.claude_code_cli_env
.insert("DBX_TEST_PROJECT_DIR".to_string(), project_dir.to_string_lossy().to_string());
config
.claude_code_cli_env
.insert("DBX_TEST_HOOK_MARKER".to_string(), hook_marker.to_string_lossy().to_string());
(config, project_dir, hook_marker)
}
#[test]
fn builds_claude_code_command_with_scoped_mcp_and_default_model() {
let spec = build_claude_code_command(&claude_code_config("default"), "hello", &run_options());
assert_eq!(spec.program, "claude");
assert!(spec.args.contains(&"--print".to_string()));
assert!(spec.args.contains(&"stream-json".to_string()));
assert!(spec.args.contains(&"--mcp-config".to_string()));
assert!(spec.args.windows(2).any(|args| args == ["--setting-sources", "user"]));
assert!(
spec.args.iter().position(|arg| arg == "--setting-sources")
< spec.args.iter().position(|arg| arg == "--tools")
);
assert!(!spec.args.contains(&"hello".to_string()));
assert!(!spec.args.contains(&"--model".to_string()));
assert!(!spec.args.contains(&"--effort".to_string()));
assert!(spec.args.iter().any(|arg| arg.contains("\"command\":\"dbx-mcp-server\"")));
assert!(spec.args.iter().any(|arg| arg.contains("\"DBX_MCP_ALLOW_WRITES\":\"0\"")));
assert!(spec.args.iter().any(|arg| arg.contains("\"DBX_MCP_SCOPE_CONNECTION_ID\":\"conn-1\"")));
assert!(spec.args.iter().any(|arg| arg.contains("mcp__dbx__dbx_execute_query")));
}
#[cfg(unix)]
#[tokio::test]
async fn model_discovery_uses_user_settings_from_an_isolated_directory() {
let (config, project_dir, hook_marker) = isolated_cli_test_config();
let models = list_claude_code_models(&config).await.unwrap();
assert!(models.iter().any(|model| model.id == "claude-user-model"));
assert!(!hook_marker.exists());
let _ = std::fs::remove_dir_all(project_dir);
}
#[cfg(unix)]
#[tokio::test]
async fn execution_uses_user_settings_from_an_isolated_directory() {
let (config, project_dir, hook_marker) = isolated_cli_test_config();
let output = run_claude_code_agent(&config, "hello", run_options(), &Notify::new(), |_| {}).await.unwrap();
assert_eq!(output, "isolated execution");
assert!(!hook_marker.exists());
let _ = std::fs::remove_dir_all(project_dir);
}
#[test]
fn builds_claude_code_command_with_custom_mcp_server_and_ask_tools() {
let mut options = run_options();
options.agent_mode = false;
options.mcp_server_command = Some(CliAgentCommandSpec {
program: "/opt/dbx/bin/dbx-mcp-server".to_string(),
args: vec!["--stdio".to_string()],
});
let spec = build_claude_code_command(&claude_code_config("sonnet"), "hello", &options);
let model_pos = spec.args.iter().position(|arg| arg == "--model").unwrap();
assert_eq!(spec.args[model_pos + 1], "sonnet");
assert!(spec.args.iter().any(|arg| arg.contains("\"command\":\"/opt/dbx/bin/dbx-mcp-server\"")));
assert!(spec.args.iter().any(|arg| arg.contains("\"args\":[\"--stdio\"]")));
assert!(!claude_code_enabled_tools(false).iter().any(|tool| tool == "mcp__dbx__dbx_execute_query"));
assert!(!spec.args.iter().any(|arg| arg.contains("mcp__dbx__dbx_execute_query")));
}
#[test]
fn builds_claude_code_command_with_supported_effort() {
let mut config = claude_code_config("sonnet");
config.reasoning_level = AiReasoningLevel::Xhigh;
let spec = build_claude_code_command(&config, "hello", &run_options());
let effort_pos = spec.args.iter().position(|arg| arg == "--effort").unwrap();
assert_eq!(spec.args[effort_pos + 1], "xhigh");
config.reasoning_level = AiReasoningLevel::Minimal;
let spec = build_claude_code_command(&config, "hello", &run_options());
assert!(!spec.args.contains(&"--effort".to_string()));
}
#[test]
fn default_model_list_matches_supported_aliases() {
assert_eq!(model_infos(DEFAULT_CLAUDE_CODE_MODELS), model_infos(&["default", "sonnet", "opus", "fable"]));
}
#[test]
fn parses_discovered_claude_code_models() {
let stdout = concat!(
"not json\n",
r#"{"type":"system","subtype":"init"}"#,
"\n",
r#"{"type":"control_response","response":{"subtype":"success","response":{"commands":[]}}}"#,
"\n",
r#"{"type":"control_response","response":{"subtype":"success","request_id":"dbx_model_discovery","response":{"models":[{"value":"default","displayName":"Default","resolvedModel":"claude-sonnet"},{"value":"claude-sonnet-4-6","displayName":"Sonnet 4.6","supportsEffort":true,"supportedEffortLevels":["low","medium","high","max","high","future"]},{"value":"claude-sonnet-4-6","displayName":"Duplicate"},{"value":"claude-opus-4-8","display_name":"Opus 4.8","supports_effort":false,"supported_effort_levels":["low"]}]}}}"#
);
let models = parse_claude_code_models(stdout).unwrap();
assert_eq!(
models,
vec![
AiModelInfo::new("default", Some("Default".to_string())),
AiModelInfo {
id: "claude-sonnet-4-6".to_string(),
display_name: Some("Sonnet 4.6".to_string()),
supported_effort_levels: vec![
AiEffortLevel::Low,
AiEffortLevel::Medium,
AiEffortLevel::High,
AiEffortLevel::Max
],
},
AiModelInfo::new("claude-opus-4-8", Some("Opus 4.8".to_string())),
]
);
}
#[test]
fn adds_default_to_discovered_claude_code_models_when_missing() {
let stdout = r#"{"type":"control_response","response":{"models":[{"id":"claude-haiku-4-5","displayName":"Haiku 4.5"}]}}"#;
let models = parse_claude_code_models(stdout).unwrap();
assert_eq!(models[0].id, "default");
assert_eq!(models[1].id, "claude-haiku-4-5");
}
#[test]
fn rejects_claude_code_initialize_response_without_models() {
let stdout = r#"{"type":"control_response","response":{"subtype":"success","response":{"commands":[]}}}"#;
assert!(parse_claude_code_models(stdout).is_none());
}
#[test]
fn normalizes_claude_code_cli_env() {
let mut config = claude_code_config("default");
config.claude_code_cli_env.insert(" HTTPS_PROXY ".to_string(), "http://proxy:9800".to_string());
config.claude_code_cli_env.insert("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string());
let env = claude_code_cli_env(&config).unwrap();
assert_eq!(
env,
vec![
("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string()),
("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string())
]
);
}
#[test]
fn rejects_reserved_dbx_mcp_env_name() {
let mut config = claude_code_config("default");
config.claude_code_cli_env.insert("DBX_MCP_SCOPE_DATABASE".to_string(), "main".to_string());
let err = claude_code_cli_env(&config).unwrap_err();
assert!(err.contains("[claudeCodeEnvReserved]"));
}
#[test]
fn rejects_shell_style_env_prefix_in_claude_code_cli_path() {
let mut config = claude_code_config("default");
config.claude_code_cli_path = Some("HTTPS_PROXY=http://proxy:9800 /opt/homebrew/bin/claude".to_string());
let err = validate_claude_code_program(&config).unwrap_err();
assert!(err.contains("[claudeCodeCliPathInvalid]"));
assert!(err.contains("environment variables section"));
}
#[test]
fn resolves_claude_code_executable_from_configured_directory() {
let dir = std::env::temp_dir().join(format!("dbx-claude-code-dir-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let executable = dir.join(if cfg!(windows) { "claude.cmd" } else { "claude" });
std::fs::write(&executable, "").unwrap();
let mut config = claude_code_config("default");
config.claude_code_cli_path = Some(dir.to_string_lossy().to_string());
let resolved = validate_claude_code_program(&config).unwrap();
assert_eq!(resolved, executable.to_string_lossy());
let _ = std::fs::remove_file(executable);
let _ = std::fs::remove_dir(dir);
}
#[test]
fn parses_claude_code_jsonl_events() {
let started = parse_claude_code_jsonl_event(
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"mcp__dbx__dbx_list_tables","input":{"schema":"public"}}]}}"#,
)
.unwrap();
assert!(
matches!(&started[0], AgentEvent::ToolCallStart { tool_name, args, .. } if tool_name == "mcp__dbx__dbx_list_tables" && args["schema"] == "public")
);
let text = parse_claude_code_jsonl_event(
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Done"}]}}"#,
)
.unwrap();
assert!(matches!(&text[0], AgentEvent::TextDelta { delta } if delta == "Done"));
let tool_result = parse_claude_code_jsonl_event(
r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"users"}],"is_error":false}]}}"#,
)
.unwrap();
assert!(
matches!(&tool_result[0], AgentEvent::ToolCallEnd { tool_call_id, result, is_error, .. } if tool_call_id == "tool-1" && result[0]["text"] == "users" && !is_error)
);
let done = parse_claude_code_jsonl_event(
r#"{"type":"result","subtype":"success","usage":{"input_tokens":12,"output_tokens":3}}"#,
)
.unwrap();
assert!(matches!(&done[0], AgentEvent::AgentEnd { input_tokens: Some(12), output_tokens: Some(3) }));
let failed = parse_claude_code_jsonl_event(
r#"{"type":"result","subtype":"error_max_turns","message":"too many turns"}"#,
)
.unwrap();
assert!(matches!(&failed[0], AgentEvent::Error { message } if message == "too many turns"));
}
}

View File

@ -3,6 +3,7 @@ use crate::ai::{AiMessage, AiModelInfo};
use crate::token_usage::TokenUsage;
use serde_json::Value;
use std::ffi::OsStr;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
@ -28,11 +29,13 @@ pub struct CliAgentCommandSpec {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliAgentJsonlDialect {
CodexExec,
ClaudeCodePrint,
}
pub struct CliAgentProcessSpec {
pub command: CliAgentCommandSpec,
pub env: Vec<(String, String)>,
pub current_dir: Option<PathBuf>,
pub stdin: Option<String>,
pub dialect: CliAgentJsonlDialect,
pub classify_spawn_error: fn(&str) -> String,
@ -109,7 +112,7 @@ pub fn build_cli_agent_prompt(
}
pub fn model_infos(ids: &[&str]) -> Vec<AiModelInfo> {
ids.iter().map(|id| AiModelInfo { id: (*id).to_string(), display_name: None }).collect()
ids.iter().map(|id| AiModelInfo::new(*id, None)).collect()
}
pub fn cli_command(program: impl AsRef<OsStr>) -> Command {
@ -143,8 +146,8 @@ pub async fn list_json_models_or_default(
if models.is_empty() {
Ok(model_infos(default_models))
} else {
let mut result = vec![AiModelInfo { id: "default".to_string(), display_name: None }];
result.extend(models.into_iter().map(|id| AiModelInfo { id, display_name: None }));
let mut result = vec![AiModelInfo::new("default", None)];
result.extend(models.into_iter().map(|id| AiModelInfo::new(id, None)));
Ok(result)
}
}
@ -190,6 +193,7 @@ pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Optio
fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliAgentEvent {
match dialect {
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line),
}
}
@ -351,15 +355,154 @@ fn codex_error_message(value: &Value) -> String {
.to_string()
}
fn parse_claude_code_jsonl_line(line: &str) -> ParsedCliAgentEvent {
let Ok(value) = serde_json::from_str::<Value>(line) else {
return ParsedCliAgentEvent::default();
};
match value.get("type").and_then(Value::as_str).unwrap_or_default() {
"assistant" => parse_claude_code_assistant(&value),
"user" => parse_claude_code_user(&value),
"result" => parse_claude_code_result(&value),
"error" => {
let message = claude_code_error_message(&value);
ParsedCliAgentEvent {
error: Some(message.clone()),
events: vec![AgentEvent::Error { message }],
..Default::default()
}
}
_ => ParsedCliAgentEvent::default(),
}
}
fn parse_claude_code_assistant(value: &Value) -> ParsedCliAgentEvent {
let content = value.get("message").and_then(|message| message.get("content")).or_else(|| value.get("content"));
let Some(content) = content else {
return ParsedCliAgentEvent::default();
};
let mut events = Vec::new();
let mut final_text = String::new();
for block in claude_content_blocks(content) {
match block.get("type").and_then(Value::as_str).unwrap_or_default() {
"text" => {
if let Some(text) = block.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) {
final_text.push_str(text);
events.push(AgentEvent::TextDelta { delta: text.to_string() });
}
}
"thinking" => {
if let Some(text) = block.get("thinking").and_then(Value::as_str).filter(|text| !text.is_empty()) {
events.push(AgentEvent::ReasoningDelta { delta: text.to_string() });
}
}
"tool_use" => {
events.push(AgentEvent::ToolCallStart {
tool_call_id: block
.get("id")
.and_then(Value::as_str)
.unwrap_or("claude-code-tool-call")
.to_string(),
tool_name: block.get("name").and_then(Value::as_str).unwrap_or("claude_code_tool").to_string(),
args: block.get("input").cloned().unwrap_or_else(|| Value::Object(Default::default())),
});
}
_ => {}
}
}
ParsedCliAgentEvent { final_text: (!final_text.is_empty()).then_some(final_text), events, ..Default::default() }
}
fn parse_claude_code_user(value: &Value) -> ParsedCliAgentEvent {
let content = value.get("message").and_then(|message| message.get("content")).or_else(|| value.get("content"));
let Some(content) = content else {
return ParsedCliAgentEvent::default();
};
let mut events = Vec::new();
for block in claude_content_blocks(content) {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
events.push(AgentEvent::ToolCallEnd {
tool_call_id: block
.get("tool_use_id")
.and_then(Value::as_str)
.or_else(|| block.get("id").and_then(Value::as_str))
.unwrap_or("claude-code-tool-call")
.to_string(),
tool_name: "mcp_tool".to_string(),
result: block.get("content").cloned().unwrap_or(Value::Null),
is_error: block.get("is_error").and_then(Value::as_bool).unwrap_or(false),
});
}
ParsedCliAgentEvent { events, ..Default::default() }
}
fn parse_claude_code_result(value: &Value) -> ParsedCliAgentEvent {
let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("success");
if subtype != "success" {
let message = claude_code_error_message(value);
return ParsedCliAgentEvent {
error: Some(message.clone()),
events: vec![AgentEvent::Error { message }],
..Default::default()
};
}
let usage = value.get("usage").and_then(|usage| {
let input =
usage.get("input_tokens").or_else(|| usage.get("prompt_tokens")).and_then(Value::as_u64).unwrap_or(0)
as u32;
let output =
usage.get("output_tokens").or_else(|| usage.get("completion_tokens")).and_then(Value::as_u64).unwrap_or(0)
as u32;
(input > 0 || output > 0).then_some(TokenUsage { input_tokens: input, output_tokens: output })
});
ParsedCliAgentEvent {
events: vec![AgentEvent::AgentEnd {
input_tokens: usage.as_ref().and_then(|u| (u.input_tokens > 0).then_some(u.input_tokens)),
output_tokens: usage.as_ref().and_then(|u| (u.output_tokens > 0).then_some(u.output_tokens)),
}],
..Default::default()
}
}
fn claude_content_blocks(content: &Value) -> Vec<Value> {
match content {
Value::Array(blocks) => blocks.clone(),
Value::String(text) => vec![serde_json::json!({ "type": "text", "text": text })],
Value::Object(_) => vec![content.clone()],
_ => Vec::new(),
}
}
fn claude_code_error_message(value: &Value) -> String {
value
.get("error")
.and_then(Value::as_str)
.or_else(|| value.get("message").and_then(Value::as_str))
.or_else(|| value.get("error").and_then(|error| error.get("message")).and_then(Value::as_str))
.or_else(|| value.get("result").and_then(Value::as_str))
.unwrap_or("Claude Code CLI failed")
.to_string()
}
pub async fn run_cli_jsonl_agent(
spec: CliAgentProcessSpec,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<String, String> {
let mut command = cli_command(&spec.command.program);
command.args(&spec.command.args).envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str())));
if let Some(current_dir) = &spec.current_dir {
command.current_dir(current_dir);
}
let mut child = command
.args(&spec.command.args)
.envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str())))
.stdin(if spec.stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@ -476,6 +619,7 @@ mod tests {
],
},
env: vec![("DBX_TEST_ENV".to_string(), "from-env".to_string())],
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::CodexExec,
classify_spawn_error,
@ -498,6 +642,7 @@ mod tests {
],
},
env: Vec::new(),
current_dir: None,
stdin: Some("prompt from stdin".to_string()),
dialect: CliAgentJsonlDialect::CodexExec,
classify_spawn_error,
@ -524,6 +669,7 @@ mod tests {
let spec = CliAgentProcessSpec {
command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] },
env: Vec::new(),
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::CodexExec,
classify_spawn_error,

View File

@ -463,7 +463,7 @@ fn parse_codex_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
let data = serde_json::from_str::<Value>(&stdout[json_start..]).ok()?;
let models = data.get("models").and_then(Value::as_array)?;
let mut result = vec![AiModelInfo { id: "default".to_string(), display_name: Some("Default".to_string()) }];
let mut result = vec![AiModelInfo::new("default", Some("Default".to_string()))];
for model in models {
let Some(id) = model
.get("slug")
@ -483,7 +483,7 @@ fn parse_codex_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string);
result.push(AiModelInfo { id: id.to_string(), display_name });
result.push(AiModelInfo::new(id, display_name));
}
(result.len() > 1).then_some(result)
@ -568,6 +568,7 @@ pub async fn run_codex_agent(
CliAgentProcessSpec {
command,
env,
current_dir: None,
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::CodexExec,
classify_spawn_error: classify_codex_spawn_error,
@ -614,6 +615,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
}
}

View File

@ -1074,6 +1074,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
},
}
}

View File

@ -9,6 +9,7 @@ pub mod agent_runtime;
pub mod agent_service;
pub mod agent_tools;
pub mod ai;
pub mod ai_claude_code_cli;
pub mod ai_cli_agent;
pub mod ai_codex_cli;
pub mod changelog;

View File

@ -3752,7 +3752,9 @@ mod tests {
// ---- AI Config tests ----
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem, AiProvider, AiReasoningLevel};
use crate::ai::{
AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem, AiEffortLevel, AiModelListItem, AiProvider, AiReasoningLevel,
};
fn make_ai_config(name: &str, is_default: bool) -> AiConfigItem {
AiConfigItem {
@ -3774,6 +3776,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: std::collections::HashMap::new(),
claude_code_cli_path: None,
claude_code_cli_env: std::collections::HashMap::new(),
},
}
}
@ -3783,7 +3787,15 @@ mod tests {
let db = temp_db_path("ai-roundtrip");
let storage = Storage::open(&db).await.unwrap();
let cfg = make_ai_config("test-config", true);
let mut cfg = make_ai_config("test-config", true);
cfg.config.provider = AiProvider::ClaudeCodeCli;
cfg.config.model = "claude-sonnet-4-6".to_string();
cfg.config.reasoning_level = AiReasoningLevel::Xhigh;
cfg.config.models = vec![AiModelListItem {
name: "claude-sonnet-4-6".to_string(),
label: Some("Sonnet 4.6".to_string()),
supported_effort_levels: vec![AiEffortLevel::Low, AiEffortLevel::High, AiEffortLevel::Xhigh],
}];
storage.save_ai_config_item(&cfg).await.unwrap();
let loaded = storage.load_ai_configs().await.unwrap();
@ -3791,7 +3803,14 @@ mod tests {
assert_eq!(loaded[0].id, "cfg-test-config");
assert_eq!(loaded[0].name, "test-config");
assert!(loaded[0].is_default);
assert_eq!(loaded[0].config.model, "gpt-4o");
assert_eq!(loaded[0].config.model, "claude-sonnet-4-6");
assert_eq!(loaded[0].config.reasoning_level, AiReasoningLevel::Xhigh);
assert_eq!(loaded[0].config.models.len(), 1);
assert_eq!(loaded[0].config.models[0].name, "claude-sonnet-4-6");
assert_eq!(
loaded[0].config.models[0].supported_effort_levels,
vec![AiEffortLevel::Low, AiEffortLevel::High, AiEffortLevel::Xhigh]
);
std::fs::remove_file(&db).ok();
}

View File

@ -93,8 +93,8 @@ fn default_agent_mode() -> String {
}
fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> {
if matches!(config.provider, AiProvider::CodexCli) {
return Err(AppError::bad_request("Codex CLI provider is only supported in DBX Desktop."));
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
return Err(AppError::bad_request("CLI providers are only supported in DBX Desktop."));
}
Ok(())
}
@ -412,6 +412,8 @@ mod tests {
context_window: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
}
}

View File

@ -395,6 +395,10 @@ test("AI provider presets include common hosted and local providers", () => {
assert.equal(AI_PROVIDER_PRESETS.openai.authMethod, "bearer");
assert.equal(AI_PROVIDER_PRESETS.openai.iconSlug, "openai");
assert.equal(AI_PROVIDER_PRESETS.deepseek.iconSlug, "deepseek");
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].iconSlug, "claudecode");
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].requiresApiKey, false);
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli"));
});
test("normalizes legacy AI config and fills provider defaults", () => {
@ -418,6 +422,32 @@ test("normalizes legacy AI config and fills provider defaults", () => {
const claudeToken = normalizeAiConfig({ provider: "claude", apiKey: "token", authMethod: "bearer" } as any);
assert.equal(claudeToken.authMethod, "bearer");
const claudeCode = normalizeAiConfig({
provider: "claude-code-cli",
claudeCodeCliPath: " /opt/homebrew/bin/claude ",
claudeCodeCliEnv: { HTTPS_PROXY: "http://proxy:9800" },
reasoningLevel: "xhigh",
models: [
{
name: "claude-sonnet-4-6",
label: "Sonnet 4.6",
supportedEffortLevels: ["low", "high", "xhigh"],
},
],
} as any);
assert.equal(claudeCode.claudeCodeCliPath, "/opt/homebrew/bin/claude");
assert.deepEqual(claudeCode.claudeCodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
assert.equal(claudeCode.reasoningLevel, "xhigh");
assert.deepEqual(claudeCode.models, [
{
name: "claude-sonnet-4-6",
label: "Sonnet 4.6",
supportedEffortLevels: ["low", "high", "xhigh"],
},
]);
assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "max" } as any).reasoningLevel, "max");
assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "future" } as any).reasoningLevel, "default");
});
test("infers legacy AI provider from saved endpoint and model", () => {

View File

@ -7,13 +7,13 @@ pub use dbx_core::ai::*;
#[tauri::command]
pub async fn ai_test_connection(config: AiConfig) -> Result<AiTestConnectionResult, String> {
let config = resolve_codex_cli_config(config);
let config = resolve_cli_provider_config(config);
dbx_core::ai::test_connection_core(&config).await
}
#[tauri::command]
pub async fn ai_list_models(config: AiConfig) -> Result<Vec<AiModelInfo>, String> {
let config = resolve_codex_cli_config(config);
let config = resolve_cli_provider_config(config);
dbx_core::ai::list_models_core(&config).await
}
@ -88,12 +88,12 @@ pub async fn ai_agent_stream(
mode: Option<String>,
allow_write_sql: Option<bool>,
) -> Result<String, String> {
let request = resolve_codex_cli_request(request);
let request = resolve_cli_provider_request(request);
let parsed_db_type: DatabaseType =
serde_json::from_str(&format!("\"{}\"", db_type)).map_err(|_| format!("Unknown database type: {db_type}"))?;
let cli_mcp_server_command = if matches!(request.config.provider, AiProvider::CodexCli) {
let cli_mcp_server_command = if is_cli_provider(&request.config.provider) {
let (program, args) = super::mcp::resolve_mcp_server_command().await?;
Some(CliAgentCommandSpec { program, args })
} else {
@ -142,27 +142,32 @@ pub async fn ai_agent_stream(
result
}
fn resolve_codex_cli_request(mut request: AiCompletionRequest) -> AiCompletionRequest {
request.config = resolve_codex_cli_config(request.config);
fn resolve_cli_provider_request(mut request: AiCompletionRequest) -> AiCompletionRequest {
request.config = resolve_cli_provider_config(request.config);
request
}
fn resolve_codex_cli_config(mut config: AiConfig) -> AiConfig {
if !matches!(config.provider, AiProvider::CodexCli) {
return config;
}
let command = config.codex_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("codex");
fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig {
let (path_slot, default_command) = match config.provider {
AiProvider::CodexCli => (&mut config.codex_cli_path, "codex"),
AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"),
_ => return config,
};
let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command);
if is_explicit_cli_path(command) {
return config;
}
if let Some(path) = super::mcp::locate_command(command) {
config.codex_cli_path = Some(path);
*path_slot = Some(path);
}
config
}
fn is_cli_provider(provider: &AiProvider) -> bool {
matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli)
}
fn is_explicit_cli_path(command: &str) -> bool {
let path = Path::new(command);
path.is_absolute() || command.contains('/') || command.contains('\\')