feat(ai): add Qoder CLI provider

This commit is contained in:
lewis authored and GitHub committed 2026-08-11 21:33:15 +08:00
1 parent da6e2738ce
commit c72b429899
38 files changed
+1100 -19

No files matched your search

@@ -57,6 +57,7 @@ import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { buildAiContext, resolveAiDatabaseTarget, resolveAiNamespaceSelection, resolveDefaultAiSchema, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai";
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { addConfiguredAiModel, aiModelOptions } from "@/lib/ai/aiConfigList";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
@@ -105,6 +106,7 @@ const { openTableTarget } = useNavigationTargets({
});
const { toast } = useToast();
const { isDark } = useTheme();
const supportsCliProviders = isTauriRuntime();
type AiMessageMention =
| {
@@ -299,7 +301,7 @@ function submitEdit(visibleIndex: number) {
const actualIndex = visibleToActualIndex(messages.value, visibleIndex);
if (actualIndex < 0) return;
if (!props.connection || !props.tab) return;
if (!settings.isConfigured) {
if (!activeFullConfig.value) {
toast(t("ai.noConfig"));
return;
}
@@ -339,7 +341,7 @@ const { catalogs: modelCatalogs, effortCatalogs, loadModels, resolveEffort, effo
// Configured providers for quick switching - get from aiConfigs
const configuredProviders = computed(() => {
const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey)));
const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter(isModelCandidate));
if (modelSearchQuery.value.trim()) {
const query = modelSearchQuery.value.trim().toLowerCase();
return providers.filter((c) => {
@@ -354,11 +356,15 @@ const configuredProviders = computed(() => {
const activeFullConfig = computed(() => {
if (!settings.activeModel) return null;
const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId);
if (!item) return null;
if (!item || !isModelCandidate(item)) return null;
const modelId = settings.activeModel.modelId;
return normalizeAiConfig({ ...item, model: modelId, runtimeEffort: runtimeEffortFromPreference(settings.activeEffort) });
});
function isModelCandidate(config: AiConfigItem): boolean {
return isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey, supportsCliProviders);
}
function getModelsForConfig(configId: string) {
const config = settings.aiConfigs.find((item) => item.id === configId);
if (!config) return [];
@@ -392,7 +398,7 @@ function toggleModelConfig(configId: string) {
}
async function loadConfiguredModelCatalogs(force = false) {
const configs = settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey));
const configs = settings.aiConfigs.filter(isModelCandidate);
const queue = [...configs];
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
while (queue.length) {
@@ -1707,7 +1713,8 @@ async function send() {
clearPendingWriteGrant();
return;
}
if (!settings.isConfigured) {
const activeConfig = activeFullConfig.value;
if (!activeConfig) {
clearPendingWriteGrant();
toast(t("ai.noConfig"));
return;
@@ -1820,7 +1827,7 @@ async function send() {
const history: AiMessage[] = messagesForAgentHistory(messages.value.slice(0, -2));
await runAgentStream(
{
config: activeFullConfig.value!,
config: activeConfig,
action: requestedAction,
mode: requestedMode,
instruction: modelInstruction,
@@ -2668,7 +2668,7 @@ function normalizeMaxRetries(value: number | undefined): number {
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli"]);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"]);
const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]);
const CURSOR_CONTROL_ENV = new Set(["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
@@ -2700,6 +2700,8 @@ const aiEditGrokCliPath = ref("");
const aiEditGrokCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditCodeBuddyCliPath = ref("");
const aiEditCodeBuddyCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditQoderCliPath = ref("");
const aiEditQoderCliEnvRows = ref<AiEnvRow[]>([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
@@ -2734,6 +2736,7 @@ const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli");
const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-cli");
const aiIsGrokCli = computed(() => aiEditProvider.value === "grok-cli");
const aiIsCodeBuddyCli = computed(() => aiEditProvider.value === "codebuddy-cli");
const aiIsQoderCli = computed(() => aiEditProvider.value === "qoder-cli");
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => {
@@ -2743,6 +2746,7 @@ const aiCliCommandName = computed(() => {
if (aiIsCursorCli.value) return "agent";
if (aiIsGrokCli.value) return "grok";
if (aiIsCodeBuddyCli.value) return "codebuddy";
if (aiIsQoderCli.value) return "qodercli";
return "codex";
});
const aiCliLoginCommand = computed(() => {
@@ -2752,6 +2756,7 @@ const aiCliLoginCommand = computed(() => {
if (aiIsCursorCli.value) return "agent login";
if (aiIsGrokCli.value) return "grok login";
if (aiIsCodeBuddyCli.value) return "codebuddy";
if (aiIsQoderCli.value) return "qodercli login";
return "codex login";
});
const aiEditCliPath = computed({
@@ -2762,6 +2767,7 @@ const aiEditCliPath = computed({
if (aiIsCursorCli.value) return aiEditCursorCliPath.value;
if (aiIsGrokCli.value) return aiEditGrokCliPath.value;
if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliPath.value;
if (aiIsQoderCli.value) return aiEditQoderCliPath.value;
return aiEditCodexCliPath.value;
},
set: (value: string) => {
@@ -2777,6 +2783,8 @@ const aiEditCliPath = computed({
aiEditGrokCliPath.value = value;
} else if (aiIsCodeBuddyCli.value) {
aiEditCodeBuddyCliPath.value = value;
} else if (aiIsQoderCli.value) {
aiEditQoderCliPath.value = value;
} else {
aiEditCodexCliPath.value = value;
}
@@ -2789,6 +2797,7 @@ const aiEditCliEnvRows = computed(() => {
if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value;
if (aiIsGrokCli.value) return aiEditGrokCliEnvRows.value;
if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliEnvRows.value;
if (aiIsQoderCli.value) return aiEditQoderCliEnvRows.value;
return aiEditCodexCliEnvRows.value;
});
watch(aiIsCliProvider, (isCliProvider) => {
@@ -2889,6 +2898,8 @@ function removeCliEnvRow(id: string) {
aiEditGrokCliEnvRows.value = aiEditGrokCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsCodeBuddyCli.value) {
aiEditCodeBuddyCliEnvRows.value = aiEditCodeBuddyCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsQoderCli.value) {
aiEditQoderCliEnvRows.value = aiEditQoderCliEnvRows.value.filter((row) => row.id !== id);
} else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
}
@@ -2925,6 +2936,8 @@ function currentAiEditConfig() {
grokCliEnv: aiIsGrokCli.value ? cliEnvFromRows(aiEditGrokCliEnvRows.value) : {},
codebuddyCliPath: aiEditCodeBuddyCliPath.value.trim() || undefined,
codebuddyCliEnv: aiIsCodeBuddyCli.value ? cliEnvFromRows(aiEditCodeBuddyCliEnvRows.value) : {},
qoderCliPath: aiEditQoderCliPath.value.trim() || undefined,
qoderCliEnv: aiIsQoderCli.value ? cliEnvFromRows(aiEditQoderCliEnvRows.value) : {},
};
}
@@ -3002,6 +3015,8 @@ function aiEnterEditMode(configId?: string) {
aiEditGrokCliEnvRows.value = aiEnvRowsFromConfig(config.grokCliEnv);
aiEditCodeBuddyCliPath.value = config.codebuddyCliPath ?? "";
aiEditCodeBuddyCliEnvRows.value = aiEnvRowsFromConfig(config.codebuddyCliEnv);
aiEditQoderCliPath.value = config.qoderCliPath ?? "";
aiEditQoderCliEnvRows.value = aiEnvRowsFromConfig(config.qoderCliEnv);
}
} else {
aiEditConfigName.value = "";
@@ -3031,6 +3046,8 @@ function aiEnterEditMode(configId?: string) {
aiEditGrokCliEnvRows.value = [];
aiEditCodeBuddyCliPath.value = "";
aiEditCodeBuddyCliEnvRows.value = [];
aiEditQoderCliPath.value = "";
aiEditQoderCliEnvRows.value = [];
}
}
@@ -166,6 +166,30 @@ describe("useAiModelCatalog", () => {
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
});
it("tracks Qoder executable and environment changes without depending on environment key order", async () => {
const initial: AiConfigItem = {
...config(),
provider: "qoder-cli",
endpoint: "",
apiKey: "",
model: "default",
qoderCliPath: "/opt/homebrew/bin/qodercli",
qoderCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", NO_PROXY: "localhost" },
};
apiMock.aiListModels.mockResolvedValueOnce([{ id: "first" }]).mockResolvedValueOnce([{ id: "second" }]);
await expect(catalog.loadModels(initial)).resolves.toEqual([{ id: "first" }]);
await expect(
catalog.loadModels({
...initial,
qoderCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" },
}),
).resolves.toEqual([{ id: "first" }]);
await expect(catalog.loadModels({ ...initial, qoderCliPath: "/usr/local/bin/qodercli" })).resolves.toEqual([{ id: "second" }]);
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
});
it("does not let a stale request overwrite a newer provider catalog", async () => {
let resolveInitial: ((models: { id: string }[]) => void) | undefined;
apiMock.aiListModels
@@ -60,6 +60,7 @@ function configSignature(config: AiConfigItem): string {
opencodeCliPath: config.opencodeCliPath ?? null,
cursorCliPath: config.cursorCliPath ?? null,
codebuddyCliPath: config.codebuddyCliPath ?? null,
qoderCliPath: config.qoderCliPath ?? null,
connectionFingerprint: fingerprint(
JSON.stringify({
apiKey: config.apiKey,
@@ -71,6 +72,7 @@ function configSignature(config: AiConfigItem): string {
opencodeCliEnv: sortedRecord(config.opencodeCliEnv),
cursorCliEnv: sortedRecord(config.cursorCliEnv),
codebuddyCliEnv: sortedRecord(config.codebuddyCliEnv),
qoderCliEnv: sortedRecord(config.qoderCliEnv),
}),
),
});
+10
View File
@@ -62,6 +62,16 @@ const taggedAiCliErrorKeys: Record<string, string> = {
codeBuddyMcpStartupFailed: "ai.cliErrors.codeBuddyMcpStartupFailed",
codeBuddyProtocolError: "ai.cliErrors.codeBuddyProtocolError",
codeBuddyRunFailed: "ai.cliErrors.codeBuddyRunFailed",
qoderNotInstalled: "ai.cliErrors.qoderNotInstalled",
qoderCliPathInvalid: "ai.cliErrors.qoderCliPathInvalid",
qoderEnvInvalid: "ai.cliErrors.qoderEnvInvalid",
qoderEnvReserved: "ai.cliErrors.qoderEnvReserved",
qoderNotAuthenticated: "ai.cliErrors.qoderNotAuthenticated",
qoderTimeout: "ai.cliErrors.qoderTimeout",
qoderMcpConfigInvalid: "ai.cliErrors.qoderMcpConfigInvalid",
qoderMcpStartupFailed: "ai.cliErrors.qoderMcpStartupFailed",
qoderProtocolError: "ai.cliErrors.qoderProtocolError",
qoderRunFailed: "ai.cliErrors.qoderRunFailed",
cursorEnvInvalid: "ai.cliErrors.cursorEnvInvalid",
cursorEnvReserved: "ai.cliErrors.cursorEnvReserved",
cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated",
+10
View File
@@ -2417,6 +2417,16 @@ export default {
codeBuddyMcpStartupFailed: "CodeBuddy Code loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.",
codeBuddyProtocolError: "CodeBuddy Code returned an invalid control response or JSON event stream. Check the CodeBuddy Code version and diagnostics below.",
codeBuddyRunFailed: "CodeBuddy Code CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
qoderNotInstalled: "Qoder CLI was not found. Install Qoder CLI or set its executable path in Settings > AI.",
qoderCliPathInvalid: "The Qoder CLI path is invalid. Select only the qodercli executable and configure environment variables separately.",
qoderEnvInvalid: "A Qoder CLI environment variable name is invalid. Use names such as HTTPS_PROXY or QODER_PERSONAL_ACCESS_TOKEN.",
qoderEnvReserved: "A DBX-managed MCP environment variable was overridden. Remove DBX_MCP_* variables from the provider configuration.",
qoderNotAuthenticated: "Qoder CLI is not authenticated. Run `qodercli login` in a terminal or configure QODER_PERSONAL_ACCESS_TOKEN, then try again.",
qoderTimeout: "Qoder CLI did not respond before the operation timed out.",
qoderMcpConfigInvalid: "Qoder CLI could not read the temporary DBX MCP configuration. Check the Qoder CLI version and diagnostics below.",
qoderMcpStartupFailed: "Qoder CLI loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.",
qoderProtocolError: "Qoder CLI returned an invalid JSON event stream. Check the Qoder CLI version and diagnostics below.",
qoderRunFailed: "Qoder CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
cursorEnvInvalid: "A Cursor CLI environment variable name is invalid. Use names such as HTTPS_PROXY.",
cursorEnvReserved: "A Cursor or DBX-managed environment variable was overridden. Remove CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, and DBX_MCP_* variables from the provider configuration.",
cursorNotAuthenticated: "Cursor CLI is not authenticated. Run `agent login` in a terminal and try again.",
+10
View File
@@ -2263,6 +2263,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "CodeBuddy Code cargó la configuración MCP pero no pudo iniciar DBX MCP Server. Revisa Ajustes > MCP y los diagnósticos siguientes.",
codeBuddyProtocolError: "CodeBuddy Code devolvió una respuesta de control o un flujo de eventos JSON no válido. Revisa la versión de CodeBuddy Code y los diagnósticos siguientes.",
codeBuddyRunFailed: "CodeBuddy Code CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.",
qoderNotInstalled: "No se encontró Qoder CLI. Instálalo o configura la ruta de su ejecutable en Configuración > IA.",
qoderCliPathInvalid: "La ruta de Qoder CLI no es válida. Selecciona únicamente el ejecutable qodercli y configura las variables de entorno por separado.",
qoderEnvInvalid: "El nombre de una variable de entorno de Qoder CLI no es válido. Usa nombres como HTTPS_PROXY o QODER_PERSONAL_ACCESS_TOKEN.",
qoderEnvReserved: "Se sobrescribió una variable de entorno MCP administrada por DBX. Elimina las variables DBX_MCP_* de la configuración del proveedor.",
qoderNotAuthenticated: "Qoder CLI no está autenticado. Ejecuta `qodercli login` en una terminal o configura QODER_PERSONAL_ACCESS_TOKEN y vuelve a intentarlo.",
qoderTimeout: "Qoder CLI no respondió antes de que se agotara el tiempo de espera.",
qoderMcpConfigInvalid: "Qoder CLI no pudo leer la configuración MCP temporal de DBX. Comprueba la versión de Qoder CLI y los diagnósticos siguientes.",
qoderMcpStartupFailed: "Qoder CLI cargó la configuración MCP, pero no pudo iniciar DBX MCP Server. Comprueba Configuración > MCP y los diagnósticos siguientes.",
qoderProtocolError: "Qoder CLI devolvió un flujo de eventos JSON no válido. Comprueba la versión de Qoder CLI y los diagnósticos siguientes.",
qoderRunFailed: "Qoder CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.",
},
run: "Ejecutar",
readingSchema: "Leyendo esquema",
+10
View File
@@ -2403,6 +2403,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "CodeBuddy Code ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.",
codeBuddyProtocolError: "CodeBuddy Code ha restituito una risposta di controllo o un flusso di eventi JSON non valido. Controlla la versione di CodeBuddy Code e i dettagli seguenti.",
codeBuddyRunFailed: "CodeBuddy Code CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.",
qoderNotInstalled: "Qoder CLI non è stato trovato. Installalo oppure configura il percorso dell'eseguibile in Impostazioni > IA.",
qoderCliPathInvalid: "Il percorso di Qoder CLI non è valido. Seleziona solo l'eseguibile qodercli e configura separatamente le variabili di ambiente.",
qoderEnvInvalid: "Il nome di una variabile di ambiente di Qoder CLI non è valido. Usa nomi come HTTPS_PROXY o QODER_PERSONAL_ACCESS_TOKEN.",
qoderEnvReserved: "È stata sovrascritta una variabile di ambiente MCP gestita da DBX. Rimuovi le variabili DBX_MCP_* dalla configurazione del provider.",
qoderNotAuthenticated: "Qoder CLI non è autenticato. Esegui `qodercli login` in un terminale oppure configura QODER_PERSONAL_ACCESS_TOKEN, quindi riprova.",
qoderTimeout: "Qoder CLI non ha risposto prima della scadenza dell'operazione.",
qoderMcpConfigInvalid: "Qoder CLI non ha potuto leggere la configurazione MCP temporanea di DBX. Controlla la versione di Qoder CLI e i dettagli seguenti.",
qoderMcpStartupFailed: "Qoder CLI ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.",
qoderProtocolError: "Qoder CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di Qoder CLI e i dettagli seguenti.",
qoderRunFailed: "Qoder CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.",
},
actions: {
general: "Generale",
+10
View File
@@ -2297,6 +2297,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "CodeBuddy CodeはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断情報を確認してください。",
codeBuddyProtocolError: "CodeBuddy Codeが無効な制御応答またはJSONイベントストリームを返しました。CodeBuddy Codeのバージョンと以下の診断情報を確認してください。",
codeBuddyRunFailed: "CodeBuddy Code CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。",
qoderNotInstalled: "Qoder CLIが見つかりません。Qoder CLIをインストールするか、設定 > AIで実行ファイルのパスを指定してください。",
qoderCliPathInvalid: "Qoder CLIのパスが無効です。qodercli実行ファイルのみを選択し、環境変数は別途設定してください。",
qoderEnvInvalid: "Qoder CLIの環境変数名が無効です。HTTPS_PROXYやQODER_PERSONAL_ACCESS_TOKENなどの名前を使用してください。",
qoderEnvReserved: "DBXが管理するMCP環境変数が上書きされました。プロバイダー設定からDBX_MCP_*変数を削除してください。",
qoderNotAuthenticated: "Qoder CLIにログインしていません。ターミナルで`qodercli login`を実行するかQODER_PERSONAL_ACCESS_TOKENを設定して、再試行してください。",
qoderTimeout: "Qoder CLIは操作のタイムアウトまでに応答しませんでした。",
qoderMcpConfigInvalid: "Qoder CLIが一時的なDBX MCP設定を読み取れませんでした。Qoder CLIのバージョンと以下の診断詳細を確認してください。",
qoderMcpStartupFailed: "Qoder CLIはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断詳細を確認してください。",
qoderProtocolError: "Qoder CLIが無効なJSONイベントストリームを返しました。Qoder CLIのバージョンと以下の診断詳細を確認してください。",
qoderRunFailed: "Qoder CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。",
},
run: "実行",
readingSchema: "スキーマを読み取り中",
+10
View File
@@ -2281,6 +2281,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "CodeBuddy Code가 MCP 구성을 불러왔지만 DBX MCP Server를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.",
codeBuddyProtocolError: "CodeBuddy Code가 잘못된 제어 응답 또는 JSON 이벤트 스트림을 반환했습니다. CodeBuddy Code 버전과 아래 진단을 확인하세요.",
codeBuddyRunFailed: "CodeBuddy Code CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.",
qoderNotInstalled: "Qoder CLI를 찾을 수 없습니다. Qoder CLI를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.",
qoderCliPathInvalid: "Qoder CLI 경로가 올바르지 않습니다. qodercli 실행 파일만 선택하고 환경 변수는 별도로 구성하세요.",
qoderEnvInvalid: "Qoder CLI 환경 변수 이름이 올바르지 않습니다. HTTPS_PROXY 또는 QODER_PERSONAL_ACCESS_TOKEN 같은 이름을 사용하세요.",
qoderEnvReserved: "DBX에서 관리하는 MCP 환경 변수가 재정의되었습니다. 공급자 구성에서 DBX_MCP_* 변수를 제거하세요.",
qoderNotAuthenticated: "Qoder CLI가 인증되지 않았습니다. 터미널에서 `qodercli login`을 실행하거나 QODER_PERSONAL_ACCESS_TOKEN을 구성한 후 다시 시도하세요.",
qoderTimeout: "Qoder CLI가 작업 제한 시간 내에 응답하지 않았습니다.",
qoderMcpConfigInvalid: "Qoder CLI가 임시 DBX MCP 구성을 읽을 수 없습니다. Qoder CLI 버전과 아래 진단을 확인하세요.",
qoderMcpStartupFailed: "Qoder CLI가 MCP 구성을 불러왔지만 DBX MCP Server를 시작할 수 없습니다. 설정 > MCP와 아래 진단을 확인하세요.",
qoderProtocolError: "Qoder CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. Qoder CLI 버전과 아래 진단을 확인하세요.",
qoderRunFailed: "Qoder CLI가 예기치 않게 종료되었습니다. 아래 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 확인하세요.",
},
actions: {
general: "일반",
+10
View File
@@ -2265,6 +2265,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "O CodeBuddy Code carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.",
codeBuddyProtocolError: "O CodeBuddy Code retornou uma resposta de controle ou um fluxo de eventos JSON inválido. Verifique a versão do CodeBuddy Code e os detalhes abaixo.",
codeBuddyRunFailed: "O CodeBuddy Code CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.",
qoderNotInstalled: "O Qoder CLI não foi encontrado. Instale-o ou configure o caminho do executável em Configurações > IA.",
qoderCliPathInvalid: "O caminho do Qoder CLI é inválido. Selecione somente o executável qodercli e configure as variáveis de ambiente separadamente.",
qoderEnvInvalid: "O nome de uma variável de ambiente do Qoder CLI é inválido. Use nomes como HTTPS_PROXY ou QODER_PERSONAL_ACCESS_TOKEN.",
qoderEnvReserved: "Uma variável de ambiente MCP gerenciada pelo DBX foi sobrescrita. Remova as variáveis DBX_MCP_* da configuração do provedor.",
qoderNotAuthenticated: "O Qoder CLI não está autenticado. Execute `qodercli login` em um terminal ou configure QODER_PERSONAL_ACCESS_TOKEN e tente novamente.",
qoderTimeout: "O Qoder CLI não respondeu antes do tempo limite da operação.",
qoderMcpConfigInvalid: "O Qoder CLI não conseguiu ler a configuração MCP temporária do DBX. Verifique a versão do Qoder CLI e os detalhes abaixo.",
qoderMcpStartupFailed: "O Qoder CLI carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.",
qoderProtocolError: "O Qoder CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do Qoder CLI e os detalhes abaixo.",
qoderRunFailed: "O Qoder CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.",
},
run: "Executar",
readingSchema: "Lendo schema",
+10
View File
@@ -2342,6 +2342,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "CodeBuddy Code 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 与下方诊断信息。",
codeBuddyProtocolError: "CodeBuddy Code 返回了无效的控制响应或 JSON 事件流。请检查 CodeBuddy Code 版本和下方诊断信息。",
codeBuddyRunFailed: "CodeBuddy Code CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
qoderNotInstalled: "未找到 Qoder CLI。请安装 Qoder CLI,或在 设置 > AI 中指定可执行文件路径。",
qoderCliPathInvalid: "Qoder CLI 路径无效。请只选择 qodercli 可执行文件,并单独配置环境变量。",
qoderEnvInvalid: "Qoder CLI 环境变量名称无效。请使用 HTTPS_PROXY 或 QODER_PERSONAL_ACCESS_TOKEN 这类合法名称。",
qoderEnvReserved: "配置覆盖了由 DBX 管理的 MCP 环境变量。请从供应商配置中移除 DBX_MCP_* 变量。",
qoderNotAuthenticated: "Qoder CLI 尚未登录。请在终端执行 `qodercli login`,或配置 QODER_PERSONAL_ACCESS_TOKEN 后重试。",
qoderTimeout: "Qoder CLI 未在操作超时前响应。",
qoderMcpConfigInvalid: "Qoder CLI 无法读取临时 DBX MCP 配置。请检查 Qoder CLI 版本和下方诊断信息。",
qoderMcpStartupFailed: "Qoder CLI 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 和下方诊断信息。",
qoderProtocolError: "Qoder CLI 返回了无效的 JSON 事件流。请检查 Qoder CLI 版本和下方诊断信息。",
qoderRunFailed: "Qoder CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
cursorEnvInvalid: "Cursor CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。",
cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。",
cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。",
+10
View File
@@ -2406,6 +2406,16 @@ export default withEnglishFallback({
codeBuddyMcpStartupFailed: "CodeBuddy Code 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 與下方診斷資訊。",
codeBuddyProtocolError: "CodeBuddy Code 回傳了無效的控制回應或 JSON 事件串流。請檢查 CodeBuddy Code 版本和下方診斷資訊。",
codeBuddyRunFailed: "CodeBuddy Code CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。",
qoderNotInstalled: "找不到 Qoder CLI。請安裝 Qoder CLI,或在 設定 > AI 中指定可執行檔路徑。",
qoderCliPathInvalid: "Qoder CLI 路徑無效。請只選擇 qodercli 可執行檔,並另外設定環境變數。",
qoderEnvInvalid: "Qoder CLI 環境變數名稱無效。請使用 HTTPS_PROXY 或 QODER_PERSONAL_ACCESS_TOKEN 這類合法名稱。",
qoderEnvReserved: "設定覆蓋了由 DBX 管理的 MCP 環境變數。請從供應商設定中移除 DBX_MCP_* 變數。",
qoderNotAuthenticated: "Qoder CLI 尚未登入。請在終端機執行 `qodercli login`,或設定 QODER_PERSONAL_ACCESS_TOKEN 後重試。",
qoderTimeout: "Qoder CLI 未在操作逾時前回應。",
qoderMcpConfigInvalid: "Qoder CLI 無法讀取暫時的 DBX MCP 設定。請檢查 Qoder CLI 版本和下方診斷詳情。",
qoderMcpStartupFailed: "Qoder CLI 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。",
qoderProtocolError: "Qoder CLI 傳回了無效的 JSON 事件串流。請檢查 Qoder CLI 版本和下方診斷詳情。",
qoderRunFailed: "Qoder CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。",
},
actions: {
general: "通用問答",
@@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => {
expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false);
});
it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
expect(
isAiConfigModelCandidate(
config({
@@ -38,4 +38,19 @@ describe("isAiConfigModelCandidate", () => {
),
).toBe(true);
});
it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"] as const)("filters existing %s configs when local CLI providers are unavailable", (provider) => {
expect(
isAiConfigModelCandidate(
config({
provider,
endpoint: "",
apiKey: "",
model: "default",
}),
false,
false,
),
).toBe(false);
});
});
+2 -1
View File
@@ -24,12 +24,13 @@ describe("orderAiConfigsForDisplay", () => {
{ id: "opencode", provider: "opencode-cli" },
{ id: "cursor", provider: "cursor-cli" },
{ id: "codebuddy", provider: "codebuddy-cli" },
{ id: "qoder", provider: "qoder-cli" },
{ id: "grok", provider: "grok-cli" },
{ id: "pi", provider: "pi-agent-cli" },
{ id: "custom", provider: "custom" },
];
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "codebuddy", "grok", "pi", "custom"]);
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "codebuddy", "qoder", "grok", "pi", "custom"]);
});
it("preserves creation order for configs from the same provider", () => {
@@ -1,9 +1,9 @@
import type { AiConfig } from "@/types/ai";
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli"]);
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"]);
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean, supportsCliProviders = true): boolean {
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.
if (CLI_PROVIDERS.has(config.provider)) return true;
if (CLI_PROVIDERS.has(config.provider)) return supportsCliProviders;
return !!config.endpoint?.trim() && (!requiresApiKey || !!config.apiKey?.trim());
}
+16
View File
@@ -504,6 +504,22 @@ describe("settingsStore AI API key normalization", () => {
codebuddyCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" },
});
});
it("normalizes Qoder CLI path and environment settings", () => {
expect(
normalizeAiConfig({
provider: "qoder-cli",
qoderCliPath: " ~/.local/bin/qodercli ",
qoderCliEnv: { QODER_PERSONAL_ACCESS_TOKEN: "token", EMPTY: null as unknown as string },
}),
).toMatchObject({
provider: "qoder-cli",
endpoint: "",
model: "default",
qoderCliPath: "~/.local/bin/qodercli",
qoderCliEnv: { QODER_PERSONAL_ACCESS_TOKEN: "token", EMPTY: "" },
});
});
});
describe("settingsStore MCP policy persistence", () => {
+22 -1
View File
@@ -264,6 +264,15 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
authMethod: "bearer",
requiresApiKey: false,
},
"qoder-cli": {
label: "Qoder CLI",
provider: "qoder-cli",
endpoint: "",
model: "default",
apiStyle: "completions",
authMethod: "bearer",
requiresApiKey: false,
},
"grok-cli": {
label: "Grok CLI",
iconSlug: "grok",
@@ -348,6 +357,8 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
grokCliEnv: normalizeAiEnv(config?.grokCliEnv),
codebuddyCliPath: config?.codebuddyCliPath?.trim() || undefined,
codebuddyCliEnv: normalizeAiEnv(config?.codebuddyCliEnv),
qoderCliPath: config?.qoderCliPath?.trim() || undefined,
qoderCliEnv: normalizeAiEnv(config?.qoderCliEnv),
};
}
@@ -1517,7 +1528,17 @@ 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" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli" || config.provider === "cursor-cli" || config.provider === "grok-cli" || config.provider === "codebuddy-cli") return true;
if (
config.provider === "codex-cli" ||
config.provider === "claude-code-cli" ||
config.provider === "pi-agent-cli" ||
config.provider === "opencode-cli" ||
config.provider === "cursor-cli" ||
config.provider === "grok-cli" ||
config.provider === "codebuddy-cli" ||
config.provider === "qoder-cli"
)
return true;
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
});
+3 -1
View File
@@ -1,4 +1,4 @@
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "grok-cli" | "codebuddy-cli" | "custom";
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "grok-cli" | "codebuddy-cli" | "qoder-cli" | "custom";
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
@@ -55,6 +55,8 @@ export interface AiConfig {
grokCliEnv?: Record<string, string>;
codebuddyCliPath?: string | null;
codebuddyCliEnv?: Record<string, string>;
qoderCliPath?: string | null;
qoderCliEnv?: Record<string, string>;
runtimeEffort?: AiEffortSelection | null;
}