From 52d5c99a923b145ca25898f0789b5c13da11cbfe Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Wed, 29 Jul 2026 16:16:39 +0800 Subject: [PATCH] fix(ai): improve API retry handling --- .gitignore | 4 + Cargo.lock | 1 + .../editor/EditorSettingsDialog.vue | 771 ++++-- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/http.ts | 692 ++++- apps/desktop/src/lib/backend/tauri.ts | 759 +++++- apps/desktop/src/stores/settingsStore.ts | 92 +- crates/dbx-core/Cargo.toml | 1 + crates/dbx-core/src/ai.rs | 2301 ++++++++++++----- crates/dbx-core/src/ai_claude_code_cli.rs | 1 + crates/dbx-core/src/ai_codex_cli.rs | 1 + crates/dbx-core/src/ai_effort.rs | 1 + crates/dbx-core/src/ai_pi_agent_cli.rs | 1 + crates/dbx-core/src/cloud_sync.rs | 1 + crates/dbx-core/src/storage.rs | 78 +- crates/dbx-web/src/main.rs | 4 + crates/dbx-web/src/routes/ai.rs | 136 +- crates/dbx-web/src/routes/app_settings.rs | 18 + src-tauri/Cargo.toml | 3 + src-tauri/src/commands/ai.rs | 138 +- src-tauri/src/commands/app_settings.rs | 10 + src-tauri/src/lib.rs | 2 + 29 files changed, 3962 insertions(+), 1097 deletions(-) diff --git a/.gitignore b/.gitignore index e0ab873c30..514c389733 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,10 @@ Thumbs.db # Temp tmp/ +# Code review tool artifacts +review_diff.json +review_classified.json + # Logs *.log diff --git a/Cargo.lock b/Cargo.lock index 3d8b9ba386..9591b00f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,6 +1920,7 @@ dependencies = [ "flate2", "font-kit", "futures", + "httpdate", "iana-time-zone", "insta", "json5", diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 7a07e06191..b9e50aec56 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -65,6 +65,8 @@ import { getAppSupportInfo, loadMaxAgentTurns, saveMaxAgentTurns, + loadMaxRetries, + saveMaxRetries, saveWebdavSyncSecretsPreference, saveWebdavSavedPassword, saveSnippetSavedToken, @@ -148,12 +150,28 @@ const selectedLocaleOption = computed(() => LOCALE_OPTIONS.find((locale) => loca const appThemeModeOptions = computed(() => [ { value: "light" as AppThemeMode, label: t("toolbar.themeLight"), icon: Sun }, { value: "dark" as AppThemeMode, label: t("toolbar.themeDark"), icon: Moon }, - { value: "system" as AppThemeMode, label: t("toolbar.themeSystem"), icon: SunMoon }, + { + value: "system" as AppThemeMode, + label: t("toolbar.themeSystem"), + icon: SunMoon, + }, ]); const appCornerStyleOptions = computed(() => [ - { value: "none" as AppCornerStyle, label: t("settings.cornerStyleNone"), previewRadius: "0px" }, - { value: "small" as AppCornerStyle, label: t("settings.cornerStyleSmall"), previewRadius: "4px" }, - { value: "large" as AppCornerStyle, label: t("settings.cornerStyleLarge"), previewRadius: "10px" }, + { + value: "none" as AppCornerStyle, + label: t("settings.cornerStyleNone"), + previewRadius: "0px", + }, + { + value: "small" as AppCornerStyle, + label: t("settings.cornerStyleSmall"), + previewRadius: "4px", + }, + { + value: "large" as AppCornerStyle, + label: t("settings.cornerStyleLarge"), + previewRadius: "10px", + }, ]); const props = defineProps<{ @@ -333,7 +351,9 @@ function setSqlVariableSyntaxToggle(key: keyof SqlVariableSyntaxToggles, value: ...editSqlVariableSyntaxOverrides.value[dbType], [key]: value, }; - const next: SqlVariableSyntaxOverrides = { ...editSqlVariableSyntaxOverrides.value }; + const next: SqlVariableSyntaxOverrides = { + ...editSqlVariableSyntaxOverrides.value, + }; // Keep storage sparse: an all-enabled type has no entry; otherwise persist only the disabled syntaxes. if (SQL_VARIABLE_SYNTAX_KEYS.every((toggleKey) => merged[toggleKey])) { delete next[dbType]; @@ -490,13 +510,19 @@ const snippetDialogOpen = ref(false); const snippetEditingId = ref(null); const snippetForm = ref({ label: "", prefix: "", body: "" }); const snippetFormPrefixError = ref(""); -const iconThemeDescTruncated = { default: ref(false), black: ref(false) }; +const iconThemeDescTruncated = { + default: ref(false), + black: ref(false), +}; const iconThemeDescRef = { default: ref(null), black: ref(null), }; const iconThemeBlackDescriptionText = computed(() => (isMacOS() ? t("settings.iconThemeBlackDescriptionMac") : t("settings.iconThemeBlackDescription"))); -const layoutDescTruncated = { separated: ref(false), classic: ref(false) }; +const layoutDescTruncated = { + separated: ref(false), + classic: ref(false), +}; const layoutDescRefs = { separated: ref(null), classic: ref(null), @@ -580,7 +606,11 @@ function openAddSnippetDialog() { function openEditSnippetDialog(snippet: SqlSnippet) { snippetEditingId.value = snippet.id; - snippetForm.value = { label: snippet.label, prefix: snippet.prefix, body: snippet.body }; + snippetForm.value = { + label: snippet.label, + prefix: snippet.prefix, + body: snippet.body, + }; snippetFormPrefixError.value = ""; snippetDialogOpen.value = true; } @@ -1275,7 +1305,10 @@ function cancelShortcutEdit() { function resetShortcut(actionId: ShortcutActionId) { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); if (!definition) return; - editShortcuts.value = { ...editShortcuts.value, [actionId]: definition.defaultShortcut }; + editShortcuts.value = { + ...editShortcuts.value, + [actionId]: definition.defaultShortcut, + }; } function clearShortcut(actionId: ShortcutActionId) { @@ -1685,7 +1718,10 @@ async function uploadSnippetSnapshot() { const summary = await snippetSyncUpload(currentSnippetConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined); snippetId.value = summary.snippetId; localStorage.setItem(`dbx-snippet-id-${snippetProvider.value}`, summary.snippetId); - return t("settings.syncSnippetUploadSuccess", { bytes: summary.bytes, id: summary.snippetId }); + return t("settings.syncSnippetUploadSuccess", { + bytes: summary.bytes, + id: summary.snippetId, + }); }); } @@ -1701,7 +1737,10 @@ async function downloadSnippetSnapshot() { // the already-loaded Pinia store instead of leaving the UI stale. await tunnelProfileStore.refresh(); await settingsStore.reloadAiConfigs(); - let message = t("settings.syncSnippetDownloadSuccess", { bytes: result.summary.bytes, id: result.summary.snippetId }); + let message = t("settings.syncSnippetDownloadSuccess", { + bytes: result.summary.bytes, + id: result.summary.snippetId, + }); if (result.applySummary.encryptedSecretsPresent && !result.applySummary.secretsApplied) message += ` ${t("settings.syncSecretsSkipped")}`; if (result.applySummary.secretsApplied) message += ` ${t("settings.syncSecretsApplied")}`; return message; @@ -1823,7 +1862,10 @@ async function testWebDav() { async function uploadWebDavSnapshot() { await runWebDavAction("upload", async () => { const summary = await webdavSyncUpload(currentWebDavConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined); - return t("settings.syncUploadSuccess", { bytes: summary.bytes, path: summary.remotePath }); + return t("settings.syncUploadSuccess", { + bytes: summary.bytes, + path: summary.remotePath, + }); }); } @@ -1864,7 +1906,10 @@ const changingPassword = ref(false); async function scrollToInitialSettingsSection() { await nextTick(); if (props.initialSection === "tableColumnTemplates") { - tableColumnTemplateSectionRef.value?.scrollIntoView({ block: "center", behavior: "smooth" }); + tableColumnTemplateSectionRef.value?.scrollIntoView({ + block: "center", + behavior: "smooth", + }); } } @@ -1896,7 +1941,12 @@ watch( localStorage.removeItem(MCP_SCOPE_CONNECTION_STORAGE_KEY); } catch (e: any) { mcpPolicyLoadError.value = e?.message || String(e); - toast(t("settings.mcpPolicyLoadFailed", { error: mcpPolicyLoadError.value }), 5000); + toast( + t("settings.mcpPolicyLoadFailed", { + error: mcpPolicyLoadError.value, + }), + 5000, + ); } finally { mcpPolicyLoading.value = false; } @@ -1969,6 +2019,7 @@ watch(activeSettingsTab, async (tab) => { if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus(); if (tab === "ai") { void loadMaxAgentTurnsSetting(); + void loadMaxRetriesSetting(); // Await completion so we don't snapshot an empty default when the store // is still loading its first payload (init via App.vue is fire-and-forget). await promptTemplateStore.ensureLoaded(); @@ -2017,7 +2068,10 @@ async function changePassword() { const res = await fetch(apiUrl("/api/auth/change-password"), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ old_password: oldPassword.value, new_password: newPassword.value }), + body: JSON.stringify({ + old_password: oldPassword.value, + new_password: newPassword.value, + }), }); if (res.ok) { passwordMessage.value = t("auth.passwordChanged"); @@ -2049,7 +2103,10 @@ const globalInstructionsSaving = ref(false); // Prompt Templates Management const templateEditing = ref(null); const templateFormOpen = ref(false); -const templateForm = ref<{ name: string; content: string }>({ name: "", content: "" }); +const templateForm = ref<{ name: string; content: string }>({ + name: "", + content: "", +}); const templateFormIsNew = ref(true); const templateSaving = ref(false); const templateDeleteConfirm = ref(null); @@ -2191,6 +2248,54 @@ async function saveMaxAgentTurnsSetting() { } } +// Max Retries (global). Default 2, range 0–10. Applied to all API-backed +// AI providers. CLI providers (claude-code, codex, pi) are unaffected +// because they use their own retry logic. +const editMaxRetries = ref(undefined); +const maxRetriesSaving = ref(false); +const maxRetriesLoaded = ref(false); +const maxRetriesLoading = ref(false); +const maxRetriesLoadError = ref(""); + +async function loadMaxRetriesSetting() { + if (maxRetriesLoaded.value || maxRetriesLoading.value) return; + maxRetriesLoading.value = true; + maxRetriesLoadError.value = ""; + try { + editMaxRetries.value = await loadMaxRetries(); + maxRetriesLoaded.value = true; + } catch (e: any) { + maxRetriesLoadError.value = e?.message || String(e); + toast(maxRetriesLoadError.value, 5000); + } finally { + maxRetriesLoading.value = false; + } +} + +async function saveMaxRetriesSetting() { + if (!maxRetriesLoaded.value) return; + const clamped = normalizeMaxRetries(editMaxRetries.value); + maxRetriesSaving.value = true; + try { + await saveMaxRetries(clamped); + editMaxRetries.value = clamped; + toast(t("ai.maxRetriesSaved")); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } finally { + maxRetriesSaving.value = false; + } +} + +function maxRetriesOutOfRange(value: number | undefined): boolean { + return typeof value === "number" && (value < 0 || value > 10); +} + +function normalizeMaxRetries(value: number | undefined): number { + const rounded = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 2; + return Math.min(10, Math.max(0, rounded)); +} + // AI Config Delete Confirmation const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); @@ -2329,7 +2434,11 @@ const aiCliPathError = computed(() => { 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 })); + return Object.entries(normalizeAiEnv(env)).map(([key, value]) => ({ + id: uuid(), + key, + value, + })); } function cliEnvFromRows(rows = aiEditCliEnvRows.value): Record { @@ -2647,7 +2756,12 @@ let diagnosticComp: import("@codemirror/state").Compartment | null = null; let previewRunGutterComp: import("@codemirror/state").Compartment | null = null; let currentStatementFrameComp: import("@codemirror/state").Compartment | null = null; let setPreviewDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; -let setPreviewRunHighlightEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null; +let setPreviewRunHighlightEffect: + | import("@codemirror/state").StateEffectType<{ + from: number; + to: number; + } | null> + | null = null; let editorViewModule: typeof import("@codemirror/view") | null = null; let previewSqlDiagnostics: PreviewSqlDiagnostic[] = []; let previewExecutableCache: ExecutableStatementRangeCache | null = null; @@ -2691,14 +2805,18 @@ function previewExecutableStatementRangeStartingAt(currentView: EditorViewType, function clearPreviewRunHighlight() { previewRunHighlightRange = null; if (previewView.value && setPreviewRunHighlightEffect) { - previewView.value.dispatch({ effects: setPreviewRunHighlightEffect.of(null) }); + previewView.value.dispatch({ + effects: setPreviewRunHighlightEffect.of(null), + }); } } function flashPreviewRunHighlight(range: { from: number; to: number }, event: Event) { previewRunHighlightRange = range; if (previewView.value && setPreviewRunHighlightEffect) { - previewView.value.dispatch({ effects: setPreviewRunHighlightEffect.of(range) }); + previewView.value.dispatch({ + effects: setPreviewRunHighlightEffect.of(range), + }); } if (event.target instanceof Element) { const marker = event.target.closest(".cm-run-statement-marker"); @@ -2780,7 +2898,14 @@ function buildPreviewCurrentStatementFrameExtension(viewModule: Pick { const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]); - editorViewModule = { Decoration, EditorView, ViewPlugin } as typeof import("@codemirror/view"); + editorViewModule = { + Decoration, + EditorView, + ViewPlugin, + } as typeof import("@codemirror/view"); fontThemeComp = new Compartment(); themeComp = new Compartment(); diagnosticComp = new Compartment(); previewRunGutterComp = new Compartment(); currentStatementFrameComp = new Compartment(); setPreviewDiagnosticsEffect = StateEffect.define(); - setPreviewRunHighlightEffect = StateEffect.define<{ from: number; to: number } | null>(); + setPreviewRunHighlightEffect = StateEffect.define<{ + from: number; + to: number; + } | null>(); previewSqlDiagnostics = previewDiagnosticsForSql(currentPreviewSql()); const ss = previewSettings.value; @@ -2921,7 +3053,17 @@ watch(previewRef, async (el) => { const buildPreviewRunHighlightExtension = () => { const highlightEffect = setPreviewRunHighlightEffect; - const buildDecorations = () => (previewRunHighlightRange ? Decoration.set([Decoration.mark({ class: "cm-settings-preview-run-highlight" }).range(previewRunHighlightRange.from, previewRunHighlightRange.to)], true) : Decoration.none); + const buildDecorations = () => + previewRunHighlightRange + ? Decoration.set( + [ + Decoration.mark({ + class: "cm-settings-preview-run-highlight", + }).range(previewRunHighlightRange.from, previewRunHighlightRange.to), + ], + true, + ) + : Decoration.none; const field = StateField.define({ create: buildDecorations, update(value, transaction) { @@ -3010,7 +3152,11 @@ onUnmounted(cleanupPreviewEditor); @@ -3074,7 +3220,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.showExecutionTargetPickerDescription") }}

+

+ {{ t("settings.showExecutionTargetPickerDescription") }} +

@@ -3082,7 +3230,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.showStatementRunButtonsDescription") }}

+

+ {{ t("settings.showStatementRunButtonsDescription") }} +

@@ -3090,7 +3240,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.showCurrentStatementFrameDescription") }}

+

+ {{ t("settings.showCurrentStatementFrameDescription") }} +

@@ -3098,7 +3250,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.showInsertValueHintsDescription") }}

+

+ {{ t("settings.showInsertValueHintsDescription") }} +

@@ -3106,7 +3260,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.wordWrapDescription") }}

+

+ {{ t("settings.wordWrapDescription") }} +

@@ -3114,7 +3270,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.vimModeDescription") }}

+

+ {{ t("settings.vimModeDescription") }} +

@@ -3122,7 +3280,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.autoCloseBracketsDescription") }}

+

+ {{ t("settings.autoCloseBracketsDescription") }} +

@@ -3130,7 +3290,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.insertSpaceAfterCompletionDescription") }}

+

+ {{ t("settings.insertSpaceAfterCompletionDescription") }} +

@@ -3138,7 +3300,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.autoAliasTablesDescription") }}

+

+ {{ t("settings.autoAliasTablesDescription") }} +

@@ -3188,7 +3352,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.prefillNewQueryWithSelectDescription") }}

+

+ {{ t("settings.prefillNewQueryWithSelectDescription") }} +

@@ -3199,8 +3365,12 @@ onUnmounted(cleanupPreviewEditor);
-
{{ t("settings.sqlVariableSyntax") }}
-

{{ t("settings.sqlVariableSyntaxDescription") }}

+
+ {{ t("settings.sqlVariableSyntax") }} +
+

+ {{ t("settings.sqlVariableSyntaxDescription") }} +

@@ -4197,12 +4448,24 @@ onUnmounted(cleanupPreviewEditor); - {{ t("settings.tableColumnTemplateColumn") }} - {{ t("settings.tableColumnTemplateType") }} - {{ t("settings.tableColumnTemplateLength") }} - {{ t("settings.tableColumnTemplateDefault") }} - {{ t("settings.tableColumnTemplateRequired") }} - {{ t("settings.tableColumnTemplateComment") }} + + {{ t("settings.tableColumnTemplateColumn") }} + + + {{ t("settings.tableColumnTemplateType") }} + + + {{ t("settings.tableColumnTemplateLength") }} + + + {{ t("settings.tableColumnTemplateDefault") }} + + + {{ t("settings.tableColumnTemplateRequired") }} + + + {{ t("settings.tableColumnTemplateComment") }} + @@ -4341,7 +4604,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.snippetsDescription") }}

+

+ {{ t("settings.snippetsDescription") }} +

-

{{ t("settings.syncWebDavDescription") }}

+

+ {{ t("settings.syncWebDavDescription") }} +

@@ -4466,7 +4733,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.syncRemotePathDescription") }}

+

+ {{ t("settings.syncRemotePathDescription") }} +

-

{{ t("settings.syncAutoUploadDescription") }}

+

+ {{ t("settings.syncAutoUploadDescription") }} +

@@ -4515,7 +4786,9 @@ onUnmounted(cleanupPreviewEditor); {{ t("settings.syncSnippetGuide") }} -

{{ t("settings.syncSnippetDescription") }}

+

+ {{ t("settings.syncSnippetDescription") }} +

@@ -4556,7 +4829,9 @@ onUnmounted(cleanupPreviewEditor); {{ t("settings.syncSnippetRememberToken") }} -

{{ t("settings.syncSnippetTokenDescription") }}

+

+ {{ t("settings.syncSnippetTokenDescription") }} +

@@ -4588,7 +4863,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.syncSecretsSharedDescription") }}

+

+ {{ t("settings.syncSecretsSharedDescription") }} +

@@ -4612,7 +4889,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.syncSecretsPassphraseDescription") }}

+

+ {{ t("settings.syncSecretsPassphraseDescription") }} +

@@ -4630,7 +4909,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("ai.noAiConfigs") }}

+

+ {{ t("ai.noAiConfigs") }} +

- - - + + +
- -
- -
-

{{ t("ai.globalInstructions") }}

-

{{ t("ai.globalInstructionsDescription") }}

-
- -
- - {{ t("ai.globalInstructionsCharCount", { count: promptTemplateCharacterCount(editGlobalInstructions), max: GLOBAL_INSTRUCTIONS_MAX }) }} - - -
-
-
-

{{ t("ai.maxAgentTurns") }}

-

{{ t("ai.maxAgentTurnsDescription") }}

+

+ {{ t("ai.maxAgentTurns") }} +

+

+ {{ t("ai.maxAgentTurnsDescription") }} +

- {{ t("ai.maxAgentTurnsRange", { min: MAX_AGENT_TURNS_MIN, max: MAX_AGENT_TURNS_MAX, default: MAX_AGENT_TURNS_DEFAULT }) }} + {{ + t("ai.maxAgentTurnsRange", { + min: MAX_AGENT_TURNS_MIN, + max: MAX_AGENT_TURNS_MAX, + default: MAX_AGENT_TURNS_DEFAULT, + }) + }}
+ +
+ +
+

+ {{ t("ai.maxRetriesGlobal") }} +

+

+ {{ t("ai.maxRetriesGlobalDescription") }} +

+
+
+ + + {{ t("ai.maxRetriesRange", { min: 0, max: 10, default: 2 }) }} + +
+ + +
+
+ + +
+ +
+

+ {{ t("ai.globalInstructions") }} +

+

+ {{ t("ai.globalInstructionsDescription") }} +

+
+ +
+ + {{ + t("ai.globalInstructionsCharCount", { + count: promptTemplateCharacterCount(editGlobalInstructions), + max: GLOBAL_INSTRUCTIONS_MAX, + }) + }} + + +
+
+
-

{{ t("ai.promptTemplates") }}

-

{{ t("ai.promptTemplatesDescription") }}

+

+ {{ t("ai.promptTemplates") }} +

+

+ {{ t("ai.promptTemplatesDescription") }} +

@@ -4742,8 +5088,20 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("ai.promptTemplateNameTooLong", { max: PROMPT_TEMPLATE_NAME_MAX }) }}

-

{{ t("ai.promptTemplateNameExists", { name: templateForm.name.trim() }) }}

+

+ {{ + t("ai.promptTemplateNameTooLong", { + max: PROMPT_TEMPLATE_NAME_MAX, + }) + }} +

+

+ {{ + t("ai.promptTemplateNameExists", { + name: templateForm.name.trim(), + }) + }} +

{{ promptTemplateCharacterCount(templateForm.name) }}/{{ PROMPT_TEMPLATE_NAME_MAX }}
@@ -4752,7 +5110,13 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("ai.promptTemplateTooLong", { max: PROMPT_TEMPLATE_CONTENT_MAX }) }}

+

+ {{ + t("ai.promptTemplateTooLong", { + max: PROMPT_TEMPLATE_CONTENT_MAX, + }) + }} +

{{ promptTemplateCharacterCount(templateForm.content) }}/{{ PROMPT_TEMPLATE_CONTENT_MAX }}
@@ -4773,7 +5137,9 @@ onUnmounted(cleanupPreviewEditor); {{ t("common.back") }} -

{{ aiEditConfigId ? t("ai.editConfig") : t("ai.addConfig") }}

+

+ {{ aiEditConfigId ? t("ai.editConfig") : t("ai.addConfig") }} +

@@ -4821,7 +5187,11 @@ onUnmounted(cleanupPreviewEditor);

- {{ t("ai.cliMcpRequiredDescription", { provider: aiCliProviderLabel }) }} + {{ + t("ai.cliMcpRequiredDescription", { + provider: aiCliProviderLabel, + }) + }}

{{ mcpStatusError || mcpStatus?.error }} @@ -4866,7 +5236,9 @@ onUnmounted(cleanupPreviewEditor);

-

{{ aiEndpointHint }}

+

+ {{ aiEndpointHint }} +

@@ -4875,8 +5247,17 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("ai.cliPathHint", { command: aiCliCommandName, loginCommand: aiCliLoginCommand }) }}

-

{{ aiCliPathError }}

+

+ {{ + t("ai.cliPathHint", { + command: aiCliCommandName, + loginCommand: aiCliLoginCommand, + }) + }} +

+

+ {{ aiCliPathError }} +

@@ -4897,8 +5278,12 @@ onUnmounted(cleanupPreviewEditor); {{ t("ai.cliEnvAdd") }} -

{{ aiCliEnvError }}

-

{{ t("ai.cliEnvHint", { provider: aiCliProviderLabel }) }}

+

+ {{ aiCliEnvError }} +

+

+ {{ t("ai.cliEnvHint", { provider: aiCliProviderLabel }) }} +

@@ -4906,9 +5291,37 @@ onUnmounted(cleanupPreviewEditor);
- - - + + +
@@ -4917,7 +5330,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("ai.contextWindowHint") }}

+

+ {{ t("ai.contextWindowHint") }} +

@@ -4961,13 +5376,17 @@ onUnmounted(cleanupPreviewEditor);
-
{{ t("settings.mcpCurrent") }}
+
+ {{ t("settings.mcpCurrent") }} +
{{ mcpStatus?.current_version ? `v${mcpStatus.current_version}` : t("settings.mcpVersionMissing") }}
-
{{ t("settings.mcpLatest") }}
+
+ {{ t("settings.mcpLatest") }} +
{{ mcpStatus?.latest_version ? `v${mcpStatus.latest_version}` : t("settings.mcpVersionUnknown") }}
@@ -5018,13 +5437,23 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.mcpConfigOptionsHint") }}

-

{{ t("settings.mcpPolicyLoadFailed", { error: mcpPolicyLoadError }) }}

+

+ {{ t("settings.mcpConfigOptionsHint") }} +

+

+ {{ + t("settings.mcpPolicyLoadFailed", { + error: mcpPolicyLoadError, + }) + }} +

-

{{ t("settings.mcpExecutionModeDescription") }}

+

+ {{ t("settings.mcpExecutionModeDescription") }} +

@@ -5265,7 +5704,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("auth.changePasswordDescription") }}

+

+ {{ t("auth.changePasswordDescription") }} +

@@ -5284,7 +5725,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.supportInfoDescription") }}

+

+ {{ t("settings.supportInfoDescription") }} +

-
{{ row.label }}
-
{{ row.value }}
+
+ {{ row.label }} +
+
+ {{ row.value }} +
-

{{ t("settings.supportInfoLoading") }}

-

{{ t("settings.supportInfoLoadFailed", { message: appSupportInfoError }) }}

+

+ {{ t("settings.supportInfoLoading") }} +

+

+ {{ + t("settings.supportInfoLoadFailed", { + message: appSupportInfoError, + }) + }} +

@@ -5309,7 +5764,9 @@ onUnmounted(cleanupPreviewEditor);
-

{{ t("settings.updateDownloadSourceDescription") }}

+

+ {{ t("settings.updateDownloadSourceDescription") }} +

-

{{ snippetFormPrefixError }}

+

+ {{ snippetFormPrefixError }} +

@@ -5542,7 +6003,11 @@ onUnmounted(cleanupPreviewEditor); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ca969391c3..388cef63f0 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1776,6 +1776,12 @@ export default { contextWindow: "Context Window", contextWindowAuto: "Auto (detect from model name)", contextWindowHint: "Tokens. Leave empty to auto-detect. Set manually for local/custom models.", + maxRetries: "Max Retries", + maxRetriesHint: "Retry on rate limits, timeouts, and temporary network errors. 0 = never, max 10.", + maxRetriesGlobal: "Max Retries", + maxRetriesGlobalDescription: "Number of automatic retries on transient API errors (rate limits, timeouts, network blips). Applies to all API-backed AI providers. CLI providers (claude-code, codex, pi) are unaffected.", + maxRetriesRange: "{min}–{max}, default {default}", + maxRetriesSaved: "Max retries saved", codexCliPath: "Codex CLI Path", codexCliPathHint: "Leave empty to use codex from PATH. Sign in separately with codex login.", codexCliPathEnvError: "Codex CLI Path should contain only the executable path. Add environment variables below.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 2a1077a13a..344c3a2e7f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1815,6 +1815,10 @@ export default withEnglishFallback({ contextWindow: "Ventana de contexto", contextWindowAuto: "Auto (detectar desde el nombre del modelo)", contextWindowHint: "Tokens. Déjalo vacío para detección automática. Configúralo manualmente para modelos locales/personalizados.", + maxRetriesGlobal: "Reintentos máximos", + maxRetriesGlobalDescription: "Número de reintentos automáticos en errores transitorios de API (límite de tasa, timeout, fallos de red). Se aplica a todos los proveedores de IA por API. Los proveedores CLI (claude-code, codex, pi) no se ven afectados.", + maxRetriesRange: "{min}–{max}, predeterminado {default}", + maxRetriesSaved: "Reintentos máximos guardados", codexCliPath: "Ruta de Codex CLI", codexCliPathHint: "Déjalo vacío para usar codex desde PATH. Inicia sesión por separado con codex login.", actions: { @@ -1846,6 +1850,8 @@ export default withEnglishFallback({ editResend: "Reenviar", editCancel: "Cancelar", cannotDeleteActiveConfig: "No se puede eliminar la configuración activa actual. Cambie a otra configuración primero.", + maxRetries: "Número máximo de reintentos", + maxRetriesHint: "Reintentar automáticamente en caso de limitación de tasa, tiempo de espera o error de red temporal. 0 = no reintentar, máximo 10.", }, contextMenu: { openConnection: "Abrir conexión", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 574045ee93..dd32614c98 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1755,6 +1755,10 @@ export default withEnglishFallback({ contextWindow: "Finestra di Contesto", contextWindowAuto: "Auto (rileva dal nome del modello)", contextWindowHint: "Token. Lascia vuoto per il rilevamento automatico. Imposta manualmente per modelli locali/personalizzati.", + maxRetriesGlobal: "Tentativi massimi", + maxRetriesGlobalDescription: "Numero di tentativi automatici su errori API transitori (limite di velocità, timeout, problemi di rete). Si applica a tutti i provider IA via API. I provider CLI (claude-code, codex, pi) non sono interessati.", + maxRetriesRange: "{min}–{max}, predefinito {default}", + maxRetriesSaved: "Tentativi massimi salvati", codexCliPath: "Percorso Codex CLI", codexCliPathHint: "Lascia vuoto per usare codex dal PATH. Esegui login separatamente con codex login.", codexCliPathEnvError: "Codex CLI Path deve contenere solo il percorso dell'eseguibile. Aggiungi le variabili d'ambiente sotto.", @@ -1844,6 +1848,8 @@ export default withEnglishFallback({ editResend: "Invia di nuovo", editCancel: "Annulla", cannotDeleteActiveConfig: "Non è possibile eliminare la configurazione attualmente attiva. Passa a un'altra configurazione prima.", + maxRetries: "Numero massimo di tentativi", + maxRetriesHint: "Riprova automaticamente in caso di limitazione, timeout o errore di rete temporaneo. 0 = nessun tentativo, massimo 10.", }, contextMenu: { openConnection: "Apri Connessione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a730ed8e5c..a9394f57d8 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1649,6 +1649,10 @@ export default withEnglishFallback({ contextWindow: "コンテキストウィンドウ", contextWindowAuto: "自動(モデル名から検出)", contextWindowHint: "トークン数。空欄で自動検出。ローカル/カスタムモデルは手動設定してください。", + maxRetriesGlobal: "最大再試行回数", + maxRetriesGlobalDescription: "一時的なAPIエラー(レート制限、タイムアウト、ネットワーク障害)発生時の自動再試行回数。すべてのAPI対応AIプロバイダーに適用されます。CLIプロバイダー(claude-code、codex、pi)は影響を受けません。", + maxRetriesRange: "{min}–{max}、デフォルト {default}", + maxRetriesSaved: "最大再試行回数を保存しました", codexCliPath: "Codex CLI パス", codexCliPathHint: "空欄の場合はPATHからcodexを使用します。codex loginで別途サインインしてください。", reasoningLevel: "推論レベル", @@ -1845,6 +1849,8 @@ export default withEnglishFallback({ editResend: "再送信", editCancel: "キャンセル", cannotDeleteActiveConfig: "現在アクティブな設定は削除できません。先に他の設定に切り替えてください。", + maxRetries: "最大リトライ回数", + maxRetriesHint: "レート制限、タイムアウト、または一時的なネットワークエラーが発生した場合に自動的にリトライします。0 = リトライなし、最大10。", }, contextMenu: { openConnection: "接続を開く", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9a27824bc0..a29211b035 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1813,6 +1813,10 @@ export default withEnglishFallback({ contextWindow: "Janela de Contexto", contextWindowAuto: "Automático (detectar pelo nome do modelo)", contextWindowHint: "Tokens. Deixe vazio para detecção automática. Defina manualmente para modelos locais/personalizados.", + maxRetriesGlobal: "Máximo de Tentativas", + maxRetriesGlobalDescription: "Número de tentativas automáticas em erros transitórios de API (limite de taxa, timeout, falhas de rede). Aplica-se a todos os provedores de IA via API. Provedores CLI (claude-code, codex, pi) não são afetados.", + maxRetriesRange: "{min}–{max}, padrão {default}", + maxRetriesSaved: "Máximo de tentativas salvo", enableThinking: "Raciocínio", enableThinkingOn: "Ativado", enableThinkingOff: "Desativado", @@ -1846,6 +1850,8 @@ export default withEnglishFallback({ editResend: "Reenviar", editCancel: "Cancelar", cannotDeleteActiveConfig: "Não é possível excluir a configuração ativa atual. Mude para outra configuração primeiro.", + maxRetries: "Número máximo de tentativas", + maxRetriesHint: "Tente novamente automaticamente ao encontrar limitação de taxa, tempo limite ou erro de rede temporário. 0 = não tentar novamente, máximo 10.", }, contextMenu: { openConnection: "Abrir Conexão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1ef31f1dd7..c3ff1f4644 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1777,6 +1777,12 @@ export default withEnglishFallback({ contextWindow: "上下文窗口", contextWindowAuto: "自动(根据模型名称推断)", contextWindowHint: "单位:token。留空自动推断,本地/自定义模型建议手动设置。", + maxRetries: "最大重试次数", + maxRetriesHint: "遇到限流、超时或临时网络错误时自动重试。0 = 不重试,最大 10。", + maxRetriesGlobal: "最大重试次数", + maxRetriesGlobalDescription: "遇到临时 API 错误(限流、超时、网络波动)时的自动重试次数。适用于所有 API 模式 AI 供应商。CLI 供应商(claude-code、codex、pi)不受影响。", + maxRetriesRange: "{min}–{max},默认 {default}", + maxRetriesSaved: "最大重试次数已保存", codexCliPath: "Codex CLI 路径", codexCliPathHint: "留空则使用 PATH 中的 codex。请先在终端执行 codex login 登录。", codexCliPathEnvError: "Codex CLI 路径只能填写可执行文件路径。请在下方添加环境变量。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 523676181d..d2c5aea23d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1756,6 +1756,12 @@ export default withEnglishFallback({ contextWindow: "Context Window", contextWindowAuto: "自動(從模型名稱偵測)", contextWindowHint: "Token 數。留空以自動偵測。本機/自訂模型請手動設定。", + maxRetries: "最大重試次數", + maxRetriesHint: "遇到限流、超時或暫時性網路錯誤時自動重試。0 = 不重試,最大 10。", + maxRetriesGlobal: "最大重試次數", + maxRetriesGlobalDescription: "遇到暫時性 API 錯誤(限流、超時、網路波動)時的自動重試次數。適用於所有 API 模式 AI 供應商。CLI 供應商(claude-code、codex、pi)不受影響。", + maxRetriesRange: "{min}–{max},預設 {default}", + maxRetriesSaved: "最大重試次數已儲存", codexCliPath: "Codex CLI 路徑", codexCliPathHint: "留空以使用 PATH 中的 codex。請另外以 codex login 登入。", codexMcpRequiredTitle: "需要 DBX MCP Server", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 5342f16df1..7d6e648503 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -272,6 +272,8 @@ export const loadMcpGlobalPolicy = forward("loadMcpGlobalPolicy"); export const saveMcpGlobalPolicy = forward("saveMcpGlobalPolicy"); export const loadMaxAgentTurns = forward("loadMaxAgentTurns"); export const saveMaxAgentTurns = forward("saveMaxAgentTurns"); +export const loadMaxRetries = forward("loadMaxRetries"); +export const saveMaxRetries = forward("saveMaxRetries"); export const completeAppClose = forward("completeAppClose"); export const requestAppClose = forward("requestAppClose"); export const setDriverStoreDir = forward("setDriverStoreDir"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index eb880bb0f2..9526c3ae06 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -281,7 +281,10 @@ export async function connectionDatabaseInfo(connectionId: string, database?: st } export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise { - return post("/api/connection/database-info/save", { connectionId, databaseInfo }); + return post("/api/connection/database-info/save", { + connectionId, + databaseInfo, + }); } export async function connectionFinalProxyPort(config: ConnectionConfig): Promise { @@ -297,7 +300,10 @@ export async function checkConnectionHealth(connectionId: string): Promise } export async function connectionIdentifierQuote(connectionId: string, database?: string): Promise { - const quote = await post("/api/connection/identifier-quote", { connectionId, database }); + const quote = await post("/api/connection/identifier-quote", { + connectionId, + database, + }); return quote ?? undefined; } @@ -376,7 +382,10 @@ export async function importJdbcDrivers(pathsOrFiles: (string | File)[]): Promis formData.append("files", blob, fileName); } } - const res = await fetch(apiUrl("/api/jdbc/drivers"), { method: "POST", body: formData }); + const res = await fetch(apiUrl("/api/jdbc/drivers"), { + method: "POST", + body: formData, + }); if (!res.ok) throw new Error(await res.text()); return res.json(); } @@ -422,7 +431,10 @@ export async function installJdbcPluginLocal(pathOrFile: string | File): Promise } const formData = new FormData(); formData.append("file", blob, fileName); - const uploadRes = await fetch(apiUrl("/api/jdbc/plugin/install-local"), { method: "POST", body: formData }); + const uploadRes = await fetch(apiUrl("/api/jdbc/plugin/install-local"), { + method: "POST", + body: formData, + }); if (!uploadRes.ok) throw new Error(await uploadRes.text()); return uploadRes.json(); } @@ -498,7 +510,10 @@ export async function importAgentsFromZip(fileOrPath: string | File, operationId const formData = new FormData(); if (operationId) formData.append("operationId", operationId); formData.append("file", fileOrPath); - const res = await fetch(apiUrl("/api/agents/import-offline"), { method: "POST", body: formData }); + const res = await fetch(apiUrl("/api/agents/import-offline"), { + method: "POST", + body: formData, + }); if (!res.ok) throw new Error(await res.text()); const result: { count: number } = await res.json(); return result.count; @@ -517,7 +532,10 @@ export async function importAgentDriver(dbType: string, pathOrFile: string | Fil const formData = new FormData(); formData.append("dbType", dbType); formData.append("file", blob, fileName); - const uploadRes = await fetch(apiUrl("/api/agents/import-driver"), { method: "POST", body: formData }); + const uploadRes = await fetch(apiUrl("/api/agents/import-driver"), { + method: "POST", + body: formData, + }); if (!uploadRes.ok) throw new Error(await uploadRes.text()); } @@ -604,7 +622,10 @@ export async function listDatabases(connectionId: string): Promise { - return post("/api/schema/database-storage", { connection_id: connectionId, databases }); + return post("/api/schema/database-storage", { + connection_id: connectionId, + databases, + }); } export async function listDorisCatalogs(connectionId: string): Promise { @@ -800,7 +821,14 @@ export async function executeQuery( executionMode?: "simple"; }, ): Promise { - return post("/api/query/execute", { connectionId, database, sql, schema, executionId, ...options }); + return post("/api/query/execute", { + connectionId, + database, + sql, + schema, + executionId, + ...options, + }); } export async function executeMulti( @@ -821,7 +849,14 @@ export async function executeMulti( executionMode?: "simple"; }, ): Promise { - return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options }); + return post("/api/query/execute-multi", { + connectionId, + database, + sql, + schema, + executionId, + ...options, + }); } export interface ExecuteMultiProgress { @@ -851,32 +886,66 @@ export async function executeMultiWithProgress( ): Promise { const executionId = crypto.randomUUID(); const results = await executeMulti(connectionId, database, sql, schema, executionId, options); - onProgress({ executionId, completed: results.length, total: results.length, success: !results.some((result) => result.execution_error === true) }); + onProgress({ + executionId, + completed: results.length, + total: results.length, + success: !results.some((result) => result.execution_error === true), + }); return results; } export async function closeQuerySession(connectionId: string, database: string, sessionId: string, clientSessionId?: string): Promise { - return post("/api/query/close-session", { connectionId, database, sessionId, clientSessionId }); + return post("/api/query/close-session", { + connectionId, + database, + sessionId, + clientSessionId, + }); } export async function closeClientConnectionSession(connectionId: string, database: string, clientSessionId: string): Promise { - return post("/api/query/close-client-session", { connectionId, database, clientSessionId }); + return post("/api/query/close-client-session", { + connectionId, + database, + clientSessionId, + }); } export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string): Promise { - return post("/api/query/execute-batch", { connectionId, database, statements, schema }); + return post("/api/query/execute-batch", { + connectionId, + database, + statements, + schema, + }); } export async function executeScript(connectionId: string, database: string, sql: string, schema?: string): Promise { - return post("/api/query/execute-script", { connectionId, database, sql, schema }); + return post("/api/query/execute-script", { + connectionId, + database, + sql, + schema, + }); } export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise { - return post("/api/query/execute-script-2pc", { connectionId, database, statements, schema }); + return post("/api/query/execute-script-2pc", { + connectionId, + database, + statements, + schema, + }); } export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise { - return post("/api/query/execute-in-transaction", { connectionId, database, statements, schema }); + return post("/api/query/execute-in-transaction", { + connectionId, + database, + statements, + schema, + }); } export async function beginManualTransaction(_connectionId: string, _database: string, _schema?: string): Promise { @@ -905,7 +974,11 @@ export async function analyzeSqlReferences(sql: string, dialect?: string): Promi } export async function findStatementAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType): Promise { - return post("/api/query/find-statement-at-cursor", { sql, cursorPos, databaseType }); + return post("/api/query/find-statement-at-cursor", { + sql, + cursorPos, + databaseType, + }); } export async function prepareQueryPaginationExecutionPlan(options: QueryPaginationExecutionPlanOptions): Promise { @@ -921,12 +994,22 @@ export async function buildExplainSql(options: BuildExplainSqlOptions): Promise< } export async function buildCreateUserSql(username: string, password: string, tablespace: string): Promise { - return post("/api/query/build-create-user-sql", { username, password, tablespace }); + return post("/api/query/build-create-user-sql", { + username, + password, + tablespace, + }); } export async function getExplainInfo(connectionId: string, database: string | undefined, schema: string | undefined, sql: string, mode: string): Promise { // Match the Tauri path: transport and Agent failures must remain distinguishable from an empty plan. - return post("/api/query/get-explain-info", { connectionId, database, schema, sql, mode }); + return post("/api/query/get-explain-info", { + connectionId, + database, + schema, + sql, + mode, + }); } export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise { @@ -955,11 +1038,15 @@ export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): } export async function buildDuckDbAttachDatabaseSql(path: string, name: string): Promise { - return post("/api/query/build-duckdb-attach-database-sql", { options: { path, name } }); + return post("/api/query/build-duckdb-attach-database-sql", { + options: { path, name }, + }); } export async function buildSqliteAttachDatabaseSql(path: string, name: string): Promise { - return post("/api/query/build-sqlite-attach-database-sql", { options: { path, name } }); + return post("/api/query/build-sqlite-attach-database-sql", { + options: { path, name }, + }); } export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise { @@ -1007,7 +1094,9 @@ export async function buildCopyTableDataSql(options: CopyTableDataSqlOptions): P } export async function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput): Promise { - return post("/api/query/build-executable-object-source-statements", { input }); + return post("/api/query/build-executable-object-source-statements", { + input, + }); } export async function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput): Promise { @@ -1019,7 +1108,9 @@ export async function buildEditableObjectSource(input: BuildEditableObjectSource } export async function buildRoutineRenameObjectSourceStatements(input: BuildRoutineRenameObjectSourceInput): Promise { - return post("/api/query/build-routine-rename-object-source-statements", { input }); + return post("/api/query/build-routine-rename-object-source-statements", { + input, + }); } export async function buildViewDdlSql(input: BuildViewDdlInput): Promise { @@ -1031,11 +1122,20 @@ export async function buildTableStructureChangeSql(options: BuildTableStructureC } export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise { - return post("/api/query/preview-sqlite-table-structure-change", { connectionId, database, options }); + return post("/api/query/preview-sqlite-table-structure-change", { + connectionId, + database, + options, + }); } export async function applySqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions, schemaRevision: string): Promise { - return post("/api/query/apply-sqlite-table-structure-change", { connectionId, database, options, schemaRevision }); + return post("/api/query/apply-sqlite-table-structure-change", { + connectionId, + database, + options, + schemaRevision, + }); } export async function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions): Promise { @@ -1083,7 +1183,9 @@ export async function buildDataGridColumnValuesFilterCondition(options: DataGrid } export async function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise { - return post("/api/query/build-data-grid-column-distinct-values-sql", { options }); + return post("/api/query/build-data-grid-column-distinct-values-sql", { + options, + }); } export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise { @@ -1214,7 +1316,18 @@ export async function aiAgentStream( const res = await fetch(apiUrl("/api/ai/agent-stream"), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId, request, connectionId, database, dbType, mode: mode || "ask", allowWriteSql, confirmedWriteSql, confirmedConnectionId, confirmedDatabase }), + body: JSON.stringify({ + sessionId, + request, + connectionId, + database, + dbType, + mode: mode || "ask", + allowWriteSql, + confirmedWriteSql, + confirmedConnectionId, + confirmedDatabase, + }), signal, }); if (!res.ok) throw new Error(await res.text()); @@ -1295,7 +1408,12 @@ export async function deleteAiConfig(configId: string): Promise { export async function loadDesktopSettings(): Promise { try { const raw = safeLocalStorageGet(DESKTOP_SETTINGS_STORAGE_KEY); - return raw ? { ...DEFAULT_DESKTOP_SETTINGS, ...(JSON.parse(raw) as Partial) } : { ...DEFAULT_DESKTOP_SETTINGS }; + return raw + ? { + ...DEFAULT_DESKTOP_SETTINGS, + ...(JSON.parse(raw) as Partial), + } + : { ...DEFAULT_DESKTOP_SETTINGS }; } catch { return { ...DEFAULT_DESKTOP_SETTINGS }; } @@ -1331,6 +1449,19 @@ export async function saveMaxAgentTurns(maxAgentTurns: number): Promise { if (!res.ok) throw new Error(await res.text()); } +export async function loadMaxRetries(): Promise { + return get("/api/app-settings/max-retries"); +} + +export async function saveMaxRetries(maxRetries: number): Promise { + const res = await fetch(apiUrl("/api/app-settings/max-retries"), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxRetries }), + }); + if (!res.ok) throw new Error(await res.text()); +} + export interface OpenTabsStatePayload { tabs: unknown[]; activeTabId: string | null; @@ -1348,7 +1479,12 @@ export async function loadOpenTabsState(): Promise const value = await loadBrowserAppState("open_tabs"); if (!value || typeof value !== "object") return null; const payload = value as Partial; - return Array.isArray(payload.tabs) ? { tabs: payload.tabs, activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null } : null; + return Array.isArray(payload.tabs) + ? { + tabs: payload.tabs, + activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null, + } + : null; } export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise { @@ -1487,7 +1623,10 @@ export async function webdavSyncSecretsStatus(): Promise { - return post("/api/cloud-sync/webdav/save-sync-secrets-preference", { enabled, passphrase }); + return post("/api/cloud-sync/webdav/save-sync-secrets-preference", { + enabled, + passphrase, + }); } export async function forgetWebdavSyncSecretsPassphrase(): Promise { @@ -1495,7 +1634,11 @@ export async function forgetWebdavSyncSecretsPassphrase(): Promise { } export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise { - return post("/api/cloud-sync/webdav/upload", { config, editorSettings, secretsPassphrase }); + return post("/api/cloud-sync/webdav/upload", { + config, + editorSettings, + secretsPassphrase, + }); } export async function webdavSyncDownload(config: WebDavConfig, secretsPassphrase?: string): Promise { @@ -1519,11 +1662,18 @@ export async function forgetSnippetSavedToken(config: SnippetSyncConfig): Promis } export async function snippetSyncUpload(config: SnippetSyncConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise { - return post("/api/cloud-sync/snippet/upload", { config, editorSettings, secretsPassphrase }); + return post("/api/cloud-sync/snippet/upload", { + config, + editorSettings, + secretsPassphrase, + }); } export async function snippetSyncDownload(config: SnippetSyncConfig, secretsPassphrase?: string): Promise { - return post("/api/cloud-sync/snippet/download", { config, secretsPassphrase }); + return post("/api/cloud-sync/snippet/download", { + config, + secretsPassphrase, + }); } export async function loadPinnedTreeNodeIds(): Promise { @@ -1584,7 +1734,10 @@ export async function previewSqlFile(fileOrPath: string | File): Promise { +export async function exportQueryResultsXlsx( + filePath: string, + worksheets: readonly { + sheetName?: string; + columns: readonly string[]; + columnTypes?: readonly string[]; + rows: readonly (readonly XlsxCellValue[])[]; + numericColumnRightAlign?: boolean; + }[], +): Promise { const { buildXlsxWorkbookMulti } = await import("@/lib/export/xlsxExport"); const workbook = buildXlsxWorkbookMulti(worksheets); const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx"; @@ -2026,15 +2193,37 @@ export async function redisListDatabases(connectionId: string): Promise { - return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count }); + return post("/api/redis/scan-keys", { + connectionId, + db, + cursor, + pattern, + count, + }); } export async function redisScanKeysBatch(connectionId: string, db: number, cursor: number, pattern: string, count: number, maxIterations: number, includeTypes = true): Promise { - return post("/api/redis/scan-keys-batch", { connectionId, db, cursor, pattern, count, maxIterations, includeTypes }); + return post("/api/redis/scan-keys-batch", { + connectionId, + db, + cursor, + pattern, + count, + maxIterations, + includeTypes, + }); } export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise { - return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count }); + return post("/api/redis/scan-values", { + connectionId, + db, + cursor, + pattern, + query, + includeKeyMatches, + count, + }); } export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise { @@ -2046,15 +2235,33 @@ export async function redisGetStreamGroups(connectionId: string, db: number, key } export async function redisGetStreamConsumers(connectionId: string, db: number, keyRaw: string, groupRaw: string): Promise { - return post("/api/redis/get-stream-consumers", { connectionId, db, keyRaw, groupRaw }); + return post("/api/redis/get-stream-consumers", { + connectionId, + db, + keyRaw, + groupRaw, + }); } export async function redisGetStreamPending(connectionId: string, db: number, keyRaw: string, groupRaw: string, cursor?: string, consumerRaw?: string): Promise { - return post("/api/redis/get-stream-pending", { connectionId, db, keyRaw, groupRaw, cursor, ...(consumerRaw === undefined ? {} : { consumerRaw }) }); + return post("/api/redis/get-stream-pending", { + connectionId, + db, + keyRaw, + groupRaw, + cursor, + ...(consumerRaw === undefined ? {} : { consumerRaw }), + }); } export async function redisSetString(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise { - return post("/api/redis/set-string", { connectionId, db, keyRaw, value, ttl }); + return post("/api/redis/set-string", { + connectionId, + db, + keyRaw, + value, + ttl, + }); } export async function redisDeleteKey(connectionId: string, db: number, keyRaw: string): Promise { @@ -2062,7 +2269,14 @@ export async function redisDeleteKey(connectionId: string, db: number, keyRaw: s } export async function redisHashSet(connectionId: string, db: number, keyRaw: string, field: string, value: string, ttl?: number): Promise { - return post("/api/redis/hash-set", { connectionId, db, keyRaw, field, value, ttl }); + return post("/api/redis/hash-set", { + connectionId, + db, + keyRaw, + field, + value, + ttl, + }); } export async function redisHashDel(connectionId: string, db: number, keyRaw: string, field: string): Promise { @@ -2074,7 +2288,13 @@ export async function redisListPush(connectionId: string, db: number, keyRaw: st } export async function redisListSet(connectionId: string, db: number, keyRaw: string, index: number, value: string): Promise { - return post("/api/redis/list-set", { connectionId, db, keyRaw, index, value }); + return post("/api/redis/list-set", { + connectionId, + db, + keyRaw, + index, + value, + }); } export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise { @@ -2090,7 +2310,14 @@ export async function redisSetRemove(connectionId: string, db: number, keyRaw: s } export async function redisZadd(connectionId: string, db: number, keyRaw: string, member: string, score: number, ttl?: number): Promise { - return post("/api/redis/zadd", { connectionId, db, keyRaw, member, score, ttl }); + return post("/api/redis/zadd", { + connectionId, + db, + keyRaw, + member, + score, + ttl, + }); } export async function redisZrem(connectionId: string, db: number, keyRaw: string, member: string): Promise { @@ -2098,7 +2325,14 @@ export async function redisZrem(connectionId: string, db: number, keyRaw: string } export async function redisStreamAdd(connectionId: string, db: number, keyRaw: string, entryId: string, fields: [string, string][], ttl?: number): Promise { - return post("/api/redis/stream-add", { connectionId, db, keyRaw, entryId, fields, ttl }); + return post("/api/redis/stream-add", { + connectionId, + db, + keyRaw, + entryId, + fields, + ttl, + }); } export async function redisJsonSet(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise { @@ -2114,7 +2348,12 @@ export async function redisSetTtl(connectionId: string, db: number, keyRaw: stri } export async function redisSetExpireAt(connectionId: string, db: number, keyRaw: string, expireAt: number): Promise { - return post("/api/redis/set-expire-at", { connectionId, db, keyRaw, expireAt }); + return post("/api/redis/set-expire-at", { + connectionId, + db, + keyRaw, + expireAt, + }); } export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise { @@ -2126,15 +2365,33 @@ export async function redisFlushDb(connectionId: string, db: number): Promise { - return post("/api/redis/execute-command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false }); + return post("/api/redis/execute-command", { + connectionId, + db, + command, + skipSafetyCheck: skipSafetyCheck ?? false, + }); } export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise { - return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count, filter }); + return post("/api/redis/load-more", { + connectionId, + db, + keyRaw, + keyType, + cursor, + count, + filter, + }); } export async function redisPubSubPublish(connectionId: string, db: number, channel: string, message: string): Promise<{ subscribers: number }> { - return post("/api/redis/pubsub/publish", { connectionId, db, channel, message }); + return post("/api/redis/pubsub/publish", { + connectionId, + db, + channel, + message, + }); } export async function redisPubSubConnect(connectionId: string): Promise { @@ -2142,7 +2399,12 @@ export async function redisPubSubConnect(connectionId: string): Promise { - return post("/api/redis/slowlog-get", { connectionId, count, nodeHost, nodePort }); + return post("/api/redis/slowlog-get", { + connectionId, + count, + nodeHost, + nodePort, + }); } export async function redisClusterMasterNodes(connectionId: string): Promise { @@ -2154,7 +2416,14 @@ export async function redisClusterMasterNodes(connectionId: string): Promise { - return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation, revision: options?.revision ?? null, includeValues: options?.includeValues ?? null }); + return post("/api/etcd/list-prefix", { + connectionId, + prefix, + limit, + continuation, + revision: options?.revision ?? null, + includeValues: options?.includeValues ?? null, + }); } export async function etcdSupportsTtl(connectionId: string): Promise { @@ -2162,7 +2431,13 @@ export async function etcdSupportsTtl(connectionId: string): Promise { } export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise { - return post("/api/etcd/get", { connectionId, key, keyBytes: options?.keyBytes ?? null, revision: options?.revision ?? null, metadataOnly: options?.metadataOnly ?? null }); + return post("/api/etcd/get", { + connectionId, + key, + keyBytes: options?.keyBytes ?? null, + revision: options?.revision ?? null, + metadataOnly: options?.metadataOnly ?? null, + }); } export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise { @@ -2182,14 +2457,36 @@ export async function etcdPut(connectionId: string, key: string, value: KvValue, } export async function etcdDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise { - return post("/api/etcd/delete", { connectionId, key, keyBytes: options?.keyBytes ?? null, expectedModRevision: options?.expectedModRevision ?? null }); + return post("/api/etcd/delete", { + connectionId, + key, + keyBytes: options?.keyBytes ?? null, + expectedModRevision: options?.expectedModRevision ?? null, + }); } -export async function etcdRename(connectionId: string, request: { key: string; keyBytes?: KvValue | null; newKey: string; expectedModRevision?: KvInt64 | null }): Promise<{ renamed: boolean; revision?: KvInt64 | null }> { +export async function etcdRename( + connectionId: string, + request: { + key: string; + keyBytes?: KvValue | null; + newKey: string; + expectedModRevision?: KvInt64 | null; + }, +): Promise<{ renamed: boolean; revision?: KvInt64 | null }> { return post("/api/etcd/rename", { connectionId, request }); } -export async function etcdHistory(connectionId: string, request: { key: string; keyBytes?: KvValue | null; startRevision?: KvInt64 | null; endRevision?: KvInt64 | null; limit: number }): Promise { +export async function etcdHistory( + connectionId: string, + request: { + key: string; + keyBytes?: KvValue | null; + startRevision?: KvInt64 | null; + endRevision?: KvInt64 | null; + limit: number; + }, +): Promise { return post("/api/etcd/history", { connectionId, request }); } @@ -2202,7 +2499,13 @@ export async function etcdStatus(connectionId: string): Promise { - return post("/api/zookeeper/list-prefix", { connectionId, prefix, limit, continuation, recursive: options?.recursive ?? null }); + return post("/api/zookeeper/list-prefix", { + connectionId, + prefix, + limit, + continuation, + recursive: options?.recursive ?? null, + }); } export async function zookeeperGet(connectionId: string, key: string): Promise { @@ -2210,7 +2513,12 @@ export async function zookeeperGet(connectionId: string, key: string): Promise { - return post("/api/zookeeper/put", { connectionId, key, value, options: options ?? null }); + return post("/api/zookeeper/put", { + connectionId, + key, + value, + options: options ?? null, + }); } export async function zookeeperDelete(connectionId: string, key: string): Promise { @@ -2322,14 +2630,24 @@ export async function nacosPreviewConfigImport(connectionId: string, targetNames formData.append("connectionId", connectionId); formData.append("targetNamespace", targetNamespace); formData.append("file", archivePath, archivePath.name); - const response = await fetch(apiUrl("/api/nacos/configs/import/preview"), { method: "POST", body: formData }); + const response = await fetch(apiUrl("/api/nacos/configs/import/preview"), { + method: "POST", + body: formData, + }); if (!response.ok) throw new Error(await response.text()); return response.json(); } export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, _archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, archiveToken?: string): Promise { if (!archiveToken) throw new Error("The Nacos import preview token is missing or expired"); - return post("/api/nacos/configs/import/apply", { connectionId, operationId, targetNamespace, archiveToken, planHash, conflictPolicy }); + return post("/api/nacos/configs/import/apply", { + connectionId, + operationId, + targetNamespace, + archiveToken, + planHash, + conflictPolicy, + }); } export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise { @@ -2389,11 +2707,23 @@ export async function hbaseGetTableSchema(connectionId: string, namespace: strin } export async function hbaseScanRows(connectionId: string, namespace: string, table: string, rowKeyPrefix: string | undefined, limit: number): Promise { - return post("/api/hbase/scan-rows", { connectionId, namespace, table, rowKeyPrefix, limit }); + return post("/api/hbase/scan-rows", { + connectionId, + namespace, + table, + rowKeyPrefix, + limit, + }); } export async function hbaseGetRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise { - return post("/api/hbase/get-row", { connectionId, namespace, table, rowKey, rowKeyEncoding }); + return post("/api/hbase/get-row", { + connectionId, + namespace, + table, + rowKey, + rowKeyEncoding, + }); } export async function hbasePutRow(connectionId: string, namespace: string, table: string, input: import("@/types/hbase").HBasePutRowInput): Promise { @@ -2401,11 +2731,22 @@ export async function hbasePutRow(connectionId: string, namespace: string, table } export async function hbaseDeleteRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise { - return post("/api/hbase/delete-row", { connectionId, namespace, table, rowKey, rowKeyEncoding }); + return post("/api/hbase/delete-row", { + connectionId, + namespace, + table, + rowKey, + rowKeyEncoding, + }); } export async function hbaseCreateTable(connectionId: string, namespace: string, table: string, columnFamilies: string[]): Promise { - return post("/api/hbase/create-table", { connectionId, namespace, table, columnFamilies }); + return post("/api/hbase/create-table", { + connectionId, + namespace, + table, + columnFamilies, + }); } export async function hbaseDeleteTable(connectionId: string, namespace: string, table: string): Promise { @@ -2425,7 +2766,10 @@ export async function mongoListDatabases(connectionId: string): Promise { - return post("/api/document-store/list-collections", { connectionId, database }); + return post("/api/document-store/list-collections", { + connectionId, + database, + }); } export async function mongoListCollections(connectionId: string, database: string): Promise { @@ -2441,11 +2785,20 @@ export async function mongoDropDatabase(connectionId: string, database: string): } export async function mongoDropCollection(connectionId: string, database: string, collection: string): Promise { - await post("/api/mongo/drop-collection", { connectionId, database, collection }); + await post("/api/mongo/drop-collection", { + connectionId, + database, + collection, + }); } export async function mongoRenameCollection(connectionId: string, database: string, collection: string, newName: string): Promise { - await post("/api/mongo/rename-collection", { connectionId, database, collection, newName }); + await post("/api/mongo/rename-collection", { + connectionId, + database, + collection, + newName, + }); } export async function elasticsearchListIndices(connectionId: string): Promise { @@ -2458,7 +2811,11 @@ export async function vectorListCollections(connectionId: string, database?: str } export async function vectorGetCollectionDetail(connectionId: string, database: string, collection: string): Promise { - return post("/api/mongo/vector-collection-detail", { connectionId, database, collection }); + return post("/api/mongo/vector-collection-detail", { + connectionId, + database, + collection, + }); } export async function mongoFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise { @@ -2471,35 +2828,84 @@ export async function mongoParseShellCommand(source: string): Promise { - return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId }); + return post("/api/mongo/find-one", { + connectionId, + database, + collection, + filter, + projection, + options, + executionId, + }); } export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise { - return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId }); + return post("/api/document-store/find-documents", { + connectionId, + database, + collection, + skip, + limit, + filter, + projection, + sort, + executionId, + }); } export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise { - return post("/api/document-store/elasticsearch-count-documents", { connectionId, index, filter, executionId }); + return post("/api/document-store/elasticsearch-count-documents", { + connectionId, + index, + filter, + executionId, + }); } export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise { - return post("/api/mongo/count-documents", { connectionId, database, collection, filter, mode, executionId }); + return post("/api/mongo/count-documents", { + connectionId, + database, + collection, + filter, + mode, + executionId, + }); } export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string, filter?: string, sort?: string): Promise { - return post("/api/document-store/list-gridfs-files", { connectionId, database, bucket, filter, sort }); + return post("/api/document-store/list-gridfs-files", { + connectionId, + database, + bucket, + filter, + sort, + }); } export async function documentListGridFsBuckets(connectionId: string, database: string, filter?: string, sort?: string): Promise { - return post("/api/document-store/list-gridfs-buckets", { connectionId, database, filter, sort }); + return post("/api/document-store/list-gridfs-buckets", { + connectionId, + database, + filter, + sort, + }); } export async function documentCreateGridFsBucket(connectionId: string, database: string, bucket: string): Promise { - return post("/api/document-store/create-gridfs-bucket", { connectionId, database, bucket }); + return post("/api/document-store/create-gridfs-bucket", { + connectionId, + database, + bucket, + }); } export async function documentDeleteGridFsBucket(connectionId: string, database: string, bucket: string): Promise { - return post("/api/document-store/delete-gridfs-bucket", { connectionId, database, bucket }); + return post("/api/document-store/delete-gridfs-bucket", { + connectionId, + database, + bucket, + }); } export async function documentDownloadGridFsFile(connectionId: string, database: string, bucket: string, fileId: string): Promise { @@ -2532,31 +2938,73 @@ export async function documentUploadGridFsFile(connectionId: string, database: s } export async function documentDeleteGridFsFile(connectionId: string, database: string, bucket: string, fileId: string): Promise { - return post("/api/document-store/delete-gridfs-file", { connectionId, database, bucket, fileId }); + return post("/api/document-store/delete-gridfs-file", { + connectionId, + database, + bucket, + fileId, + }); } export async function mongoServerVersion(connectionId: string, database: string, executionId?: string): Promise { - return post("/api/mongo/server-version", { connectionId, database, executionId }); + return post("/api/mongo/server-version", { + connectionId, + database, + executionId, + }); } export async function mongoAggregateDocuments(connectionId: string, database: string, collection: string, pipelineJson: string, maxRows?: number, optionsJson?: string, executionId?: string): Promise { - return post("/api/mongo/aggregate-documents", { connectionId, database, collection, pipelineJson, maxRows, optionsJson, executionId }); + return post("/api/mongo/aggregate-documents", { + connectionId, + database, + collection, + pipelineJson, + maxRows, + optionsJson, + executionId, + }); } export async function mongoDistinct(connectionId: string, database: string, collection: string, field: string, filter?: string, executionId?: string): Promise { - return post("/api/mongo/distinct", { connectionId, database, collection, field, filter, executionId }); + return post("/api/mongo/distinct", { + connectionId, + database, + collection, + field, + filter, + executionId, + }); } export async function mongoCollectionStats(connectionId: string, database: string, collection: string, scale?: number, executionId?: string): Promise { - return post("/api/mongo/collection-stats", { connectionId, database, collection, scale, executionId }); + return post("/api/mongo/collection-stats", { + connectionId, + database, + collection, + scale, + executionId, + }); } export async function mongoCreateIndex(connectionId: string, database: string, collection: string, keysJson: string, optionsJson?: string): Promise<{ name: string }> { - return post("/api/mongo/create-index", { connectionId, database, collection, keysJson, optionsJson }); + return post("/api/mongo/create-index", { + connectionId, + database, + collection, + keysJson, + optionsJson, + }); } export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> { - return post("/api/mongo/drop-indexes", { connectionId, database, collection, indexesJson, single }); + return post("/api/mongo/drop-indexes", { + connectionId, + database, + collection, + indexesJson, + single, + }); } export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { @@ -2564,11 +3012,22 @@ export async function mongoInsertDocument(connectionId: string, database: string } export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { - return post("/api/document-store/insert-document", { connectionId, database, collection, docJson, routing }); + return post("/api/document-store/insert-document", { + connectionId, + database, + collection, + docJson, + routing, + }); } export async function mongoInsertDocuments(connectionId: string, database: string, collection: string, docsJson: string): Promise<{ affected_rows: number }> { - return post("/api/mongo/insert-documents", { connectionId, database, collection, docsJson }); + return post("/api/mongo/insert-documents", { + connectionId, + database, + collection, + docsJson, + }); } export async function mongoUpdateDocument(connectionId: string, database: string, collection: string, id: string, docJson: string, routing?: string): Promise { @@ -2576,11 +3035,26 @@ export async function mongoUpdateDocument(connectionId: string, database: string } export async function documentUpdateDocument(connectionId: string, database: string, collection: string, id: string, docJson: string, routing?: string): Promise { - return post("/api/document-store/update-document", { connectionId, database, collection, id, docJson, routing }); + return post("/api/document-store/update-document", { + connectionId, + database, + collection, + id, + docJson, + routing, + }); } export async function mongoUpdateDocuments(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, many: boolean, optionsJson?: string): Promise<{ affected_rows: number }> { - return post("/api/mongo/update-documents", { connectionId, database, collection, filterJson, updateJson, many, optionsJson }); + return post("/api/mongo/update-documents", { + connectionId, + database, + collection, + filterJson, + updateJson, + many, + optionsJson, + }); } export async function mongoDeleteDocument(connectionId: string, database: string, collection: string, id: string, routing?: string): Promise { @@ -2588,23 +3062,55 @@ export async function mongoDeleteDocument(connectionId: string, database: string } export async function documentDeleteDocument(connectionId: string, database: string, collection: string, id: string, routing?: string): Promise { - return post("/api/document-store/delete-document", { connectionId, database, collection, id, routing }); + return post("/api/document-store/delete-document", { + connectionId, + database, + collection, + id, + routing, + }); } export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> { - return post("/api/mongo/delete-documents", { connectionId, database, collection, filterJson, many }); + return post("/api/mongo/delete-documents", { + connectionId, + database, + collection, + filterJson, + many, + }); } export async function mongoFindOneAndUpdate(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, optionsJson?: string): Promise { - return post("/api/mongo/find-one-and-update", { connectionId, database, collection, filterJson, updateJson, optionsJson }); + return post("/api/mongo/find-one-and-update", { + connectionId, + database, + collection, + filterJson, + updateJson, + optionsJson, + }); } export async function mongoFindOneAndReplace(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise { - return post("/api/mongo/find-one-and-replace", { connectionId, database, collection, filterJson, replacementJson, optionsJson }); + return post("/api/mongo/find-one-and-replace", { + connectionId, + database, + collection, + filterJson, + replacementJson, + optionsJson, + }); } export async function mongoFindOneAndDelete(connectionId: string, database: string, collection: string, filterJson: string, optionsJson?: string): Promise { - return post("/api/mongo/find-one-and-delete", { connectionId, database, collection, filterJson, optionsJson }); + return post("/api/mongo/find-one-and-delete", { + connectionId, + database, + collection, + filterJson, + optionsJson, + }); } // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index f0d25ed3d0..df2f708b1f 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -393,11 +393,29 @@ export type AgentEvent = | { type: "turn_start"; turn: number } | { type: "text_delta"; delta: string } | { type: "reasoning_delta"; delta: string } - | { type: "tool_call_start"; tool_call_id: string; tool_name: string; args: Record } - | { type: "tool_call_end"; tool_call_id: string; tool_name: string; result: unknown; is_error: boolean } + | { + type: "tool_call_start"; + tool_call_id: string; + tool_name: string; + args: Record; + } + | { + type: "tool_call_end"; + tool_call_id: string; + tool_name: string; + result: unknown; + is_error: boolean; + } | { type: "turn_end"; turn: number } | { type: "agent_end"; input_tokens?: number; output_tokens?: number } - | { type: "context_compacted"; summary: string; summary_tokens: number; compacted_messages: number; estimated_before: number; estimated_after: number } + | { + type: "context_compacted"; + summary: string; + summary_tokens: number; + compacted_messages: number; + estimated_before: number; + estimated_after: number; + } | { type: "error"; message: string }; export async function aiAgentStream( @@ -421,7 +439,18 @@ export async function aiAgentStream( } }); try { - return await invoke("ai_agent_stream", { sessionId, request, connectionId, database, dbType, mode, allowWriteSql, confirmedWriteSql, confirmedConnectionId, confirmedDatabase }); + return await invoke("ai_agent_stream", { + sessionId, + request, + connectionId, + database, + dbType, + mode, + allowWriteSql, + confirmedWriteSql, + confirmedConnectionId, + confirmedDatabase, + }); } catch (e) { unlisten(); throw e; @@ -512,6 +541,14 @@ export async function saveMaxAgentTurns(maxAgentTurns: number): Promise { return invoke("save_max_agent_turns", { maxAgentTurns }); } +export async function loadMaxRetries(): Promise { + return invoke("load_max_retries"); +} + +export async function saveMaxRetries(maxRetries: number): Promise { + return invoke("save_max_retries", { maxRetries }); +} + export interface OpenTabsStatePayload { tabs: unknown[]; activeTabId: string | null; @@ -612,7 +649,11 @@ export async function forgetWebdavSyncSecretsPassphrase(): Promise { } export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise { - return invoke("webdav_sync_upload", { config, editorSettings, secretsPassphrase }); + return invoke("webdav_sync_upload", { + config, + editorSettings, + secretsPassphrase, + }); } export async function webdavSyncDownload(config: WebDavConfig, secretsPassphrase?: string): Promise { @@ -636,7 +677,11 @@ export async function forgetSnippetSavedToken(config: SnippetSyncConfig): Promis } export async function snippetSyncUpload(config: SnippetSyncConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise { - return invoke("snippet_sync_upload", { config, editorSettings, secretsPassphrase }); + return invoke("snippet_sync_upload", { + config, + editorSettings, + secretsPassphrase, + }); } export async function snippetSyncDownload(config: SnippetSyncConfig, secretsPassphrase?: string): Promise { @@ -766,7 +811,9 @@ export async function testConnection(config: ConnectionConfig): Promise export async function testConnectionWithInfo(config: ConnectionConfig): Promise { try { - const result = await invoke("test_connection_with_info", { config }); + const result = await invoke("test_connection_with_info", { + config, + }); return normalizeConnectionTestResult(result, config); } catch (error) { if (!isTauriCommandUnavailable(error, "test_connection_with_info")) throw error; @@ -784,7 +831,10 @@ export async function connectionDatabaseInfo(connectionId: string, database?: st } export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise { - return invoke("save_connection_database_info", { connectionId, databaseInfo }); + return invoke("save_connection_database_info", { + connectionId, + databaseInfo, + }); } export async function connectionFinalProxyPort(config: ConnectionConfig): Promise { @@ -800,7 +850,10 @@ export async function checkConnectionHealth(connectionId: string): Promise } export async function connectionIdentifierQuote(connectionId: string, database?: string): Promise { - const quote = await invoke("connection_identifier_quote", { connectionId, database }); + const quote = await invoke("connection_identifier_quote", { + connectionId, + database, + }); return quote ?? undefined; } @@ -829,15 +882,30 @@ export async function listSqlServerLinkedServers(connectionId: string): Promise< } export async function listSqlServerLinkedServerCatalogs(connectionId: string, server: string): Promise { - return invoke("list_sqlserver_linked_server_catalogs", { connectionId, server }); + return invoke("list_sqlserver_linked_server_catalogs", { + connectionId, + server, + }); } export async function listSqlServerLinkedServerSchemas(connectionId: string, server: string, catalog: string): Promise { - return invoke("list_sqlserver_linked_server_schemas", { connectionId, server, catalog }); + return invoke("list_sqlserver_linked_server_schemas", { + connectionId, + server, + catalog, + }); } export async function listSqlServerLinkedServerTables(connectionId: string, server: string, catalog: string, schema: string, filter?: string, limit?: number, offset?: number): Promise { - return invoke("list_sqlserver_linked_server_tables", { connectionId, server, catalog, schema, filter, limit, offset }); + return invoke("list_sqlserver_linked_server_tables", { + connectionId, + server, + catalog, + schema, + filter, + limit, + offset, + }); } export async function saveSchemaCache(cacheKey: string, payload: unknown): Promise { @@ -853,15 +921,40 @@ export async function deleteSchemaCachePrefix(prefix: string): Promise { } export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: SidebarObjectKind[], catalog?: string, tableNameFilter?: import("@/types/database").TableNameFilter): Promise { - return invoke("list_tables", { connectionId, database, schema, filter, limit, offset, objectTypes, catalog, tableNameFilter }); + return invoke("list_tables", { + connectionId, + database, + schema, + filter, + limit, + offset, + objectTypes, + catalog, + tableNameFilter, + }); } export async function getTableComment(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("get_table_comment", { connectionId, database, schema, table, catalog }); + return invoke("get_table_comment", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise { - return invoke("list_objects", { connectionId, database, schema, objectTypes, filter, limit, offset, catalog }); + return invoke("list_objects", { + connectionId, + database, + schema, + objectTypes, + filter, + limit, + offset, + catalog, + }); } export async function listObjectStatistics(connectionId: string, database: string, schema: string): Promise { @@ -877,7 +970,15 @@ export async function completionAssistantSearch(request: CompletionAssistantRequ } export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string, relationName?: string): Promise { - return invoke("get_object_source", { connectionId, database, schema, name, objectType, signature, relationName }); + return invoke("get_object_source", { + connectionId, + database, + schema, + name, + objectType, + signature, + relationName, + }); } export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise { @@ -889,11 +990,23 @@ export async function listSchemaInfos(connectionId: string, database: string): P } export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise { - return invoke("get_columns", { connectionId, database, schema, table, catalog, clientSessionId }); + return invoke("get_columns", { + connectionId, + database, + schema, + table, + catalog, + clientSessionId, + }); } export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { - return invoke("get_sqlserver_column_metadata", { connectionId, database, schema, table }); + return invoke("get_sqlserver_column_metadata", { + connectionId, + database, + schema, + table, + }); } export async function listDataTypes(connectionId: string, database: string): Promise { @@ -916,7 +1029,14 @@ export async function executeQuery( executionMode?: "simple"; }, ): Promise { - return invoke("execute_query", { connectionId, database, sql, schema, executionId, ...options }); + return invoke("execute_query", { + connectionId, + database, + sql, + schema, + executionId, + ...options, + }); } export async function executeMulti( @@ -937,7 +1057,14 @@ export async function executeMulti( executionMode?: "simple"; }, ): Promise { - return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options }); + return invoke("execute_multi", { + connectionId, + database, + sql, + schema, + executionId, + ...options, + }); } export interface ExecuteMultiProgress { @@ -970,7 +1097,14 @@ export async function executeMultiWithProgress( if (event.payload.executionId === executionId) onProgress(event.payload); }); try { - return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options }); + return await invoke("execute_multi", { + connectionId, + database, + sql, + schema, + executionId, + ...options, + }); } finally { unlisten(); } @@ -985,15 +1119,30 @@ export async function cancelQuery(executionId: string): Promise { } export async function closeQuerySession(connectionId: string, database: string, sessionId: string, clientSessionId?: string): Promise { - return invoke("close_query_session", { connectionId, database, sessionId, clientSessionId }); + return invoke("close_query_session", { + connectionId, + database, + sessionId, + clientSessionId, + }); } export async function closeClientConnectionSession(connectionId: string, database: string, clientSessionId: string): Promise { - return invoke("close_client_connection_session", { connectionId, database, clientSessionId }); + return invoke("close_client_connection_session", { + connectionId, + database, + clientSessionId, + }); } export async function executeBatch(connectionId: string, database: string, statements: string[], schema?: string, timeoutSecs?: number): Promise { - return invoke("execute_batch", { connectionId, database, statements, schema, timeoutSecs }); + return invoke("execute_batch", { + connectionId, + database, + statements, + schema, + timeoutSecs, + }); } export async function executeScript(connectionId: string, database: string, sql: string, schema?: string): Promise { @@ -1001,11 +1150,21 @@ export async function executeScript(connectionId: string, database: string, sql: } export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise { - return invoke("execute_script_with_2pc", { connectionId, database, statements, schema }); + return invoke("execute_script_with_2pc", { + connectionId, + database, + statements, + schema, + }); } export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise { - return invoke("execute_in_transaction", { connectionId, database, statements, schema }); + return invoke("execute_in_transaction", { + connectionId, + database, + statements, + schema, + }); } export async function beginManualTransaction(connectionId: string, database: string, schema?: string): Promise { @@ -1013,7 +1172,13 @@ export async function beginManualTransaction(connectionId: string, database: str } export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number): Promise { - return invoke("execute_in_manual_transaction", { txnSessionId, sql, database, schema, maxRows }); + return invoke("execute_in_manual_transaction", { + txnSessionId, + sql, + database, + schema, + maxRows, + }); } export async function commitManualTransaction(txnSessionId: string): Promise { @@ -1050,11 +1215,19 @@ export async function buildCreateUserSql(username: string, password: string, tab export async function getExplainInfo(connectionId: string, database: string | undefined, schema: string | undefined, sql: string, mode: string): Promise { // Preserve Agent/driver errors so the explain view can show the actionable cause. - return invoke("get_explain_info", { connectionId, database, schema, sql, mode }); + return invoke("get_explain_info", { + connectionId, + database, + schema, + sql, + mode, + }); } export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise { - const result = await invoke("build_dropped_file_preview_sql", { options }); + const result = await invoke("build_dropped_file_preview_sql", { + options, + }); return result ?? undefined; } @@ -1079,11 +1252,15 @@ export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): } export async function buildDuckDbAttachDatabaseSql(path: string, name: string): Promise { - return invoke("build_duckdb_attach_database_sql", { options: { path, name } }); + return invoke("build_duckdb_attach_database_sql", { + options: { path, name }, + }); } export async function buildSqliteAttachDatabaseSql(path: string, name: string): Promise { - return invoke("build_sqlite_attach_database_sql", { options: { path, name } }); + return invoke("build_sqlite_attach_database_sql", { + options: { path, name }, + }); } export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise { @@ -1155,11 +1332,20 @@ export async function buildTableStructureChangeSql(options: BuildTableStructureC } export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise { - return invoke("preview_sqlite_table_structure_change", { connectionId, database, options }); + return invoke("preview_sqlite_table_structure_change", { + connectionId, + database, + options, + }); } export async function applySqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions, schemaRevision: string): Promise { - return invoke("apply_sqlite_table_structure_change", { connectionId, database, options, schemaRevision }); + return invoke("apply_sqlite_table_structure_change", { + connectionId, + database, + options, + schemaRevision, + }); } export async function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions): Promise { @@ -1254,35 +1440,86 @@ export async function buildDataCompareSyncPlan(options: DataCompareSyncPlanOptio } export async function listIndexes(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("list_indexes", { connectionId, database, schema, table, catalog }); + return invoke("list_indexes", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function listForeignKeys(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("list_foreign_keys", { connectionId, database, schema, table, catalog }); + return invoke("list_foreign_keys", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function listTriggers(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("list_triggers", { connectionId, database, schema, table, catalog }); + return invoke("list_triggers", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function listConstraints(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("list_constraints", { connectionId, database, schema, table, catalog }); + return invoke("list_constraints", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function listPartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("list_partitions", { connectionId, database, schema, table, catalog }); + return invoke("list_partitions", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function listSubpartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("list_subpartitions", { connectionId, database, schema, table, catalog }); + return invoke("list_subpartitions", { + connectionId, + database, + schema, + table, + catalog, + }); } export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { - return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog }); + return invoke("get_table_ddl", { + connectionId, + database, + schema, + table, + objectType, + catalog, + }); } export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { - return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog, includePostgresAccess: true }); + return invoke("get_table_ddl", { + connectionId, + database, + schema, + table, + objectType, + catalog, + includePostgresAccess: true, + }); } export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise { @@ -1311,7 +1548,12 @@ export async function listFunctions(connectionId: string, database: string, sche } export async function listSequences(connectionId: string, database: string, schema: string, withLastValues: boolean): Promise { - return invoke("list_sequences", { connectionId, database, schema, withLastValues }); + return invoke("list_sequences", { + connectionId, + database, + schema, + withLastValues, + }); } export async function listRules(connectionId: string, database: string, schema: string): Promise { @@ -1391,7 +1633,9 @@ export async function importJdbcDrivers(paths: (string | File)[]): Promise { - return invoke("install_jdbc_driver_from_maven", { request: { coordinate, repositories } }); + return invoke("install_jdbc_driver_from_maven", { + request: { coordinate, repositories }, + }); } export async function installPrestoSqlJdbcDriver(): Promise { @@ -1734,10 +1978,25 @@ export interface RedisStreamPendingPage { export type RedisValueData = | { kind: "string"; content: RedisBlob } | { kind: "json"; value: string } - | { kind: "list"; items: RedisListItem[]; total: number; scan_cursor?: number } + | { + kind: "list"; + items: RedisListItem[]; + total: number; + scan_cursor?: number; + } | { kind: "set"; items: RedisSetItem[]; total: number; scan_cursor?: number } - | { kind: "hash"; items: RedisHashItem[]; total: number; scan_cursor?: number } - | { kind: "zset"; items: RedisZsetItem[]; total: number; scan_cursor?: number } + | { + kind: "hash"; + items: RedisHashItem[]; + total: number; + scan_cursor?: number; + } + | { + kind: "zset"; + items: RedisZsetItem[]; + total: number; + scan_cursor?: number; + } | { kind: "stream"; entries: RedisStreamEntry[] } | { kind: "unknown" }; @@ -1784,15 +2043,37 @@ export async function redisListDatabases(connectionId: string): Promise { - return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count }); + return invoke("redis_scan_keys", { + connectionId, + db, + cursor, + pattern, + count, + }); } export async function redisScanKeysBatch(connectionId: string, db: number, cursor: number, pattern: string, count: number, maxIterations: number, includeTypes = true): Promise { - return invoke("redis_scan_keys_batch", { connectionId, db, cursor, pattern, count, maxIterations, includeTypes }); + return invoke("redis_scan_keys_batch", { + connectionId, + db, + cursor, + pattern, + count, + maxIterations, + includeTypes, + }); } export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise { - return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count }); + return invoke("redis_scan_values", { + connectionId, + db, + cursor, + pattern, + query, + includeKeyMatches, + count, + }); } export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise { @@ -1804,11 +2085,23 @@ export async function redisGetStreamGroups(connectionId: string, db: number, key } export async function redisGetStreamConsumers(connectionId: string, db: number, keyRaw: string, groupRaw: string): Promise { - return invoke("redis_get_stream_consumers", { connectionId, db, keyRaw, groupRaw }); + return invoke("redis_get_stream_consumers", { + connectionId, + db, + keyRaw, + groupRaw, + }); } export async function redisGetStreamPending(connectionId: string, db: number, keyRaw: string, groupRaw: string, cursor?: string, consumerRaw?: string): Promise { - return invoke("redis_get_stream_pending", { connectionId, db, keyRaw, groupRaw, cursor, ...(consumerRaw === undefined ? {} : { consumerRaw }) }); + return invoke("redis_get_stream_pending", { + connectionId, + db, + keyRaw, + groupRaw, + cursor, + ...(consumerRaw === undefined ? {} : { consumerRaw }), + }); } export async function redisSetString(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise { @@ -1820,7 +2113,14 @@ export async function redisDeleteKey(connectionId: string, db: number, keyRaw: s } export async function redisHashSet(connectionId: string, db: number, keyRaw: string, field: string, value: string, ttl?: number): Promise { - return invoke("redis_hash_set", { connectionId, db, keyRaw, field, value, ttl }); + return invoke("redis_hash_set", { + connectionId, + db, + keyRaw, + field, + value, + ttl, + }); } export async function redisHashDel(connectionId: string, db: number, keyRaw: string, field: string): Promise { @@ -1856,7 +2156,14 @@ export async function redisZrem(connectionId: string, db: number, keyRaw: string } export async function redisStreamAdd(connectionId: string, db: number, keyRaw: string, entryId: string, fields: [string, string][], ttl?: number): Promise { - return invoke("redis_stream_add", { connectionId, db, keyRaw, entryId, fields, ttl }); + return invoke("redis_stream_add", { + connectionId, + db, + keyRaw, + entryId, + fields, + ttl, + }); } export async function redisJsonSet(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise { @@ -1884,11 +2191,24 @@ export async function redisFlushDb(connectionId: string, db: number): Promise { - return invoke("redis_execute_command", { connectionId, db, command, skipSafetyCheck: skipSafetyCheck ?? false }); + return invoke("redis_execute_command", { + connectionId, + db, + command, + skipSafetyCheck: skipSafetyCheck ?? false, + }); } export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise { - return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count, filter }); + return invoke("redis_load_more", { + connectionId, + db, + keyRaw, + keyType, + cursor, + count, + filter, + }); } export async function redisPubSubPublish(connectionId: string, db: number, channel: string, message: string): Promise<{ subscribers: number }> { @@ -1901,7 +2221,12 @@ export async function redisPubSubConnect(connectionId: string): Promise { - return invoke("redis_slowlog_get", { connectionId, count, nodeHost, nodePort }); + return invoke("redis_slowlog_get", { + connectionId, + count, + nodeHost, + nodePort, + }); } export async function redisClusterMasterNodes(connectionId: string): Promise { @@ -2134,7 +2459,14 @@ export interface KvStatusResponse { } export async function etcdListPrefix(connectionId: string, prefix: string, limit: number, continuation?: string | null, options?: KvListPrefixOptions | null): Promise { - return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation, revision: options?.revision ?? null, includeValues: options?.includeValues ?? null }); + return invoke("etcd_list_prefix", { + connectionId, + prefix, + limit, + continuation, + revision: options?.revision ?? null, + includeValues: options?.includeValues ?? null, + }); } export async function etcdSupportsTtl(connectionId: string): Promise { @@ -2142,7 +2474,13 @@ export async function etcdSupportsTtl(connectionId: string): Promise { } export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise { - return invoke("etcd_get", { connectionId, key, keyBytes: options?.keyBytes ?? null, revision: options?.revision ?? null, metadataOnly: options?.metadataOnly ?? null }); + return invoke("etcd_get", { + connectionId, + key, + keyBytes: options?.keyBytes ?? null, + revision: options?.revision ?? null, + metadataOnly: options?.metadataOnly ?? null, + }); } export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise { @@ -2162,13 +2500,35 @@ export async function etcdPut(connectionId: string, key: string, value: KvValue, } export async function etcdDelete(connectionId: string, key: string, options?: KvDeleteOptions | null): Promise { - return invoke("etcd_delete", { connectionId, key, keyBytes: options?.keyBytes ?? null, expectedModRevision: options?.expectedModRevision ?? null }); + return invoke("etcd_delete", { + connectionId, + key, + keyBytes: options?.keyBytes ?? null, + expectedModRevision: options?.expectedModRevision ?? null, + }); } -export async function etcdRename(connectionId: string, request: { key: string; keyBytes?: KvValue | null; newKey: string; expectedModRevision?: KvInt64 | null }): Promise<{ renamed: boolean; revision?: KvInt64 | null }> { +export async function etcdRename( + connectionId: string, + request: { + key: string; + keyBytes?: KvValue | null; + newKey: string; + expectedModRevision?: KvInt64 | null; + }, +): Promise<{ renamed: boolean; revision?: KvInt64 | null }> { return invoke("etcd_rename", { connectionId, request }); } -export async function etcdHistory(connectionId: string, request: { key: string; keyBytes?: KvValue | null; startRevision?: KvInt64 | null; endRevision?: KvInt64 | null; limit: number }): Promise { +export async function etcdHistory( + connectionId: string, + request: { + key: string; + keyBytes?: KvValue | null; + startRevision?: KvInt64 | null; + endRevision?: KvInt64 | null; + limit: number; + }, +): Promise { return invoke("etcd_history", { connectionId, request }); } export async function etcdStatus(connectionId: string): Promise { @@ -2177,7 +2537,13 @@ export async function etcdStatus(connectionId: string): Promise { - return invoke("zookeeper_list_prefix", { connectionId, prefix, limit, continuation, recursive: options?.recursive ?? null }); + return invoke("zookeeper_list_prefix", { + connectionId, + prefix, + limit, + continuation, + recursive: options?.recursive ?? null, + }); } export async function zookeeperGet(connectionId: string, key: string): Promise { @@ -2185,7 +2551,12 @@ export async function zookeeperGet(connectionId: string, key: string): Promise { - return invoke("zookeeper_put", { connectionId, key, value, options: options ?? null }); + return invoke("zookeeper_put", { + connectionId, + key, + value, + options: options ?? null, + }); } export async function zookeeperDelete(connectionId: string, key: string): Promise { @@ -2198,11 +2569,23 @@ export async function hbaseGetTableSchema(connectionId: string, namespace: strin } export async function hbaseScanRows(connectionId: string, namespace: string, table: string, rowKeyPrefix: string | undefined, limit: number): Promise { - return invoke("hbase_scan_rows", { connectionId, namespace, table, rowKeyPrefix, limit }); + return invoke("hbase_scan_rows", { + connectionId, + namespace, + table, + rowKeyPrefix, + limit, + }); } export async function hbaseGetRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise { - return invoke("hbase_get_row", { connectionId, namespace, table, rowKey, rowKeyEncoding }); + return invoke("hbase_get_row", { + connectionId, + namespace, + table, + rowKey, + rowKeyEncoding, + }); } export async function hbasePutRow(connectionId: string, namespace: string, table: string, input: import("@/types/hbase").HBasePutRowInput): Promise { @@ -2210,11 +2593,22 @@ export async function hbasePutRow(connectionId: string, namespace: string, table } export async function hbaseDeleteRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise { - return invoke("hbase_delete_row", { connectionId, namespace, table, rowKey, rowKeyEncoding }); + return invoke("hbase_delete_row", { + connectionId, + namespace, + table, + rowKey, + rowKeyEncoding, + }); } export async function hbaseCreateTable(connectionId: string, namespace: string, table: string, columnFamilies: string[]): Promise { - return invoke("hbase_create_table", { connectionId, namespace, table, columnFamilies }); + return invoke("hbase_create_table", { + connectionId, + namespace, + table, + columnFamilies, + }); } export async function hbaseDeleteTable(connectionId: string, namespace: string, table: string): Promise { @@ -2277,7 +2671,11 @@ export async function mongoListCollections(connectionId: string, database: strin } export async function vectorGetCollectionDetail(connectionId: string, database: string, collection: string): Promise { - return invoke("vector_collection_detail", { connectionId, database, collection }); + return invoke("vector_collection_detail", { + connectionId, + database, + collection, + }); } export async function mongoCreateDatabase(connectionId: string, database: string): Promise { @@ -2289,11 +2687,20 @@ export async function mongoDropDatabase(connectionId: string, database: string): } export async function mongoDropCollection(connectionId: string, database: string, collection: string): Promise { - return invoke("mongo_drop_collection", { connectionId, database, collection }); + return invoke("mongo_drop_collection", { + connectionId, + database, + collection, + }); } export async function mongoRenameCollection(connectionId: string, database: string, collection: string, newName: string): Promise { - return invoke("mongo_rename_collection", { connectionId, database, collection, newName }); + return invoke("mongo_rename_collection", { + connectionId, + database, + collection, + newName, + }); } export async function elasticsearchListIndices(connectionId: string): Promise { @@ -2310,7 +2717,15 @@ export async function mongoFindDocuments(connectionId: string, database: string, } export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise { - return invoke("mongo_find_one", { connectionId, database, collection, filter, projection, options, executionId }); + return invoke("mongo_find_one", { + connectionId, + database, + collection, + filter, + projection, + options, + executionId, + }); } export async function mongoParseShellCommand(source: string): Promise { @@ -2319,35 +2734,81 @@ export async function mongoParseShellCommand(source: string): Promise { - return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId }); + return invoke("document_find_documents", { + connectionId, + database, + collection, + skip, + limit, + filter, + projection, + sort, + executionId, + }); } export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise { - return invoke("elasticsearch_count_documents", { connectionId, index, filter, executionId }); + return invoke("elasticsearch_count_documents", { + connectionId, + index, + filter, + executionId, + }); } export async function mongoCountDocuments(connectionId: string, database: string, collection: string, filter?: string, mode?: "accurate" | "legacy", executionId?: string): Promise { - return invoke("mongo_count_documents", { connectionId, database, collection, filter, mode, executionId }); + return invoke("mongo_count_documents", { + connectionId, + database, + collection, + filter, + mode, + executionId, + }); } export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string, filter?: string, sort?: string): Promise { - return invoke("document_list_gridfs_files", { connectionId, database, bucket, filter, sort }); + return invoke("document_list_gridfs_files", { + connectionId, + database, + bucket, + filter, + sort, + }); } export async function documentListGridFsBuckets(connectionId: string, database: string, filter?: string, sort?: string): Promise { - return invoke("document_list_gridfs_buckets", { connectionId, database, filter, sort }); + return invoke("document_list_gridfs_buckets", { + connectionId, + database, + filter, + sort, + }); } export async function documentCreateGridFsBucket(connectionId: string, database: string, bucket: string): Promise { - return invoke("document_create_gridfs_bucket", { connectionId, database, bucket }); + return invoke("document_create_gridfs_bucket", { + connectionId, + database, + bucket, + }); } export async function documentDeleteGridFsBucket(connectionId: string, database: string, bucket: string): Promise { - return invoke("document_delete_gridfs_bucket", { connectionId, database, bucket }); + return invoke("document_delete_gridfs_bucket", { + connectionId, + database, + bucket, + }); } export async function documentDownloadGridFsFile(connectionId: string, database: string, bucket: string, fileId: string): Promise { - const data = await invoke("document_download_gridfs_file", { connectionId, database, bucket, fileId }); + const data = await invoke("document_download_gridfs_file", { + connectionId, + database, + bucket, + fileId, + }); return new Uint8Array(data); } @@ -2363,31 +2824,73 @@ export async function documentUploadGridFsFile(connectionId: string, database: s } export async function documentDeleteGridFsFile(connectionId: string, database: string, bucket: string, fileId: string): Promise { - return invoke("document_delete_gridfs_file", { connectionId, database, bucket, fileId }); + return invoke("document_delete_gridfs_file", { + connectionId, + database, + bucket, + fileId, + }); } export async function mongoServerVersion(connectionId: string, database: string, executionId?: string): Promise { - return invoke("mongo_server_version", { connectionId, database, executionId }); + return invoke("mongo_server_version", { + connectionId, + database, + executionId, + }); } export async function mongoAggregateDocuments(connectionId: string, database: string, collection: string, pipelineJson: string, maxRows?: number, optionsJson?: string, executionId?: string): Promise { - return invoke("mongo_aggregate_documents", { connectionId, database, collection, pipelineJson, maxRows, optionsJson, executionId }); + return invoke("mongo_aggregate_documents", { + connectionId, + database, + collection, + pipelineJson, + maxRows, + optionsJson, + executionId, + }); } export async function mongoDistinct(connectionId: string, database: string, collection: string, field: string, filter?: string, executionId?: string): Promise { - return invoke("mongo_distinct", { connectionId, database, collection, field, filter, executionId }); + return invoke("mongo_distinct", { + connectionId, + database, + collection, + field, + filter, + executionId, + }); } export async function mongoCollectionStats(connectionId: string, database: string, collection: string, scale?: number, executionId?: string): Promise { - return invoke("mongo_collection_stats", { connectionId, database, collection, scale, executionId }); + return invoke("mongo_collection_stats", { + connectionId, + database, + collection, + scale, + executionId, + }); } export async function mongoCreateIndex(connectionId: string, database: string, collection: string, keysJson: string, optionsJson?: string): Promise<{ name: string }> { - return invoke("mongo_create_index", { connectionId, database, collection, keysJson, optionsJson }); + return invoke("mongo_create_index", { + connectionId, + database, + collection, + keysJson, + optionsJson, + }); } export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> { - return invoke("mongo_drop_indexes", { connectionId, database, collection, indexesJson, single }); + return invoke("mongo_drop_indexes", { + connectionId, + database, + collection, + indexesJson, + single, + }); } export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { @@ -2395,11 +2898,22 @@ export async function mongoInsertDocument(connectionId: string, database: string } export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { - return invoke("document_insert_document", { connectionId, database, collection, docJson, routing }); + return invoke("document_insert_document", { + connectionId, + database, + collection, + docJson, + routing, + }); } export async function mongoInsertDocuments(connectionId: string, database: string, collection: string, docsJson: string): Promise<{ affected_rows: number }> { - const affectedRows = await invoke("mongo_insert_documents", { connectionId, database, collection, docsJson }); + const affectedRows = await invoke("mongo_insert_documents", { + connectionId, + database, + collection, + docsJson, + }); return { affected_rows: affectedRows }; } @@ -2408,7 +2922,14 @@ export async function mongoUpdateDocument(connectionId: string, database: string } export async function documentUpdateDocument(connectionId: string, database: string, collection: string, id: string, docJson: string, routing?: string): Promise { - return invoke("document_update_document", { connectionId, database, collection, id, docJson, routing }); + return invoke("document_update_document", { + connectionId, + database, + collection, + id, + docJson, + routing, + }); } export async function mongoUpdateDocuments(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, many: boolean, optionsJson?: string): Promise<{ affected_rows: number }> { @@ -2429,7 +2950,13 @@ export async function mongoDeleteDocument(connectionId: string, database: string } export async function documentDeleteDocument(connectionId: string, database: string, collection: string, id: string, routing?: string): Promise { - return invoke("document_delete_document", { connectionId, database, collection, id, routing }); + return invoke("document_delete_document", { + connectionId, + database, + collection, + id, + routing, + }); } export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> { @@ -2444,15 +2971,35 @@ export async function mongoDeleteDocuments(connectionId: string, database: strin } export async function mongoFindOneAndUpdate(connectionId: string, database: string, collection: string, filterJson: string, updateJson: string, optionsJson?: string): Promise { - return invoke("mongo_find_one_and_update", { connectionId, database, collection, filterJson, updateJson, optionsJson }); + return invoke("mongo_find_one_and_update", { + connectionId, + database, + collection, + filterJson, + updateJson, + optionsJson, + }); } export async function mongoFindOneAndReplace(connectionId: string, database: string, collection: string, filterJson: string, replacementJson: string, optionsJson?: string): Promise { - return invoke("mongo_find_one_and_replace", { connectionId, database, collection, filterJson, replacementJson, optionsJson }); + return invoke("mongo_find_one_and_replace", { + connectionId, + database, + collection, + filterJson, + replacementJson, + optionsJson, + }); } export async function mongoFindOneAndDelete(connectionId: string, database: string, collection: string, filterJson: string, optionsJson?: string): Promise { - return invoke("mongo_find_one_and_delete", { connectionId, database, collection, filterJson, optionsJson }); + return invoke("mongo_find_one_and_delete", { + connectionId, + database, + collection, + filterJson, + optionsJson, + }); } // --- History --- @@ -2515,7 +3062,11 @@ export async function saveHistory(entry: HistoryEntry): Promise { } export async function loadHistory(limit: number, offset: number, activityKind?: string): Promise { - return invoke("load_history", { limit, offset, activityKind: activityKind ?? null }); + return invoke("load_history", { + limit, + offset, + activityKind: activityKind ?? null, + }); } export async function searchHistory(request: HistorySearchRequest): Promise { @@ -2800,7 +3351,9 @@ export async function importTableFile(request: TableImportRequest, onProgress: ( } }); try { - const summary = await invoke("import_table_file", { request }); + const summary = await invoke("import_table_file", { + request, + }); unlisten(); return summary; } catch (e) { @@ -3011,7 +3564,10 @@ export async function startQueryResultExport(request: QueryResultExportRequest, } export async function cancelQueryResultExport(exportId: string, executionId?: string): Promise { - return invoke("cancel_query_result_export", { exportId, executionId: executionId || null }); + return invoke("cancel_query_result_export", { + exportId, + executionId: executionId || null, + }); } export async function beginDatabaseBackupSnapshot(connectionId: string, database: string): Promise { @@ -3066,7 +3622,16 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string }); } -export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: readonly string[]; columnTypes?: readonly string[]; rows: readonly (readonly XlsxCellValue[])[]; numericColumnRightAlign?: boolean }[]): Promise { +export async function exportQueryResultsXlsx( + filePath: string, + worksheets: readonly { + sheetName?: string; + columns: readonly string[]; + columnTypes?: readonly string[]; + rows: readonly (readonly XlsxCellValue[])[]; + numericColumnRightAlign?: boolean; + }[], +): Promise { return invoke("export_query_results_xlsx", { request: { filePath, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 062158a202..d27902b200 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -393,7 +393,14 @@ export interface CustomTheme { ddlColors: CustomThemeDdlColors; } -export const DEFAULT_CUSTOM_THEMES: CustomTheme[] = [{ id: "default", name: "Custom", colors: { ...DEFAULT_CUSTOM_THEME_COLORS }, ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS } }]; +export const DEFAULT_CUSTOM_THEMES: CustomTheme[] = [ + { + id: "default", + name: "Custom", + colors: { ...DEFAULT_CUSTOM_THEME_COLORS }, + ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS }, + }, +]; export type SidebarObjectInfoMode = "comment-inline" | "comment-aligned" | "comment-right" | "size" | "hidden"; @@ -538,7 +545,11 @@ export function enforceRightSidebarPanelExclusivity(current: RightSidebarPanelSt return transitionRightSidebarPanels(current, panelToKeep, true, true); } -export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [ +export const EDITOR_THEMES: { + value: EditorTheme; + label: string; + dark: boolean; +}[] = [ { value: "app", label: "Follow app theme", dark: false }, { value: "one-dark", label: "One Dark", dark: true }, { value: "vscode-dark", label: "VS Dark+", dark: true }, @@ -790,7 +801,13 @@ function normalizeSqlSnippets(value: unknown, existing?: SqlSnippet[]): SqlSnipp if (seenPrefixes.has(item.prefix)) continue; seenPrefixes.add(item.prefix); // Older settings do not have this field; only an explicit false disables a snippet. - valid.push({ id: item.id, label: item.label, prefix: item.prefix, body: item.body, enabled: item.enabled !== false }); + valid.push({ + id: item.id, + label: item.label, + prefix: item.prefix, + body: item.body, + enabled: item.enabled !== false, + }); } if (valid.length === 0) return existing ?? DEFAULT_SQL_SNIPPETS; return valid; @@ -845,7 +862,10 @@ export function normalizeEditorSettings(settings: Partial, exist return { ...renamed, colors: { ...DEFAULT_CUSTOM_THEME_COLORS, ...renamed.colors }, - ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS, ...(renamed as any).ddlColors }, + ddlColors: { + ...DEFAULT_CUSTOM_THEME_DDL_COLORS, + ...(renamed as any).ddlColors, + }, }; }); } @@ -854,7 +874,10 @@ export function normalizeEditorSettings(settings: Partial, exist { id: "migrated", name: "Migrated", - colors: { ...DEFAULT_CUSTOM_THEME_COLORS, ...settings.customThemeColors }, + colors: { + ...DEFAULT_CUSTOM_THEME_COLORS, + ...settings.customThemeColors, + }, ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS }, }, ] @@ -915,17 +938,43 @@ export function normalizeEditorSettings(settings: Partial, exist sidebarObjectDisplay: settings.sidebarObjectDisplay === "simple" || settings.sidebarObjectDisplay === "grouped" ? settings.sidebarObjectDisplay : DEFAULT_EDITOR_SETTINGS.sidebarObjectDisplay, sidebarTableSearchEnabled: typeof settings.sidebarTableSearchEnabled === "boolean" ? settings.sidebarTableSearchEnabled : DEFAULT_EDITOR_SETTINGS.sidebarTableSearchEnabled, autoSelectActiveSidebarNode: settings.autoSelectActiveSidebarNode ?? DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode, - openTabsRestoreMode: normalizeOpenTabsRestoreMode((settings as Partial).openTabsRestoreMode, (settings as Partial & { restoreOpenTabsOnLaunch?: boolean }).restoreOpenTabsOnLaunch), - disconnectTabHandlingMode: normalizeDisconnectTabHandlingMode((settings as Partial).disconnectTabHandlingMode, (settings as Partial & { closeQueryTabsOnDisconnect?: boolean }).closeQueryTabsOnDisconnect), + openTabsRestoreMode: normalizeOpenTabsRestoreMode( + (settings as Partial).openTabsRestoreMode, + ( + settings as Partial & { + restoreOpenTabsOnLaunch?: boolean; + } + ).restoreOpenTabsOnLaunch, + ), + disconnectTabHandlingMode: normalizeDisconnectTabHandlingMode( + (settings as Partial).disconnectTabHandlingMode, + ( + settings as Partial & { + closeQueryTabsOnDisconnect?: boolean; + } + ).closeQueryTabsOnDisconnect, + ), reuseDataTab: settings.reuseDataTab ?? DEFAULT_EDITOR_SETTINGS.reuseDataTab, prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect, updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled, sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes), sidebarObjectInfoMode: normalizeSidebarObjectInfoMode( settings.sidebarObjectInfoMode, - (settings as Partial & { sidebarTableCommentLayout?: string }).sidebarTableCommentLayout, - (settings as Partial & { sidebarHideTableComments?: boolean }).sidebarHideTableComments, - (settings as Partial & { sidebarShowDatabaseSizes?: boolean }).sidebarShowDatabaseSizes, + ( + settings as Partial & { + sidebarTableCommentLayout?: string; + } + ).sidebarTableCommentLayout, + ( + settings as Partial & { + sidebarHideTableComments?: boolean; + } + ).sidebarHideTableComments, + ( + settings as Partial & { + sidebarShowDatabaseSizes?: boolean; + } + ).sidebarShowDatabaseSizes, ), sidebarAllowHorizontalScroll: settings.sidebarAllowHorizontalScroll ?? DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll, columnFormatters: normalizeColumnFormatters(settings.columnFormatters), @@ -996,7 +1045,9 @@ export const useSettingsStore = defineStore("settings", () => { const isAiConfigLoaded = ref(false); const aiConfigs = ref([]); const desktopSettings = ref({ ...DEFAULT_DESKTOP_SETTINGS }); - const mcpGlobalPolicy = ref({ ...DEFAULT_MCP_GLOBAL_POLICY }); + const mcpGlobalPolicy = ref({ + ...DEFAULT_MCP_GLOBAL_POLICY, + }); const isDesktopSettingsLoaded = ref(false); const isMcpGlobalPolicyLoaded = ref(false); const isEditorSettingsLoaded = ref(false); @@ -1119,7 +1170,10 @@ export const useSettingsStore = defineStore("settings", () => { const savedActive = savedSelection?.active; const savedConfig = savedActive ? aiConfigs.value.find((config) => config.id === savedActive.configId) : undefined; if (savedConfig && savedActive?.modelId.trim()) { - activeModel.value = { configId: savedConfig.id, modelId: savedActive.modelId.trim() }; + activeModel.value = { + configId: savedConfig.id, + modelId: savedActive.modelId.trim(), + }; } else { const fallback = aiConfigs.value.find((config) => config.isDefault) || aiConfigs.value[0]; activeModel.value = fallback?.model.trim() ? { configId: fallback.id, modelId: fallback.model.trim() } : null; @@ -1175,7 +1229,10 @@ export const useSettingsStore = defineStore("settings", () => { await api.saveAiConfigItem(normalized); aiConfigs.value.push(normalized); if (aiConfigs.value.length === 1 && normalized.model.trim()) { - activeModel.value = { configId: normalized.id, modelId: normalized.model }; + activeModel.value = { + configId: normalized.id, + modelId: normalized.model, + }; persistAiChatSelection(); } } @@ -1214,7 +1271,10 @@ export const useSettingsStore = defineStore("settings", () => { } function updateActiveModel(model: { configId: string; modelId: string }) { - activeModel.value = { configId: model.configId, modelId: model.modelId.trim() }; + activeModel.value = { + configId: model.configId, + modelId: model.modelId.trim(), + }; persistAiChatSelection(); } @@ -1412,7 +1472,9 @@ export const useSettingsStore = defineStore("settings", () => { } function deleteCustomColumnFormatter(id: string) { - const customColumnFormatters = { ...editorSettings.value.customColumnFormatters }; + const customColumnFormatters = { + ...editorSettings.value.customColumnFormatters, + }; delete customColumnFormatters[id]; const columnFormatters = Object.fromEntries( Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => { diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index cbd616e6a5..ad95113e43 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -57,6 +57,7 @@ tracing-subscriber = "0.3" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["compat"] } chrono = { version = "0.4", features = ["serde"] } +httpdate = "1" rust_decimal = { version = "1", features = ["serde", "db-postgres"] } anyhow = "1" uuid = { version = "1", features = ["v4", "serde"] } diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 6e21d23d10..4d5fa4697d 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -10,6 +10,17 @@ use std::sync::{Arc, LazyLock}; use tokio::sync::{Notify, RwLock}; use uuid::Uuid; +/// Default number of automatic retries on transient API errors +/// (rate limits, timeouts, network blips). Users can adjust the limit in +/// Settings → AI. Set to 0 to disable automatic retries entirely. +pub const DEFAULT_MAX_RETRIES: u32 = 2; +pub const MAX_MAX_RETRIES: u32 = 10; + +/// Clamp a user-provided max-retries value into the supported range. +pub fn clamp_max_retries(value: u32) -> u32 { + value.clamp(0, MAX_MAX_RETRIES) +} + // --------------------------------------------------------------------------- // Stream cancel registry // --------------------------------------------------------------------------- @@ -340,6 +351,11 @@ pub struct AiConfig { pub runtime_effort: Option, #[serde(default)] pub context_window: Option, + /// Maximum number of automatic retries on transient API errors + /// (rate limits, timeouts, network blips). `None` uses the default (2). + /// Set to `Some(0)` to disable automatic retries entirely. + #[serde(default)] + pub max_retries: Option, #[serde(default)] pub codex_cli_path: Option, #[serde(default)] @@ -358,6 +374,30 @@ fn default_enable_thinking() -> bool { true } +/// Whether the provider is a CLI-based provider that goes through its own +/// executable (claude-code, codex, pi) rather than through `with_retry` / +/// `with_stream_retry`. +pub fn is_cli_provider(provider: &AiProvider) -> bool { + matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) +} + +/// Merge the global `max_retries` setting into an `AiConfig`. +/// +/// Applied by all API-backed entry points at request time. CLI providers are +/// skipped because they use their own retry logic and never reach the +/// `with_retry` / `with_stream_retry` paths. +/// +/// Some API-backed entry points (model listing, effort resolution) do not +/// currently call `with_retry` and so the merged value is a no-op for them. +/// The merge is still applied uniformly so that every API-backed command +/// follows the same entry-point pattern — if retry logic is added to those +/// functions later, the global setting takes effect automatically. +pub fn merge_global_max_retries(config: &mut AiConfig, max_retries: u32) { + if !is_cli_provider(&config.provider) { + config.max_retries = Some(max_retries); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AiMessage { @@ -1778,6 +1818,7 @@ fn truncate_diagnostic(value: &str, max_chars: usize) -> String { async fn categorized_http_error(response: reqwest::Response, provider: &str, api_key: &str) -> String { let status = response.status(); + let headers = response.headers().clone(); let body = response.text().await.unwrap_or_default(); let detail = serde_json::from_str::(&body) .ok() @@ -1793,7 +1834,7 @@ async fn categorized_http_error(response: reqwest::Response, provider: &str, api let detail = if api_key.is_empty() { detail } else { detail.replace(api_key, "***") }; let detail = truncate_diagnostic(&detail, 500); let diagnostic = format!("HTTP {}: {detail}", status.as_u16()); - format!("[{}] {provider} API error ({diagnostic})", classify_error(&diagnostic)) + maybe_tag_retry_after(&headers, format!("[{}] {provider} API error ({diagnostic})", classify_error(&diagnostic))) } fn format_transport_error(provider: &str, error: reqwest::Error) -> String { @@ -1801,6 +1842,32 @@ fn format_transport_error(provider: &str, error: reqwest::Error) -> String { format!("{provider} request failed: {}", error.without_url()) } +/// Extract an error string from a non-2xx streaming response, preserving any +/// `Retry-After` header so the retry helpers can honour server-requested delays. +/// +/// Mirrors [`categorized_http_error`]: always embeds `HTTP ` in the +/// diagnostic so that [`classify_error`] works correctly on empty-body or +/// non-JSON responses (e.g. a bare 429 from a proxy). +async fn stream_error(response: reqwest::Response, fallback: &str) -> String { + let status = response.status(); + let headers = response.headers().clone(); + // Read the body as text first so we still have it when JSON parsing fails. + let body_text = response.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&body_text) + .ok() + .and_then(|data| extract_error(&data)) + .unwrap_or_else(|| { + let trimmed = body_text.trim(); + if trimmed.is_empty() { + "empty response body".to_string() + } else { + trimmed.to_string() + } + }); + let diagnostic = format!("HTTP {}: {detail}", status.as_u16()); + maybe_tag_retry_after(&headers, format!("[{}] {fallback} API error ({diagnostic})", classify_error(&diagnostic))) +} + async fn measure_first_stream_chunk( mut byte_stream: impl futures::Stream> + Unpin, start: std::time::Instant, @@ -1872,124 +1939,141 @@ pub async fn test_connection_core(config: &AiConfig) -> Result { - let body = json!({ - "model": claude_http_model(&model), - "max_tokens": 16, - "system": CLAUDE_DEFAULT_SYSTEM, - "messages": [{ "role": "user", "content": TEST_PROMPT }], - "stream": true, - }); - let res = client - .post(resolve_endpoint(config)) - .headers(claude_headers(config)?) - .json(&body) - .send() - .await - .map_err(|e| format!("Claude request failed: {e}"))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "Claude", &config.api_key).await); - } - res.bytes_stream() - } - AiProvider::Gemini => { - // Gemini only returns SSE from streamGenerateContent; generateContent - // returns one JSON document even when alt=sse is supplied. - let ep = resolve_gemini_stream_endpoint(config); - let res = client - .post(&ep) - .header(CONTENT_TYPE, "application/json") - .query(&[("key", normalized_api_key(config)), ("alt", "sse")]) - .json(&json!({ - "contents": [{ "parts": [{ "text": TEST_PROMPT }], "role": "user" }], - "generationConfig": { "maxOutputTokens": 256 }, - })) - .send() - .await - .map_err(|e| format_transport_error("Gemini", e))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "Gemini", &config.api_key).await); - } - res.bytes_stream() - } - AiProvider::Custom if uses_anthropic_messages_api(config) => { - let body = json!({ - "model": claude_http_model(&model), - "max_tokens": 16, - "system": CLAUDE_DEFAULT_SYSTEM, - "messages": [{ "role": "user", "content": TEST_PROMPT }], - "stream": true, - }); - let res = client - .post(resolve_endpoint(config)) - .headers(claude_headers(config)?) - .json(&body) - .send() - .await - .map_err(|e| format!("Claude request failed: {e}"))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "Claude", &config.api_key).await); - } - res.bytes_stream() - } - _ => { - // OpenAI-compatible providers - let mut body_obj = if config.api_style == AiApiStyle::Responses { - json!({ - "model": &model, - "input": [{ "role": "user", "content": TEST_PROMPT }], - "max_output_tokens": 16, - "stream": true, - }) - } else { - let messages = vec![json!({ "role": "user", "content": TEST_PROMPT })]; - let mut body = json!({ - "model": &model, - "messages": messages, - "stream": true, - }); - set_chat_completion_token_limit(&mut body, config, 16); - body + with_retry(&config_ref, || { + let client = client.clone(); + let model = model.clone(); + let provider = provider.clone(); + let api_key = api_key.clone(); + let endpoint = endpoint.clone(); + let gemini_ep = gemini_ep.clone(); + let api_style = api_style.clone(); + let config_inner = config_inner.clone(); + async move { + let start = std::time::Instant::now(); + + let byte_stream = match provider { + AiProvider::Claude => { + let body = json!({ + "model": claude_http_model(&model), + "max_tokens": 16, + "system": CLAUDE_DEFAULT_SYSTEM, + "messages": [{ "role": "user", "content": TEST_PROMPT }], + "stream": true, + }); + let headers = claude_headers(&config_inner)?; + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("Claude request failed: {e}"))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "Claude", &api_key).await); + } + res.bytes_stream() + } + AiProvider::Gemini => { + let res = client + .post(&gemini_ep) + .header(CONTENT_TYPE, "application/json") + .query(&[("key", api_key.as_str()), ("alt", "sse")]) + .json(&json!({ + "contents": [{ "parts": [{ "text": TEST_PROMPT }], "role": "user" }], + "generationConfig": { "maxOutputTokens": 256 }, + })) + .send() + .await + .map_err(|e| format_transport_error("Gemini", e))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "Gemini", &api_key).await); + } + res.bytes_stream() + } + AiProvider::Custom if is_claude => { + let body = json!({ + "model": claude_http_model(&model), + "max_tokens": 16, + "system": CLAUDE_DEFAULT_SYSTEM, + "messages": [{ "role": "user", "content": TEST_PROMPT }], + "stream": true, + }); + let headers = claude_headers(&config_inner)?; + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("Claude request failed: {e}"))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "Claude", &api_key).await); + } + res.bytes_stream() + } + _ => { + let mut body_obj = if api_style == AiApiStyle::Responses { + json!({ + "model": &model, + "input": [{ "role": "user", "content": TEST_PROMPT }], + "max_output_tokens": 16, + "stream": true, + }) + } else { + let messages = vec![json!({ "role": "user", "content": TEST_PROMPT })]; + let mut body = json!({ + "model": &model, + "messages": messages, + "stream": true, + }); + set_chat_completion_token_limit(&mut body, &config_inner, 16); + body + }; + if api_style != AiApiStyle::Responses { + apply_chat_completion_thinking_toggle(&mut body_obj, &config_inner); + } + let headers = maybe_bearer_headers(&config_inner)?; + let res = client + .post(&endpoint) + .headers(headers) + .json(&body_obj) + .send() + .await + .map_err(|e| format!("AI request failed: {e}"))?; + if !res.status().is_success() { + return Err(categorized_http_error(res, "AI", &api_key).await); + } + res.bytes_stream() + } }; - if config.api_style != AiApiStyle::Responses { - apply_chat_completion_thinking_toggle(&mut body_obj, config); - } - let ep = resolve_endpoint(config); - let res = client - .post(&ep) - .headers(maybe_bearer_headers(config)?) - .json(&body_obj) - .send() - .await - .map_err(|e| format!("AI request failed: {e}"))?; - if !res.status().is_success() { - return Err(categorized_http_error(res, "AI", &config.api_key).await); - } - res.bytes_stream() - } - }; - match measure_first_stream_chunk(byte_stream, start, is_claude, is_gemini).await { - Ok((latency, _delta)) => Ok(AiTestConnectionResult { - success: true, - message: format!("OK — {}ms", latency), - latency_ms: Some(latency), - model_used: model, - error_category: None, - }), - Err(e) => { - let category = classify_error(&e); - Err(format!("[{category}] {e}")) + match measure_first_stream_chunk(byte_stream, start, is_claude, is_gemini).await { + Ok((latency, _delta)) => Ok(AiTestConnectionResult { + success: true, + message: format!("OK — {}ms", latency), + latency_ms: Some(latency), + model_used: model, + error_category: None, + }), + Err(e) => { + let category = classify_error(&e); + Err(format!("[{category}] {e}")) + } + } } - } + }) + .await } fn classify_error(msg: &str) -> &'static str { @@ -2044,6 +2128,158 @@ fn classify_error(msg: &str) -> &'static str { } } +/// Whether an error is transient and worth retrying. +/// Rate limits, timeouts, network blips, and empty responses are retryable; +/// authentication failures, missing models, safety blocks, and token limits are not. +fn is_retryable_error(error: &str) -> bool { + matches!(classify_error(error), "rateLimit" | "timeout" | "network" | "emptyResponse") +} + +/// Extract Retry-After seconds from HTTP response headers. +/// +/// Supports both integer seconds and HTTP-date (RFC 9110 §10.2.3) formats. +/// Returns `None` for missing or unparseable values, and 0 for dates in the past. +fn parse_retry_after_secs(value: &str, now: std::time::SystemTime) -> Option { + if let Ok(secs) = value.parse::() { + return Some(secs); + } + + let retry_at = httpdate::parse_http_date(value).ok()?; + match retry_at.duration_since(now) { + Ok(delay) => Some(delay.as_secs().saturating_add(u64::from(delay.subsec_nanos() > 0))), + Err(_) => Some(0), + } +} + +fn retry_after_secs(headers: &reqwest::header::HeaderMap) -> Option { + let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?; + parse_retry_after_secs(value, std::time::SystemTime::now()) +} + +/// Parse a Retry-After duration from an error string. +/// +/// Recognises the `[retry-after:N]` prefix (N in whole seconds) optionally +/// embedded by [`categorized_http_error`] or stream error sites. +fn parse_retry_after(error: &str) -> Option { + let rest = error.strip_prefix("[retry-after:")?; + let end = rest.find(']')?; + let secs: u64 = rest[..end].parse().ok()?; + Some(std::time::Duration::from_secs(secs)) +} + +/// Prepend a `[retry-after:N]` tag to an error string if the headers include +/// a Retry-After value. +fn maybe_tag_retry_after(headers: &reqwest::header::HeaderMap, mut error: String) -> String { + if let Some(secs) = retry_after_secs(headers) { + error = format!("[retry-after:{secs}]{error}"); + } + error +} + +/// Execute an async operation with automatic retry on transient errors. +/// +/// Uses the `max_retries` field from `config` (defaults to +/// [`DEFAULT_MAX_RETRIES`] when `None`, capped at [`MAX_MAX_RETRIES`]). +/// Exponential back-off starts at 500 ms and doubles each +/// attempt, with a small jitter to spread retries across concurrent tasks. +async fn with_retry(config: &AiConfig, mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let max = config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES).min(MAX_MAX_RETRIES); + let mut last_err: Option = None; + + for attempt in 0..=max { + if attempt > 0 { + // Exponential back-off: 500ms, 1s, 2s, 4s … with ±25 % jitter. + let base_ms = 2u64.pow(attempt.saturating_sub(1)) * 500; + // Honour Retry-After from the previous error response. + let server_delay_ms = + last_err.as_ref().and_then(|e| parse_retry_after(e)).map(|d| d.as_millis() as u64).unwrap_or(0); + let base_ms = base_ms.max(server_delay_ms); + let jitter = (base_ms as f64 * 0.25) as u64; + // We can't use thread_rng here (wasm compat), so rely on the OS + // scheduler to naturally jitter concurrent tasks. A tiny fixed + // offset per attempt avoids thundering-herd for a single caller. + let delay_ms = base_ms + (attempt as u64 * 37) % jitter.max(1); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + log::debug!("[ai][retry] attempt {attempt}/{} ({}ms back-off)", max, delay_ms); + } + + match op().await { + Ok(result) => return Ok(result), + Err(err) if attempt < max && is_retryable_error(&err) => { + log::warn!("[ai][retry] transient error, will retry: {err}"); + last_err = Some(err); + } + Err(err) => return Err(err), + } + } + + Err(last_err.unwrap_or_else(|| "retry exhausted with no recorded error".to_string())) +} + +/// Variant of [`with_retry`] for streaming operations. +/// +/// Streaming requests are retried only when the `emitted` flag is still `false` +/// — that is, no text, reasoning, or tool-call event has reached the frontend. +/// Once content has been emitted, retrying would cause duplicate output, so the +/// error is propagated immediately regardless of whether it is transient. +/// +/// The closure rebuilds the full `send` + stream-consume each attempt. +/// When `cancelled` is provided, back-off sleep yields to cancellation +/// so the user's Stop button takes effect quickly. +async fn with_stream_retry( + config: &AiConfig, + emitted: &std::sync::Arc, + cancelled: Option<&Notify>, + mut op: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let max = config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES).min(MAX_MAX_RETRIES); + let mut last_err: Option = None; + + for attempt in 0..=max { + if attempt > 0 { + let base_ms = 2u64.pow(attempt.saturating_sub(1)) * 500; + // Honour Retry-After from the previous error response. + let server_delay_ms = + last_err.as_ref().and_then(|e| parse_retry_after(e)).map(|d| d.as_millis() as u64).unwrap_or(0); + let base_ms = base_ms.max(server_delay_ms); + let jitter = (base_ms as f64 * 0.25) as u64; + let delay_ms = base_ms + (attempt as u64 * 37) % jitter.max(1); + let delay = std::time::Duration::from_millis(delay_ms); + log::debug!("[ai][stream_retry] attempt {attempt}/{} ({}ms back-off)", max, delay_ms); + // Yield to cancellation during back-off so Stop is responsive. + if let Some(cancelled) = cancelled { + tokio::select! { + _ = tokio::time::sleep(delay) => {}, + _ = cancelled.notified() => return Err(AGENT_CANCELLED_ERROR.to_string()), + } + } else { + tokio::time::sleep(delay).await; + } + } + + match op().await { + Ok(result) => return Ok(result), + Err(err) + if attempt < max && is_retryable_error(&err) && !emitted.load(std::sync::atomic::Ordering::Relaxed) => + { + log::warn!("[ai][stream_retry] transient error before content, will retry: {err}"); + last_err = Some(err); + } + Err(err) => return Err(err), + } + } + + Err(last_err.unwrap_or_else(|| "stream retry exhausted with no recorded error".to_string())) +} + pub async fn complete(request: &AiCompletionRequest) -> Result { validate_config(&request.config)?; @@ -2051,33 +2287,41 @@ pub async fn complete(request: &AiCompletionRequest) -> Result { return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } - let client = build_ai_http_client(&request.config, 60)?; + let config = request.config.clone(); + let client = build_ai_http_client(&config, 60)?; - match request.config.provider { - AiProvider::Claude => call_claude(&client, request.clone()).await, - AiProvider::Gemini => call_gemini(&client, request.clone()).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), - AiProvider::Openai - | AiProvider::Deepseek - | AiProvider::Qwen - | AiProvider::Ollama - | AiProvider::OpenaiCompatible => { - if request.config.api_style == AiApiStyle::Responses { - call_responses_api(&client, request.clone()).await - } else { - call_openai_compatible(&client, request.clone()).await + with_retry(&config, || { + let client = client.clone(); + let request = request.clone(); + async move { + match request.config.provider { + AiProvider::Claude => call_claude(&client, request).await, + AiProvider::Gemini => call_gemini(&client, request).await, + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), + AiProvider::Openai + | AiProvider::Deepseek + | AiProvider::Qwen + | AiProvider::Ollama + | AiProvider::OpenaiCompatible => { + if request.config.api_style == AiApiStyle::Responses { + call_responses_api(&client, request).await + } else { + call_openai_compatible(&client, request).await + } + } + AiProvider::Custom => { + if uses_anthropic_messages_api(&request.config) { + call_claude(&client, request).await + } else if request.config.api_style == AiApiStyle::Responses { + call_responses_api(&client, request).await + } else { + call_openai_compatible(&client, request).await + } + } } } - AiProvider::Custom => { - if uses_anthropic_messages_api(&request.config) { - call_claude(&client, request.clone()).await - } else if request.config.api_style == AiApiStyle::Responses { - call_responses_api(&client, request.clone()).await - } else { - call_openai_compatible(&client, request.clone()).await - } - } - } + }) + .await } // --------------------------------------------------------------------------- @@ -2142,63 +2386,77 @@ async fn stream_claude( }); crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_endpoint(&request.config)) - .headers(claude_headers(&request.config)?) - .json(&body) - .send() - .await - .map_err(|e| format!("Claude request failed: {e}"))?; + let headers = claude_headers(&request.config)?; + let endpoint = resolve_endpoint(&request.config); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - return Err(extract_error(&data).unwrap_or_else(|| "Claude API error".to_string())); - } - - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); - - let mut finished = false; - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if data == "[DONE]" { - finished = true; - break; - } - - if let Ok(event) = serde_json::from_str::(data) { - if let Some(text) = claude_stream_text(&event) { - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - reasoning_delta: None, - done: false, - }); - } - } - } - - if finished { break; } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let headers = headers.clone(); + let endpoint = endpoint.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("Claude request failed: {e}"))?; + if !res.status().is_success() { + return Err(stream_error(res, "Claude API error").await); } - _ = cancelled.notified() => { break; } + + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); + + let mut finished = false; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if data == "[DONE]" { + finished = true; + break; + } + + if let Ok(event) = serde_json::from_str::(data) { + if let Some(text) = claude_stream_text(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text.to_string(), + reasoning_delta: None, + done: false, + }); + } + } + } + + if finished { break; } + } + _ = cancelled.notified() => { break; } + } + } + + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: None, + done: true, + }); + + Ok(()) } - } - - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: None, - done: true, - }); - - Ok(()) + }) + .await } async fn stream_openai( @@ -2222,82 +2480,96 @@ async fn stream_openai( apply_chat_completion_thinking_toggle(&mut body_obj, &request.config); crate::ai_effort::apply_runtime_effort(&mut body_obj, &request.config); - let res = client - .post(resolve_endpoint(&request.config)) - .headers(headers) - .json(&body_obj) - .send() - .await - .map_err(|e| format!("AI request failed: {e}"))?; + let endpoint = resolve_endpoint(&request.config); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - return Err(extract_error(&data).unwrap_or_else(|| "API error".to_string())); - } - - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - let mut finish_reason_deadline = None; - - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); - - let mut finished = false; - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if data == "[DONE]" { - finished = true; - break; - } - - if let Ok(event) = serde_json::from_str::(data) { - if let Some(reasoning) = openai_stream_reasoning(&event) { - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: Some(reasoning.to_string()), - done: false, - }); - } - if let Some(text) = openai_stream_text(&event) { - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text, - reasoning_delta: None, - done: false, - }); - } - if finish_reason_deadline.is_none() && openai_stream_has_finish_reason(&event) { - finish_reason_deadline = - Some(tokio::time::Instant::now() + std::time::Duration::from_secs(1)); - } - } - } - - if finished { break; } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body_obj = body_obj.clone(); + let headers = headers.clone(); + let endpoint = endpoint.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .headers(headers) + .json(&body_obj) + .send() + .await + .map_err(|e| format!("AI request failed: {e}"))?; + if !res.status().is_success() { + return Err(stream_error(res, "API error").await); } - _ = cancelled.notified() => { break; } - _ = async { - match finish_reason_deadline { - Some(deadline) => tokio::time::sleep_until(deadline).await, - None => std::future::pending().await, + + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + let mut finish_reason_deadline = None; + + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); + + let mut finished = false; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if data == "[DONE]" { + finished = true; + break; + } + + if let Ok(event) = serde_json::from_str::(data) { + if let Some(reasoning) = openai_stream_reasoning(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: Some(reasoning.to_string()), + done: false, + }); + } + if let Some(text) = openai_stream_text(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text, + reasoning_delta: None, + done: false, + }); + } + if finish_reason_deadline.is_none() && openai_stream_has_finish_reason(&event) { + finish_reason_deadline = + Some(tokio::time::Instant::now() + std::time::Duration::from_secs(1)); + } + } + } + + if finished { break; } + } + _ = cancelled.notified() => { break; } + _ = async { + match finish_reason_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } + } => { break; } } - } => { break; } + } + + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: None, + done: true, + }); + + Ok(()) } - } - - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: None, - done: true, - }); - - Ok(()) + }) + .await } async fn stream_responses_api( @@ -2317,63 +2589,76 @@ async fn stream_responses_api( }); crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_endpoint(&request.config)) - .headers(headers) - .json(&body) - .send() - .await - .map_err(|e| format!("AI request failed: {e}"))?; + let endpoint = resolve_endpoint(&request.config); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - return Err(extract_error(&data).unwrap_or_else(|| "API error".to_string())); - } - - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); - - let mut finished = false; - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if data == "[DONE]" { - finished = true; - break; - } - - if let Ok(event) = serde_json::from_str::(data) { - if let Some(text) = responses_stream_text(&event) { - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - reasoning_delta: None, - done: false, - }); - } - } - } - - if finished { break; } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let headers = headers.clone(); + let endpoint = endpoint.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("AI request failed: {e}"))?; + if !res.status().is_success() { + return Err(stream_error(res, "API error").await); } - _ = cancelled.notified() => { break; } + + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); + + let mut finished = false; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if data == "[DONE]" { + finished = true; + break; + } + + if let Ok(event) = serde_json::from_str::(data) { + if let Some(text) = responses_stream_text(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text.to_string(), + reasoning_delta: None, + done: false, + }); + } + } + } + + if finished { break; } + } + _ = cancelled.notified() => { break; } + } + } + + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: None, + done: true, + }); + + Ok(()) } - } - - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: None, - done: true, - }); - - Ok(()) + }) + .await } async fn stream_gemini( @@ -2403,57 +2688,71 @@ async fn stream_gemini( }); crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_gemini_stream_endpoint(&request.config)) - .query(&[("key", normalized_api_key(&request.config)), ("alt", "sse")]) - .header(CONTENT_TYPE, "application/json") - .json(&body) - .send() - .await - .map_err(|e| format_transport_error("Gemini", e))?; + let endpoint = resolve_gemini_stream_endpoint(&request.config); + let api_key = normalized_api_key(&request.config).to_string(); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| format_transport_error("Gemini", e))?; - return Err(extract_error(&data).unwrap_or_else(|| "Gemini API error".to_string())); - } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let endpoint = endpoint.clone(); + let api_key = api_key.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .query(&[("key", api_key.as_str()), ("alt", "sse")]) + .header(CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|e| format_transport_error("Gemini", e))?; + if !res.status().is_success() { + return Err(stream_error(res, "Gemini API error").await); + } - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.without_url().to_string())?; - buf.extend_from_slice(&chunk); + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.without_url().to_string())?; + buf.extend_from_slice(&chunk); - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if let Ok(event) = serde_json::from_str::(data) { - let text = gemini_text(&event); - if !text.is_empty() { - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text, - reasoning_delta: None, - done: false, - }); + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if let Ok(event) = serde_json::from_str::(data) { + let text = gemini_text(&event); + if !text.is_empty() { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text, + reasoning_delta: None, + done: false, + }); + } + } } } + _ = cancelled.notified() => { break; } } } - _ = cancelled.notified() => { break; } + + on_chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: None, + done: true, + }); + + Ok(()) } - } - - on_chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: None, - done: true, - }); - - Ok(()) + }) + .await } // --------------------------------------------------------------------------- @@ -2624,129 +2923,146 @@ async fn stream_claude_with_tools( }); crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_endpoint(&request.config)) - .headers(claude_headers(&request.config)?) - .json(&body) - .send() - .await - .map_err(|e| format!("Claude request failed: {e}"))?; + let headers = claude_headers(&request.config)?; + let endpoint = resolve_endpoint(&request.config); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - return Err(extract_error(&data).unwrap_or_else(|| "Claude API error".to_string())); - } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let headers = headers.clone(); + let endpoint = endpoint.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("Claude request failed: {e}"))?; + if !res.status().is_success() { + return Err(stream_error(res, "Claude API error").await); + } - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - // Track the current content block index and type for tool_use blocks - let mut current_block_index: Option = None; - let mut current_block_type: Option = None; - let mut token_usage: Option = None; + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + // Track the current content block index and type for tool_use blocks + let mut current_block_index: Option = None; + let mut current_block_type: Option = None; + let mut token_usage: Option = None; - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); - let mut finished = false; - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if data == "[DONE]" { - finished = true; - break; - } - - if let Ok(event) = serde_json::from_str::(data) { - let event_type = event["type"].as_str().unwrap_or(""); - - match event_type { - // message_start carries input_tokens (prompt cost) - "message_start" => { - if let Some(i) = event["message"]["usage"]["input_tokens"].as_u64() { - let existing_output = token_usage.as_ref().map(|u| u.output_tokens).unwrap_or(0); - token_usage = Some(TokenUsage { input_tokens: i as u32, output_tokens: existing_output }); - } + let mut finished = false; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if data == "[DONE]" { + finished = true; + break; } - // message_delta carries output_tokens (generation cost) - "message_delta" => { - if let Some(o) = event["usage"]["output_tokens"].as_u64() { - let existing_input = token_usage.as_ref().map(|u| u.input_tokens).unwrap_or(0); - token_usage = Some(TokenUsage { input_tokens: existing_input, output_tokens: o as u32 }); - } - } - "content_block_start" => { - let idx = event["index"].as_u64().unwrap_or(0) as u32; - let block_type = event["content_block"]["type"].as_str().unwrap_or(""); - current_block_index = Some(idx); - current_block_type = Some(block_type.to_string()); - if block_type == "tool_use" { - let id = event["content_block"]["id"].as_str().unwrap_or_default().to_string(); - let name = event["content_block"]["name"].as_str().unwrap_or_default().to_string(); - on_event(StreamToolEvent::ToolCallStart { index: idx, id, name }); - } - } - "content_block_delta" => { - let idx = event["index"].as_u64().unwrap_or(0) as u32; - let delta_type = event["delta"]["type"].as_str().unwrap_or(""); + if let Ok(event) = serde_json::from_str::(data) { + let event_type = event["type"].as_str().unwrap_or(""); - match delta_type { - "text_delta" => { - if let Some(text) = event["delta"]["text"].as_str() { - on_event(StreamToolEvent::Chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - reasoning_delta: None, - done: false, - })); + match event_type { + // message_start carries input_tokens (prompt cost) + "message_start" => { + if let Some(i) = event["message"]["usage"]["input_tokens"].as_u64() { + let existing_output = token_usage.as_ref().map(|u| u.output_tokens).unwrap_or(0); + token_usage = Some(TokenUsage { input_tokens: i as u32, output_tokens: existing_output }); } } - "thinking_delta" => { - if let Some(thinking) = event["delta"]["thinking"].as_str() { - on_event(StreamToolEvent::Chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: Some(thinking.to_string()), - done: false, - })); + // message_delta carries output_tokens (generation cost) + "message_delta" => { + if let Some(o) = event["usage"]["output_tokens"].as_u64() { + let existing_input = token_usage.as_ref().map(|u| u.input_tokens).unwrap_or(0); + token_usage = Some(TokenUsage { input_tokens: existing_input, output_tokens: o as u32 }); } } - "input_json_delta" => { - if let Some(fragment) = event["delta"]["partial_json"].as_str() { - on_event(StreamToolEvent::ToolCallDelta { - index: idx, - fragment: fragment.to_string(), - }); + "content_block_start" => { + let idx = event["index"].as_u64().unwrap_or(0) as u32; + let block_type = event["content_block"]["type"].as_str().unwrap_or(""); + current_block_index = Some(idx); + current_block_type = Some(block_type.to_string()); + + if block_type == "tool_use" { + let id = event["content_block"]["id"].as_str().unwrap_or_default().to_string(); + let name = event["content_block"]["name"].as_str().unwrap_or_default().to_string(); + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::ToolCallStart { index: idx, id, name }); } } + "content_block_delta" => { + let idx = event["index"].as_u64().unwrap_or(0) as u32; + let delta_type = event["delta"]["type"].as_str().unwrap_or(""); + + match delta_type { + "text_delta" => { + if let Some(text) = event["delta"]["text"].as_str() { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::Chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text.to_string(), + reasoning_delta: None, + done: false, + })); + } + } + "thinking_delta" => { + if let Some(thinking) = event["delta"]["thinking"].as_str() { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::Chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: Some(thinking.to_string()), + done: false, + })); + } + } + "input_json_delta" => { + if let Some(fragment) = event["delta"]["partial_json"].as_str() { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::ToolCallDelta { + index: idx, + fragment: fragment.to_string(), + }); + } + } + _ => {} + } + } + "content_block_stop" => { + if let Some(idx) = current_block_index.take() { + if current_block_type.as_deref() == Some("tool_use") { + on_event(StreamToolEvent::ToolCallComplete { index: idx }); + } + } + current_block_type = None; + } _ => {} } } - "content_block_stop" => { - if let Some(idx) = current_block_index.take() { - if current_block_type.as_deref() == Some("tool_use") { - on_event(StreamToolEvent::ToolCallComplete { index: idx }); - } - } - current_block_type = None; - } - _ => {} } + + if finished { break; } + } + _ = cancelled.notified() => { + return Err(AGENT_CANCELLED_ERROR.to_string()); } } + } - if finished { break; } - } - _ = cancelled.notified() => { - return Err(AGENT_CANCELLED_ERROR.to_string()); - } + Ok(token_usage) } - } - - Ok(token_usage) + }) + .await } /// Streaming OpenAI-compatible call with tool support. @@ -2809,107 +3125,123 @@ async fn stream_openai_with_tools( } crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_endpoint(&request.config)) - .headers(headers) - .json(&body) - .send() - .await - .map_err(|e| format!("AI request failed: {e}"))?; + let endpoint = resolve_endpoint(&request.config); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - return Err(extract_error(&data).unwrap_or_else(|| "API error".to_string())); - } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let headers = headers.clone(); + let endpoint = endpoint.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("AI request failed: {e}"))?; + if !res.status().is_success() { + return Err(stream_error(res, "API error").await); + } - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - let mut token_usage: Option = None; - let mut finish_reason_deadline = None; + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + let mut token_usage: Option = None; + let mut finish_reason_deadline = None; - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); - let mut finished = false; - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if data == "[DONE]" { - finished = true; - break; - } - - if let Ok(event) = serde_json::from_str::(data) { - // Token usage (final chunk from OpenAI with include_usage) - if let Some(usage) = event.get("usage") { - if let (Some(p), Some(c)) = ( - usage.get("prompt_tokens").and_then(|v| v.as_u64()), - usage.get("completion_tokens").and_then(|v| v.as_u64()), - ) { - token_usage = Some(TokenUsage { input_tokens: p as u32, output_tokens: c as u32 }); + let mut finished = false; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if data == "[DONE]" { + finished = true; + break; } - } - // Reasoning - if let Some(reasoning) = openai_stream_reasoning(&event) { - on_event(StreamToolEvent::Chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: String::new(), - reasoning_delta: Some(reasoning.to_string()), - done: false, - })); - } - // Text - if let Some(text) = openai_stream_text(&event) { - on_event(StreamToolEvent::Chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text, - reasoning_delta: None, - done: false, - })); - } - // Tool calls - if let Some(tool_calls) = event["choices"].get(0).and_then(|c| c["delta"]["tool_calls"].as_array()) { - for tc in tool_calls { - let idx = tc["index"].as_u64().unwrap_or(0) as u32; - // The first delta carries the tool call id and - // function name. Some OpenAI-compatible providers - // (e.g. GLM) send id="" on subsequent deltas, so - // only a non-empty id marks a genuine start. - if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) { - let name = tc["function"]["name"].as_str().unwrap_or_default().to_string(); - on_event(StreamToolEvent::ToolCallStart { index: idx, id: id.to_string(), name }); + + if let Ok(event) = serde_json::from_str::(data) { + // Token usage (final chunk from OpenAI with include_usage) + if let Some(usage) = event.get("usage") { + if let (Some(p), Some(c)) = ( + usage.get("prompt_tokens").and_then(|v| v.as_u64()), + usage.get("completion_tokens").and_then(|v| v.as_u64()), + ) { + token_usage = Some(TokenUsage { input_tokens: p as u32, output_tokens: c as u32 }); + } } - // Argument fragments - if let Some(fragment) = tc["function"]["arguments"].as_str() { - on_event(StreamToolEvent::ToolCallDelta { index: idx, fragment: fragment.to_string() }); + // Reasoning + if let Some(reasoning) = openai_stream_reasoning(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::Chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: String::new(), + reasoning_delta: Some(reasoning.to_string()), + done: false, + })); + } + // Text + if let Some(text) = openai_stream_text(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::Chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text, + reasoning_delta: None, + done: false, + })); + } + // Tool calls + if let Some(tool_calls) = event["choices"].get(0).and_then(|c| c["delta"]["tool_calls"].as_array()) { + for tc in tool_calls { + let idx = tc["index"].as_u64().unwrap_or(0) as u32; + // The first delta carries the tool call id and + // function name. Some OpenAI-compatible providers + // (e.g. GLM) send id="" on subsequent deltas, so + // only a non-empty id marks a genuine start. + if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) { + let name = tc["function"]["name"].as_str().unwrap_or_default().to_string(); + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::ToolCallStart { index: idx, id: id.to_string(), name }); + } + // Argument fragments + if let Some(fragment) = tc["function"]["arguments"].as_str() { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::ToolCallDelta { index: idx, fragment: fragment.to_string() }); + } + } + } + if finish_reason_deadline.is_none() && openai_stream_has_finish_reason(&event) { + finish_reason_deadline = + Some(tokio::time::Instant::now() + std::time::Duration::from_secs(1)); } } } - if finish_reason_deadline.is_none() && openai_stream_has_finish_reason(&event) { - finish_reason_deadline = - Some(tokio::time::Instant::now() + std::time::Duration::from_secs(1)); - } - } - } - if finished { break; } - } - _ = cancelled.notified() => { - return Err(AGENT_CANCELLED_ERROR.to_string()); - } - _ = async { - match finish_reason_deadline { - Some(deadline) => tokio::time::sleep_until(deadline).await, - None => std::future::pending().await, + if finished { break; } + } + _ = cancelled.notified() => { + return Err(AGENT_CANCELLED_ERROR.to_string()); + } + _ = async { + match finish_reason_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } + } => { break; } } - } => { break; } + } + + Ok(token_usage) } - } - - Ok(token_usage) + }) + .await } async fn stream_responses_with_tools( @@ -2933,112 +3265,128 @@ async fn stream_responses_with_tools( }); crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_endpoint(&request.config)) - .headers(headers) - .json(&body) - .send() - .await - .map_err(|e| format!("AI request failed: {e}"))?; + let endpoint = resolve_endpoint(&request.config); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; - return Err(extract_error(&data).unwrap_or_else(|| "API error".to_string())); - } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let headers = headers.clone(); + let endpoint = endpoint.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("AI request failed: {e}"))?; + if !res.status().is_success() { + return Err(stream_error(res, "API error").await); + } - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - let mut item_indices: HashMap = HashMap::new(); - let mut started_indices: HashSet = HashSet::new(); - let mut argument_indices: HashSet = HashSet::new(); - let mut next_index: u32 = 0; - let mut token_usage: Option = None; + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + let mut item_indices: HashMap = HashMap::new(); + let mut started_indices: HashSet = HashSet::new(); + let mut argument_indices: HashSet = HashSet::new(); + let mut next_index: u32 = 0; + let mut token_usage: Option = None; - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.to_string())?; - buf.extend_from_slice(&chunk); + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.to_string())?; + buf.extend_from_slice(&chunk); - let mut finished = false; - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if data == "[DONE]" { - finished = true; - break; + let mut finished = false; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if data == "[DONE]" { + finished = true; + break; + } + + if let Ok(event) = serde_json::from_str::(data) { + if let Some(usage) = responses_token_usage(&event) { + token_usage = Some(usage); + } + + if let Some(text) = responses_stream_text(&event) { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::Chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text.to_string(), + reasoning_delta: None, + done: false, + })); + } + + match event["type"].as_str().unwrap_or_default() { + "response.output_item.added" => { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + emit_responses_function_call_item( + &event, + &mut item_indices, + &mut started_indices, + &mut argument_indices, + &mut next_index, + on_event, + ); + } + "response.output_item.done" => { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + if let Some(index) = emit_responses_function_call_item( + &event, + &mut item_indices, + &mut started_indices, + &mut argument_indices, + &mut next_index, + on_event, + ) { + on_event(StreamToolEvent::ToolCallComplete { index }); + } + } + "response.function_call_arguments.delta" => { + let index = event["item_id"] + .as_str() + .and_then(|id| item_indices.get(id).copied()) + .or_else(|| event["output_index"].as_u64().map(|i| i as u32)) + .unwrap_or(0); + if let Some(fragment) = event["delta"].as_str() { + argument_indices.insert(index); + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::ToolCallDelta { index, fragment: fragment.to_string() }); + } + } + "response.function_call_arguments.done" => { + let index = event["item_id"] + .as_str() + .and_then(|id| item_indices.get(id).copied()) + .or_else(|| event["output_index"].as_u64().map(|i| i as u32)) + .unwrap_or(0); + on_event(StreamToolEvent::ToolCallComplete { index }); + } + _ => {} + } + } + } + + if finished { break; } } - - if let Ok(event) = serde_json::from_str::(data) { - if let Some(usage) = responses_token_usage(&event) { - token_usage = Some(usage); - } - - if let Some(text) = responses_stream_text(&event) { - on_event(StreamToolEvent::Chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - reasoning_delta: None, - done: false, - })); - } - - match event["type"].as_str().unwrap_or_default() { - "response.output_item.added" => { - emit_responses_function_call_item( - &event, - &mut item_indices, - &mut started_indices, - &mut argument_indices, - &mut next_index, - on_event, - ); - } - "response.output_item.done" => { - if let Some(index) = emit_responses_function_call_item( - &event, - &mut item_indices, - &mut started_indices, - &mut argument_indices, - &mut next_index, - on_event, - ) { - on_event(StreamToolEvent::ToolCallComplete { index }); - } - } - "response.function_call_arguments.delta" => { - let index = event["item_id"] - .as_str() - .and_then(|id| item_indices.get(id).copied()) - .or_else(|| event["output_index"].as_u64().map(|i| i as u32)) - .unwrap_or(0); - if let Some(fragment) = event["delta"].as_str() { - argument_indices.insert(index); - on_event(StreamToolEvent::ToolCallDelta { index, fragment: fragment.to_string() }); - } - } - "response.function_call_arguments.done" => { - let index = event["item_id"] - .as_str() - .and_then(|id| item_indices.get(id).copied()) - .or_else(|| event["output_index"].as_u64().map(|i| i as u32)) - .unwrap_or(0); - on_event(StreamToolEvent::ToolCallComplete { index }); - } - _ => {} - } + _ = cancelled.notified() => { + return Err(AGENT_CANCELLED_ERROR.to_string()); } } + } - if finished { break; } - } - _ = cancelled.notified() => { - return Err(AGENT_CANCELLED_ERROR.to_string()); - } + Ok(token_usage) } - } - - Ok(token_usage) + }) + .await } fn build_gemini_contents(messages: &[AiMessage]) -> Vec { @@ -3135,71 +3483,86 @@ async fn stream_gemini_with_tools( }); crate::ai_effort::apply_runtime_effort(&mut body, &request.config); - let res = client - .post(resolve_gemini_stream_endpoint(&request.config)) - .query(&[("key", normalized_api_key(&request.config)), ("alt", "sse")]) - .header(CONTENT_TYPE, "application/json") - .json(&body) - .send() - .await - .map_err(|e| format_transport_error("Gemini", e))?; + let endpoint = resolve_gemini_stream_endpoint(&request.config); + let api_key = normalized_api_key(&request.config).to_string(); + let config = request.config.clone(); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - if !res.status().is_success() { - let data: serde_json::Value = res.json().await.map_err(|e| format_transport_error("Gemini", e))?; - return Err(extract_error(&data).unwrap_or_else(|| "Gemini API error".to_string())); - } + with_stream_retry(&config, &emitted, Some(cancelled), || { + let body = body.clone(); + let endpoint = endpoint.clone(); + let api_key = api_key.clone(); + let session_id = session_id.to_string(); + let emitted = emitted.clone(); + async move { + let res = client + .post(&endpoint) + .query(&[("key", api_key.as_str()), ("alt", "sse")]) + .header(CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|e| format_transport_error("Gemini", e))?; + if !res.status().is_success() { + return Err(stream_error(res, "Gemini API error").await); + } - let mut byte_stream = res.bytes_stream(); - let mut buf = Vec::new(); - let mut tool_call_idx: u32 = 0; - let mut token_usage: Option = None; + let mut byte_stream = res.bytes_stream(); + let mut buf = Vec::new(); + let mut tool_call_idx: u32 = 0; + let mut token_usage: Option = None; - loop { - tokio::select! { - chunk = byte_stream.next() => { - let Some(chunk) = chunk else { break }; - let chunk = chunk.map_err(|e| e.without_url().to_string())?; - buf.extend_from_slice(&chunk); + loop { + tokio::select! { + chunk = byte_stream.next() => { + let Some(chunk) = chunk else { break }; + let chunk = chunk.map_err(|e| e.without_url().to_string())?; + buf.extend_from_slice(&chunk); - while let Some(line) = drain_next_stream_line(&mut buf)? { - let Some(data) = stream_data_payload(&line) else { continue }; - if let Ok(event) = serde_json::from_str::(data) { - // Token usage (overwrite each chunk, keep last value) - if let (Some(p), Some(c)) = ( - event["usageMetadata"]["promptTokenCount"].as_u64(), - event["usageMetadata"]["candidatesTokenCount"].as_u64(), - ) { - token_usage = Some(TokenUsage { input_tokens: p as u32, output_tokens: c as u32 }); - } - if let Some(candidates) = event["candidates"].as_array() { - if let Some(parts) = candidates[0]["content"]["parts"].as_array() { - for part in parts { - // Text - if let Some(text) = part["text"].as_str() { - on_event(StreamToolEvent::Chunk(AiStreamChunk { - session_id: session_id.to_string(), - delta: text.to_string(), - reasoning_delta: None, - done: false, - })); - } - // Function call (Gemini sends complete objects, not deltas) - if emit_gemini_tool_call_part(part, tool_call_idx, on_event) { - tool_call_idx += 1; + while let Some(line) = drain_next_stream_line(&mut buf)? { + let Some(data) = stream_data_payload(&line) else { continue }; + if let Ok(event) = serde_json::from_str::(data) { + // Token usage (overwrite each chunk, keep last value) + if let (Some(p), Some(c)) = ( + event["usageMetadata"]["promptTokenCount"].as_u64(), + event["usageMetadata"]["candidatesTokenCount"].as_u64(), + ) { + token_usage = Some(TokenUsage { input_tokens: p as u32, output_tokens: c as u32 }); + } + if let Some(candidates) = event["candidates"].as_array() { + if let Some(parts) = candidates[0]["content"]["parts"].as_array() { + for part in parts { + // Text + if let Some(text) = part["text"].as_str() { + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + on_event(StreamToolEvent::Chunk(AiStreamChunk { + session_id: session_id.clone(), + delta: text.to_string(), + reasoning_delta: None, + done: false, + })); + } + // Function call (Gemini sends complete objects, not deltas) + emitted.store(true, std::sync::atomic::Ordering::Relaxed); + if emit_gemini_tool_call_part(part, tool_call_idx, on_event) { + tool_call_idx += 1; + } + } } } } } } + _ = cancelled.notified() => { + return Err(AGENT_CANCELLED_ERROR.to_string()); + } } } - _ = cancelled.notified() => { - return Err(AGENT_CANCELLED_ERROR.to_string()); - } - } - } - Ok(token_usage) + Ok(token_usage) + } + }) + .await } /// Public entry point: stream an LLM call with tool support, accumulating tool calls. @@ -3326,17 +3689,19 @@ mod tests { apply_chat_completion_thinking_toggle, build_ai_http_client, build_gemini_contents, build_responses_input_with_tools, call_claude, classify_error, claude_headers, claude_system_prompt, drain_next_stream_line, emit_gemini_tool_call_part, emit_responses_function_call_item, format_transport_error, - gemini_text, is_kimi_model, maybe_bearer_headers, measure_first_stream_chunk, - ollama_selected_model_tool_support, openai_response_text, openai_stream_reasoning, openai_stream_text, - parse_dynamic_effort_capability, parse_gemini_model_list_response, parse_model_list_response, resolve_endpoint, + gemini_text, is_kimi_model, is_retryable_error, maybe_bearer_headers, maybe_tag_retry_after, + measure_first_stream_chunk, merge_global_max_retries, ollama_selected_model_tool_support, openai_response_text, + openai_stream_reasoning, openai_stream_text, parse_dynamic_effort_capability, parse_gemini_model_list_response, + parse_model_list_response, parse_retry_after, parse_retry_after_secs, resolve_endpoint, resolve_gemini_stream_endpoint, resolve_model_effort_core, resolve_model_list_endpoint, resolve_ollama_show_endpoint, responses_function_tool, responses_max_output_tokens, responses_stream_text, - responses_text, responses_token_usage, retain_ollama_completion_models, set_chat_completion_token_limit, - stream_claude, stream_claude_with_tools, stream_data_payload, stream_openai_with_tools, - uses_anthropic_messages_api, validate_config, validate_model_list_config, AiApiStyle, AiAuthMethod, - AiCapabilitySource, AiCompletionRequest, AiConfig, AiEffortCapability, AiEffortOption, AiEffortSelection, - AiMessage, AiModelInfo, AiProvider, AiReasoningLevel, StreamToolEvent, StreamingToolCallAccumulator, - ToolCallRef, AUTHORIZATION, CLAUDE_DEFAULT_SYSTEM, TEST_PROMPT, + responses_text, responses_token_usage, retain_ollama_completion_models, retry_after_secs, + set_chat_completion_token_limit, stream_claude, stream_claude_with_tools, stream_data_payload, stream_error, + stream_openai_with_tools, test_connection_core, uses_anthropic_messages_api, validate_config, + validate_model_list_config, with_retry, with_stream_retry, AiApiStyle, AiAuthMethod, AiCapabilitySource, + AiCompletionRequest, AiConfig, AiEffortCapability, AiEffortOption, AiEffortSelection, AiMessage, AiModelInfo, + AiProvider, AiReasoningLevel, StreamToolEvent, StreamingToolCallAccumulator, ToolCallRef, AUTHORIZATION, + CLAUDE_DEFAULT_SYSTEM, TEST_PROMPT, }; struct CapturedJsonRequest { @@ -3389,6 +3754,37 @@ mod tests { (format!("http://{address}/v1/messages"), server) } + async fn spawn_error_server_with_body( + status: u16, + reason: &'static str, + body: &'static str, + retry_after_secs: Option, + ) -> (String, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let n = socket.read(&mut buf).await.unwrap(); + assert!(n > 0, "server received no request"); + + let mut resp = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n", + body.len() + ); + if let Some(secs) = retry_after_secs { + resp.push_str(&format!("Retry-After: {secs}\r\n")); + } + resp.push_str("Connection: close\r\n\r\n"); + resp.push_str(body); + socket.write_all(resp.as_bytes()).await.unwrap(); + }); + + (format!("http://{address}/test"), server) + } + fn claude_http_test_request(endpoint: String) -> AiCompletionRequest { AiCompletionRequest { config: AiConfig { @@ -3405,6 +3801,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: Some(1_000_000), + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3723,6 +4120,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3752,6 +4150,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3779,6 +4178,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3806,6 +4206,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3837,6 +4238,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3865,6 +4267,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3905,6 +4308,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3928,6 +4332,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -3954,6 +4359,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4004,6 +4410,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4066,6 +4473,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4100,6 +4508,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4214,6 +4623,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4245,6 +4655,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4325,6 +4736,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4585,6 +4997,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4717,6 +5130,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4753,6 +5167,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4795,6 +5210,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4831,6 +5247,7 @@ mod tests { reasoning_level: AiReasoningLevel::High, runtime_effort: Some(AiEffortSelection::ProviderDefault), context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4894,6 +5311,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -4951,6 +5369,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -5077,4 +5496,468 @@ mod tests { assert!(value.get("special_values").is_none()); assert_eq!(serde_json::from_value::(value).unwrap(), capability); } + + // ------------------------------------------------------------------ + // with_retry / with_stream_retry unit tests + // ------------------------------------------------------------------ + + fn retry_config(max_retries: Option) -> AiConfig { + serde_json::from_value(serde_json::json!({ + "provider": "openai", + "model": "gpt-4", + "maxRetries": max_retries, + })) + .unwrap() + } + + #[tokio::test] + async fn retry_succeeds_on_first_attempt() { + let cfg = retry_config(Some(2)); + let result = with_retry(&cfg, || async { Ok::<&str, String>("ok") }).await; + assert_eq!(result, Ok("ok")); + } + + #[tokio::test] + async fn retry_succeeds_after_transient_error() { + let cfg = retry_config(Some(2)); + let mut calls = 0; + let result = with_retry(&cfg, || { + calls += 1; + async move { + if calls == 1 { + Err("rate limit exceeded (429)".to_string()) + } else { + Ok::<&str, String>("recovered") + } + } + }) + .await; + assert_eq!(result, Ok("recovered")); + assert_eq!(calls, 2); + } + + #[tokio::test] + async fn retry_exhausts_and_returns_last_error() { + let cfg = retry_config(Some(1)); // max 1 → 2 attempts total + let result = with_retry(&cfg, || async { Err::<(), _>("gateway timeout (504)".to_string()) }).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("504")); + } + + #[tokio::test] + async fn retry_zero_means_no_retry() { + let cfg = retry_config(Some(0)); + let mut calls = 0; + let result = with_retry(&cfg, || { + calls += 1; + async move { Err::<(), _>("rate limit exceeded (429)".to_string()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(calls, 1, "maxRetries=0 should not retry"); + } + + #[tokio::test] + async fn retry_stops_on_non_retryable_error() { + let cfg = retry_config(Some(3)); + let mut calls = 0; + let result = with_retry(&cfg, || { + calls += 1; + async move { Err::<(), _>("unauthorized (401)".to_string()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(calls, 1, "auth errors are not retryable"); + } + + #[tokio::test] + async fn retry_honours_retry_after_from_error() { + // Verify that a [retry-after:N] tag in the error forces the delay + // to be at least N seconds. We use a tight timing assertion: the + // sleep call inside with_retry must take ≥ 1 second when the error + // carries [retry-after:1]. + let cfg = retry_config(Some(2)); + let start = std::time::Instant::now(); + let mut calls = 0; + let result = with_retry(&cfg, || { + calls += 1; + async move { + if calls == 1 { + Err("[retry-after:1]rate limit exceeded (429)".to_string()) + } else { + Ok::<&str, String>("ok") + } + } + }) + .await; + let elapsed = start.elapsed(); + assert_eq!(result, Ok("ok")); + assert_eq!(calls, 2); + assert!(elapsed.as_millis() >= 800, "expected ≥1s Retry-After delay, got {}ms", elapsed.as_millis()); + } + + #[tokio::test] + async fn stream_retry_succeeds_on_first_attempt() { + let cfg = retry_config(Some(2)); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let result = with_stream_retry(&cfg, &emitted, None, || async { Ok::<&str, String>("ok") }).await; + assert_eq!(result, Ok("ok")); + } + + #[tokio::test] + async fn stream_retry_does_not_retry_after_content_emitted() { + let cfg = retry_config(Some(3)); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); // content already out + let mut calls = 0; + let result = with_stream_retry(&cfg, &emitted, None, || { + calls += 1; + async move { Err::<(), _>("rate limit exceeded (429)".to_string()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(calls, 1, "should not retry once content has been emitted"); + } + + #[tokio::test] + async fn stream_retry_retries_before_content_emitted() { + let cfg = retry_config(Some(2)); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut calls = 0; + let result = with_stream_retry(&cfg, &emitted, None, || { + calls += 1; + async move { + if calls == 1 { + Err("gateway timeout (504)".to_string()) + } else { + Ok::<&str, String>("recovered") + } + } + }) + .await; + assert_eq!(result, Ok("recovered")); + assert_eq!(calls, 2); + } + + #[tokio::test] + async fn stream_retry_cancels_during_backoff() { + let cfg = retry_config(Some(2)); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancel = tokio::sync::Notify::new(); + + // Cancel immediately — the retry loop should notice during its first + // back-off sleep and return the cancellation error. + cancel.notify_one(); + + let mut calls = 0; + let result = with_stream_retry(&cfg, &emitted, Some(&cancel), || { + calls += 1; + async move { + if calls == 1 { + Err("rate limit exceeded (429)".to_string()) + } else { + Ok::<&str, String>("never") + } + } + }) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("cancelled")); + assert_eq!(calls, 1); + } + + // ------------------------------------------------------------------ + // Retry-After / classify_error helpers + // ------------------------------------------------------------------ + + #[test] + fn test_parse_retry_after_from_error() { + assert_eq!(parse_retry_after("[retry-after:5]rate limit exceeded"), Some(std::time::Duration::from_secs(5))); + assert_eq!(parse_retry_after("[retry-after:120]gateway timeout"), Some(std::time::Duration::from_secs(120))); + assert_eq!(parse_retry_after("rate limit exceeded"), None); + assert_eq!(parse_retry_after("[retry-after:abc]error"), None); + assert_eq!(parse_retry_after(""), None); + } + + #[test] + fn test_retry_after_secs_from_headers() { + let mut headers = reqwest::header::HeaderMap::new(); + assert_eq!(retry_after_secs(&headers), None); + + headers.insert(reqwest::header::RETRY_AFTER, reqwest::header::HeaderValue::from_static("42")); + assert_eq!(retry_after_secs(&headers), Some(42)); + + let now = std::time::UNIX_EPOCH + + std::time::Duration::from_secs(1_700_000_000) + + std::time::Duration::from_millis(250); + let future = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_001); + assert_eq!(parse_retry_after_secs(&httpdate::fmt_http_date(future), now), Some(1)); + + let past = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_699_999_999); + assert_eq!(parse_retry_after_secs(&httpdate::fmt_http_date(past), now), Some(0)); + assert_eq!(parse_retry_after_secs("not-a-number", now), None); + } + + #[test] + fn test_maybe_tag_retry_after() { + let mut headers = reqwest::header::HeaderMap::new(); + assert_eq!(maybe_tag_retry_after(&headers, "err".to_string()), "err"); + + headers.insert(reqwest::header::RETRY_AFTER, reqwest::header::HeaderValue::from_static("30")); + assert_eq!(maybe_tag_retry_after(&headers, "err".to_string()), "[retry-after:30]err"); + } + + #[test] + fn test_is_retryable_error_covers_all_categories() { + assert!(is_retryable_error("rate limit exceeded (429)")); + assert!(is_retryable_error("gateway timeout (504)")); + assert!(is_retryable_error("connection refused")); + assert!(is_retryable_error("response body was empty")); + assert!(!is_retryable_error("unauthorized (401)")); + assert!(!is_retryable_error("model not found (404)")); + assert!(!is_retryable_error("safety filter blocked")); + assert!(!is_retryable_error("finishReason=MAX_TOKENS")); + } + + /// `stream_error` embeds `HTTP ` in the diagnostic; verify that + /// classification works on the patterns it produces, including the + /// empty-body / non-JSON case that was previously classified as `unknown`. + #[test] + fn test_classify_error_from_stream_error_diagnostic() { + // JSON parse succeeded, server error text included. + assert_eq!(classify_error("HTTP 429: rate limit exceeded"), "rateLimit"); + assert_eq!(classify_error("HTTP 504: gateway timeout"), "timeout"); + assert_eq!(classify_error("HTTP 503: service unavailable"), "network"); + assert_eq!(classify_error("HTTP 502: bad gateway"), "network"); + // Empty body — the regression case: a bare 429 with no JSON payload. + assert_eq!(classify_error("HTTP 429: empty response body"), "rateLimit"); + // Non-JSON body that still contains a rate-limit keyword. + assert_eq!(classify_error("HTTP 429: Too Many Requests"), "rateLimit"); + // Auth failures must NOT be retryable. + assert_eq!(classify_error("HTTP 401: unauthorized"), "auth"); + assert_eq!(classify_error("HTTP 403: forbidden"), "auth"); + assert!(!is_retryable_error("[auth] Claude API error (HTTP 401: unauthorized)")); + assert!(!is_retryable_error("[auth] Claude API error (HTTP 403: forbidden)")); + } + + // ------------------------------------------------------------------ + // stream_error integration tests (real reqwest::Response) + // ------------------------------------------------------------------ + + /// Send a real HTTP request against a local server returning a specific + /// error status, then run the response through `stream_error` and verify + /// the output format, classification, and Retry-After encoding. + async fn call_stream_error( + status: u16, + reason: &'static str, + body: &'static str, + retry_after_secs: Option, + ) -> String { + let (url, _server) = spawn_error_server_with_body(status, reason, body, retry_after_secs).await; + let resp = reqwest::Client::new() + .post(&url) + .json(&serde_json::json!({"model": "test"})) + .send() + .await + .expect("request to test server failed"); + stream_error(resp, "TestProvider").await + } + + #[tokio::test] + async fn stream_error_empty_body_429_is_retryable() { + let err = call_stream_error(429, "Too Many Requests", "", None).await; + // Expected format: [{classify}] {fallback} API error (HTTP {status}: {detail}) + assert!(err.contains("[rateLimit]"), "429 with empty body should classify as rateLimit, got: {err}"); + assert!(err.contains("HTTP 429: empty response body"), "got: {err}"); + assert!(is_retryable_error(&err), "stream_error 429 must be retryable, got: {err}"); + } + + #[tokio::test] + async fn stream_error_empty_body_429_triggers_retry() { + let (url, _server) = spawn_error_server_with_body(429, "Too Many Requests", "", None).await; + + let cfg = retry_config(Some(2)); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut calls = 0; + + let result = with_stream_retry(&cfg, &emitted, None, || { + calls += 1; + let url = url.clone(); + async move { + if calls == 1 { + let resp = reqwest::Client::new() + .post(&url) + .json(&serde_json::json!({"model": "test"})) + .send() + .await + .expect("request failed"); + Err(stream_error(resp, "TestProvider").await) + } else { + Ok::<(), String>(()) + } + } + }) + .await; + assert!(result.is_ok(), "should retry after stream_error 429, got: {result:?}"); + assert_eq!(calls, 2); + } + + #[tokio::test] + async fn stream_error_preserves_retry_after_from_headers() { + let err = call_stream_error(429, "Too Many Requests", r#"{"error":{"message":"rate limit"}}"#, Some(5)).await; + // The Retry-After: 5 header should be encoded as [retry-after:5] prefix. + assert!(err.starts_with("[retry-after:5]"), "Retry-After should be prepended, got: {err}"); + assert!(err.contains("[rateLimit]"), "got: {err}"); + assert!(err.contains("rate limit"), "should include original error detail, got: {err}"); + } + + #[tokio::test] + async fn stream_error_retry_after_extends_backoff() { + // If the response says Retry-After: 1, the first retry must wait ≥ 800ms + // (after jitter) rather than the default 500ms. + let (url, _server) = + spawn_error_server_with_body(429, "Too Many Requests", r#"{"error":{"message":"rate limit"}}"#, Some(1)) + .await; + + let cfg = retry_config(Some(2)); + let emitted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let start = std::time::Instant::now(); + let mut calls = 0; + + let result = with_stream_retry(&cfg, &emitted, None, || { + calls += 1; + let url = url.clone(); + async move { + if calls == 1 { + let resp = reqwest::Client::new() + .post(&url) + .json(&serde_json::json!({"model": "test"})) + .send() + .await + .expect("request failed"); + Err(stream_error(resp, "TestProvider").await) + } else { + Ok::<(), String>(()) + } + } + }) + .await; + let elapsed = start.elapsed(); + assert!(result.is_ok()); + assert_eq!(calls, 2); + assert!(elapsed.as_millis() >= 800, "Retry-After: 1 should force ≥1s delay, got {}ms", elapsed.as_millis()); + } + + #[tokio::test] + async fn stream_error_non_json_429_body_is_retryable() { + // Some proxies return HTML or plain text for 429. + let err = call_stream_error(429, "Too Many Requests", "Too Many Requests", None).await; + assert!( + err.contains("[rateLimit]"), + "non-JSON body containing 'rate' keyword should classify as rateLimit, got: {err}" + ); + assert!(err.contains(""), "raw body should be preserved in detail, got: {err}"); + assert!(is_retryable_error(&err)); + } + + #[tokio::test] + async fn stream_error_401_is_not_retryable() { + let err = call_stream_error(401, "Unauthorized", r#"{"error":{"message":"invalid api key"}}"#, None).await; + assert!(err.contains("[auth]"), "got: {err}"); + assert!(err.contains("HTTP 401"), "got: {err}"); + assert!(!is_retryable_error(&err), "401 must not be retryable"); + } + + // ------------------------------------------------------------------ + // merge_global_max_retries unit tests + // ------------------------------------------------------------------ + + fn test_config(provider: AiProvider) -> AiConfig { + serde_json::from_value(serde_json::json!({ + "provider": serde_json::to_string(&provider).unwrap().trim_matches('"'), + "model": "test-model", + })) + .unwrap() + } + + #[test] + fn merge_global_max_retries_sets_for_api_providers() { + for provider in &[ + AiProvider::Claude, + AiProvider::Openai, + AiProvider::OpenaiCompatible, + AiProvider::Custom, + AiProvider::Gemini, + AiProvider::Deepseek, + AiProvider::Qwen, + AiProvider::Ollama, + ] { + let mut config = test_config(provider.clone()); + config.max_retries = None; + merge_global_max_retries(&mut config, 3); + assert_eq!(config.max_retries, Some(3), "merge should set max_retries for {provider:?}"); + } + } + + #[test] + fn merge_global_max_retries_skips_cli_providers() { + for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] { + let mut config = test_config(provider.clone()); + config.max_retries = None; + merge_global_max_retries(&mut config, 0); + assert_eq!(config.max_retries, None, "merge must not touch CLI provider {provider:?}"); + } + } + + // ------------------------------------------------------------------ + // End-to-end: max_retries=0 prevents retry even against a real server + // ------------------------------------------------------------------ + + /// Spawn a TCP server that returns 429 for every connection and counts them. + /// Returns (url, count, handle). Abort the handle after the test. + async fn spawn_counting_429_server( + ) -> (String, std::sync::Arc, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}/v1/messages"); + let count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let count2 = count.clone(); + + let handle = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + count2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut buf = vec![0u8; 4096]; + let _ = socket.read(&mut buf).await; + let resp = b"HTTP/1.1 429 Too Many Requests\r\n\ + Content-Type: application/json\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = socket.write_all(resp).await; + } + }); + + (url, count, handle) + } + + #[tokio::test] + async fn test_connection_core_no_retry_when_max_retries_zero() { + let (url, count, server) = spawn_counting_429_server().await; + + let mut config: AiConfig = serde_json::from_value(serde_json::json!({ + "provider": "claude", + "apiKey": "sk-test", + "model": "claude-sonnet-4", + "endpoint": url, + })) + .unwrap(); + config.max_retries = Some(0); + + let result = test_connection_core(&config).await; + assert!(result.is_err(), "429 should fail, got: {result:?}"); + + server.abort(); + let requests = count.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(requests, 1, "max_retries=0 should mean exactly 1 request, got {requests}"); + } } diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs index 385bc38e84..5234e2e2ef 100644 --- a/crates/dbx-core/src/ai_claude_code_cli.rs +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -573,6 +573,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index 526074719b..9f6e2c46e6 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -1029,6 +1029,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index 5bd29f6d27..d317a77f73 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -383,6 +383,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: HashMap::new(), claude_code_cli_path: None, diff --git a/crates/dbx-core/src/ai_pi_agent_cli.rs b/crates/dbx-core/src/ai_pi_agent_cli.rs index 9c9ab0922a..28e4697331 100644 --- a/crates/dbx-core/src/ai_pi_agent_cli.rs +++ b/crates/dbx-core/src/ai_pi_agent_cli.rs @@ -748,6 +748,7 @@ mod tests { reasoning_level: AiReasoningLevel::High, runtime_effort: Some(AiEffortSelection::Enum("high".to_string())), context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: HashMap::new(), claude_code_cli_path: None, diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index c144368a26..5976d3760c 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1093,6 +1093,7 @@ mod tests { reasoning_level: crate::ai::AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 1a2db3769e..e869423dbe 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -29,6 +29,7 @@ const APP_STATE_EDITOR_SETTINGS_KEY: &str = "editor_settings"; const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs"; const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions"; const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy"; +const MAX_RETRIES_KEY: &str = "max_retries"; const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions"; const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1"; const USER_DATA_TABLES: &[&str] = &[ @@ -1387,18 +1388,23 @@ impl Storage { ) -> Result<(), String> { let mut settings = settings.clone(); self.with_conn(move |conn| { - // The dedicated policy writer is the only owner of this key. Keep - // its latest value across overlapping legacy settings saves. + // Dedicated writers are the only owners of these keys. Keep their + // latest values across overlapping legacy settings saves. let current: Option = conn .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string())?; - settings.remove(MCP_GLOBAL_POLICY_KEY); + let dedicated_keys = [MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY]; + for key in dedicated_keys { + settings.remove(key); + } if let Some(current) = current { let current = serde_json::from_str::>(¤t) .map_err(|e| format!("invalid app settings JSON: {e}"))?; - if let Some(policy) = current.get(MCP_GLOBAL_POLICY_KEY) { - settings.insert(MCP_GLOBAL_POLICY_KEY.to_string(), policy.clone()); + for key in dedicated_keys { + if let Some(value) = current.get(key) { + settings.insert(key.to_string(), value.clone()); + } } } let json = serde_json::Value::Object(settings).to_string(); @@ -1809,6 +1815,37 @@ impl Storage { .map(|value| crate::agent_loop::clamp_max_agent_turns(value.min(u32::MAX as u64) as u32)) .unwrap_or(crate::agent_loop::DEFAULT_MAX_AGENT_TURNS)) } + + pub async fn save_max_retries(&self, max_retries: u32) -> Result<(), String> { + let max_retries = crate::ai::clamp_max_retries(max_retries); + self.with_conn(move |conn| { + let current: Option = conn + .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) + .optional() + .map_err(|e| e.to_string())?; + let mut settings = match current { + Some(json) => serde_json::from_str::>(&json) + .map_err(|e| format!("invalid app settings JSON: {e}"))?, + None => serde_json::Map::new(), + }; + settings + .insert(MAX_RETRIES_KEY.to_string(), serde_json::Value::Number(serde_json::Number::from(max_retries))); + let json = serde_json::Value::Object(settings).to_string(); + conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json]) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn load_max_retries(&self) -> Result { + let settings = self.load_app_settings_json().await?; + Ok(settings + .get(MAX_RETRIES_KEY) + .and_then(serde_json::Value::as_u64) + .map(|value| crate::ai::clamp_max_retries(value.min(u32::MAX as u64) as u32)) + .unwrap_or(crate::ai::DEFAULT_MAX_RETRIES)) + } } // AI Conversations @@ -4714,6 +4751,36 @@ mod tests { assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::MAX_MAX_AGENT_TURNS); } + #[tokio::test] + async fn max_retries_defaults_and_persists_clamped() { + let path = temp_db_path("max-retries"); + let storage = Storage::open(&path).await.unwrap(); + + assert_eq!(storage.load_max_retries().await.unwrap(), crate::ai::DEFAULT_MAX_RETRIES); + + storage.save_max_retries(5).await.unwrap(); + assert_eq!(storage.load_max_retries().await.unwrap(), 5); + + storage.save_max_retries(0).await.unwrap(); + assert_eq!(storage.load_max_retries().await.unwrap(), 0); + + // Values above the cap are clamped so raw DB edits cannot bypass the limit. + storage.save_max_retries(u32::MAX).await.unwrap(); + assert_eq!(storage.load_max_retries().await.unwrap(), crate::ai::MAX_MAX_RETRIES); + } + + #[tokio::test] + async fn max_retries_survives_stale_app_settings_save() { + let path = temp_db_path("max-retries-stale-save"); + let storage = Storage::open(&path).await.unwrap(); + let stale_settings = storage.load_app_settings_json().await.unwrap(); + + storage.save_max_retries(7).await.unwrap(); + storage.save_app_settings_json(&stale_settings).await.unwrap(); + + assert_eq!(storage.load_max_retries().await.unwrap(), 7); + } + #[tokio::test] async fn password_hash_preserves_existing_desktop_settings() { let path = temp_db_path("password-preserve-desktop-settings"); @@ -5040,6 +5107,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: std::collections::HashMap::new(), claude_code_cli_path: None, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 0e4e2db6a4..dc091e8197 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -719,6 +719,10 @@ async fn main() { "/app-settings/max-agent-turns", get(routes::app_settings::load_max_agent_turns).put(routes::app_settings::save_max_agent_turns), ) + .route( + "/app-settings/max-retries", + get(routes::app_settings::load_max_retries).put(routes::app_settings::save_max_retries), + ) .route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config)) // Cloud sync .route("/cloud-sync/webdav/test", post(routes::cloud_sync::webdav_sync_test)) diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index 8f8b82b751..8fd167ac9b 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -114,7 +114,7 @@ fn default_agent_mode() -> String { } fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> { - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if dbx_core::ai::is_cli_provider(&config.provider) { return Err(AppError::bad_request("CLI providers are only supported in DBX Desktop.")); } Ok(()) @@ -269,9 +269,17 @@ pub async fn delete_ai_conversation( // AI complete (non-streaming) // --------------------------------------------------------------------------- -pub async fn ai_complete(Json(body): Json) -> Result, AppError> { +pub async fn ai_complete( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { reject_web_unsupported_ai_provider(&body.request.config)?; - let result = dbx_core::ai::complete(&body.request).await.map_err(AppError::from)?; + let mut request = body.request; + dbx_core::ai::merge_global_max_retries( + &mut request.config, + state.app.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); + let result = dbx_core::ai::complete(&request).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -280,24 +288,44 @@ pub async fn ai_complete(Json(body): Json) -> Result>, Json(body): Json, ) -> Result, AppError> { - reject_web_unsupported_ai_provider(&body.config)?; - let result = dbx_core::ai::test_connection_core(&body.config).await.map_err(AppError::from)?; + let mut config = body.config; + reject_web_unsupported_ai_provider(&config)?; + dbx_core::ai::merge_global_max_retries( + &mut config, + state.app.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); + let result = dbx_core::ai::test_connection_core(&config).await.map_err(AppError::from)?; Ok(Json(result)) } -pub async fn ai_list_models(Json(body): Json) -> Result>, AppError> { - reject_web_unsupported_ai_provider(&body.config)?; - let result = dbx_core::ai::list_models_core(&body.config).await.map_err(AppError::from)?; +pub async fn ai_list_models( + State(state): State>, + Json(body): Json, +) -> Result>, AppError> { + let mut config = body.config; + reject_web_unsupported_ai_provider(&config)?; + dbx_core::ai::merge_global_max_retries( + &mut config, + state.app.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); + let result = dbx_core::ai::list_models_core(&config).await.map_err(AppError::from)?; Ok(Json(result)) } pub async fn ai_resolve_model_effort( + State(state): State>, Json(body): Json, ) -> Result, AppError> { - reject_web_unsupported_ai_provider(&body.config)?; - let result = dbx_core::ai::resolve_model_effort_core(&body.config, &body.model_id).await.map_err(AppError::from)?; + let mut config = body.config; + reject_web_unsupported_ai_provider(&config)?; + dbx_core::ai::merge_global_max_retries( + &mut config, + state.app.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); + let result = dbx_core::ai::resolve_model_effort_core(&config, &body.model_id).await.map_err(AppError::from)?; Ok(Json(result)) } @@ -315,11 +343,16 @@ pub async fn ai_cancel_stream(Json(body): Json) -> Result // --------------------------------------------------------------------------- pub async fn ai_stream( + State(state): State>, Json(body): Json, ) -> Result>>, AppError> { let session_id = body.session_id; - let request = body.request; + let mut request = body.request; reject_web_unsupported_ai_provider(&request.config)?; + dbx_core::ai::merge_global_max_retries( + &mut request.config, + state.app.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); let cancelled = dbx_core::ai::register_stream(&session_id).await; let (tx, rx) = tokio::sync::broadcast::channel::(256); @@ -380,6 +413,10 @@ pub async fn ai_agent_stream( log::warn!("Failed to load max_agent_turns setting, using default: {err}"); dbx_core::agent_loop::DEFAULT_MAX_AGENT_TURNS }); + let max_retries = state.app.storage.load_max_retries().await.unwrap_or_else(|err| { + log::warn!("Failed to load max_retries setting, using default: {err}"); + dbx_core::ai::DEFAULT_MAX_RETRIES + }); // Reject the confirmed-write grant when the connection or database changed // between the user's confirmation and this backend request (defense-in-depth). let (allow_write_sql, confirmed_write_sql) = dbx_core::agent_tools::verify_confirmed_target( @@ -409,7 +446,8 @@ pub async fn ai_agent_stream( }; let sid = session_id.clone(); - let req_config = request.config; + let mut req_config = request.config; + dbx_core::ai::merge_global_max_retries(&mut req_config, max_retries); let req_system_prompt = request.system_prompt; let req_messages = request.messages; let req_task_contract = request.task_contract; @@ -475,6 +513,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, runtime_effort: None, context_window: None, + max_retries: None, codex_cli_path: None, codex_cli_env: Default::default(), claude_code_cli_path: None, @@ -508,4 +547,77 @@ mod tests { assert!(reject_web_unsupported_ai_provider(&config).is_ok(), "provider {:?} should be allowed", provider); } } + + /// Integration test: the `ai_test_connection` web handler applies global + /// max_retries=0 so that a 429 is not retried. + #[tokio::test] + async fn web_test_connection_respects_global_max_retries_zero() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use axum::extract::State; + use dbx_core::connection::AppState; + + let dir = std::env::temp_dir().join(format!("dbx-web-intg-max-retries-{}", uuid::Uuid::new_v4())); + let _ = std::fs::create_dir_all(&dir); + let storage = dbx_core::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); + storage.save_max_retries(0).await.unwrap(); + assert_eq!(storage.load_max_retries().await.unwrap(), 0); + + let app = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins"))); + let web_state = Arc::new(crate::state::WebState::for_tests(app, dir.clone())); + + // Counting 429 server + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}"); + let count = Arc::new(AtomicU32::new(0)); + let count2 = count.clone(); + let srv = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + count2.fetch_add(1, Ordering::SeqCst); + let mut buf = vec![0u8; 4096]; + let _ = socket.read(&mut buf).await; + let resp = b"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(resp).await; + } + }); + + let config = AiConfig { + provider: AiProvider::Claude, + api_key: "sk-test".to_string(), + auth_method: AiAuthMethod::ApiKey, + endpoint: url.clone(), + model: "claude-sonnet-4".to_string(), + models: vec![], + api_style: AiApiStyle::AnthropicMessages, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: false, + reasoning_level: AiReasoningLevel::Default, + runtime_effort: None, + context_window: None, + max_retries: None, + codex_cli_path: None, + codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), + }; + + let body = super::AiTestConnectionRequest { config }; + let result = super::ai_test_connection(State(web_state), axum::Json(body)).await; + + // 429 → should NOT succeed (no retry). + if let Ok(axum::Json(resp)) = result { + assert!(!resp.success, "429 with max_retries=0 must fail, got success"); + } + + srv.abort(); + assert_eq!(count.load(Ordering::SeqCst), 1, "max_retries=0 must mean exactly 1 request"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/dbx-web/src/routes/app_settings.rs b/crates/dbx-web/src/routes/app_settings.rs index cc4aff708f..bfc3244280 100644 --- a/crates/dbx-web/src/routes/app_settings.rs +++ b/crates/dbx-web/src/routes/app_settings.rs @@ -83,6 +83,24 @@ pub async fn save_max_agent_turns( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveMaxRetriesRequest { + pub max_retries: u32, +} + +pub async fn load_max_retries(State(state): State>) -> Result, AppError> { + state.app.storage.load_max_retries().await.map(Json).map_err(AppError::from) +} + +pub async fn save_max_retries( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.storage.save_max_retries(body.max_retries).await.map_err(AppError::from)?; + Ok(Json(())) +} + pub async fn decrypt_config(Json(body): Json) -> Result, AppError> { decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError::from) } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 51e814164a..7d8e69635c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,6 +67,9 @@ font-kit = "0.14.3" tauri-plugin-clipboard-manager = "2.3.2" sys-locale = "0.3" +[dev-dependencies] +tauri = { version = "2.10.3", features = ["test"] } + [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false, features = ["std"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSButton", "NSControl", "NSDockTile", "NSImage", "NSResponder", "NSView", "NSWindow"] } diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 31761a7d7f..8d74a34efb 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -6,20 +6,39 @@ use super::connection::AppState; pub use dbx_core::ai::*; #[tauri::command] -pub async fn ai_test_connection(config: AiConfig) -> Result { - let config = resolve_cli_provider_config(config); +pub async fn ai_test_connection( + state: State<'_, Arc>, + config: AiConfig, +) -> Result { + let mut config = resolve_cli_provider_config(config); + merge_global_max_retries( + &mut config, + state.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); dbx_core::ai::test_connection_core(&config).await } #[tauri::command] -pub async fn ai_list_models(config: AiConfig) -> Result, String> { - let config = resolve_cli_provider_config(config); +pub async fn ai_list_models(state: State<'_, Arc>, config: AiConfig) -> Result, String> { + let mut config = resolve_cli_provider_config(config); + merge_global_max_retries( + &mut config, + state.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); dbx_core::ai::list_models_core(&config).await } #[tauri::command] -pub async fn ai_resolve_model_effort(config: AiConfig, model_id: String) -> Result { - let config = resolve_cli_provider_config(config); +pub async fn ai_resolve_model_effort( + state: State<'_, Arc>, + config: AiConfig, + model_id: String, +) -> Result { + let mut config = resolve_cli_provider_config(config); + merge_global_max_retries( + &mut config, + state.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); dbx_core::ai::resolve_model_effort_core(&config, &model_id).await } @@ -67,12 +86,27 @@ pub async fn load_ai_chat_selection(state: State<'_, Arc>) -> Result Result { +pub async fn ai_complete(state: State<'_, Arc>, request: AiCompletionRequest) -> Result { + let mut request = request; + merge_global_max_retries( + &mut request.config, + state.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); dbx_core::ai::complete(&request).await } #[tauri::command] -pub async fn ai_stream(app: AppHandle, session_id: String, request: AiCompletionRequest) -> Result<(), String> { +pub async fn ai_stream( + app: AppHandle, + state: State<'_, Arc>, + session_id: String, + request: AiCompletionRequest, +) -> Result<(), String> { + let mut request = request; + merge_global_max_retries( + &mut request.config, + state.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); let cancelled = dbx_core::ai::register_stream(&session_id).await; let result = dbx_core::ai::stream(&session_id, &request, &cancelled, |chunk| { @@ -110,7 +144,11 @@ pub async fn ai_agent_stream( confirmed_connection_id: Option, confirmed_database: Option, ) -> Result { - let request = resolve_cli_provider_request(request); + let mut request = resolve_cli_provider_request(request); + merge_global_max_retries( + &mut request.config, + state.storage.load_max_retries().await.unwrap_or(dbx_core::ai::DEFAULT_MAX_RETRIES), + ); let parsed_db_type: DatabaseType = serde_json::from_str(&format!("\"{}\"", db_type)).map_err(|_| format!("Unknown database type: {db_type}"))?; @@ -209,10 +247,6 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { config } -fn is_cli_provider(provider: &AiProvider) -> bool { - matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) -} - fn is_explicit_cli_path(command: &str) -> bool { let path = Path::new(command); path.is_absolute() || command.contains('/') || command.contains('\\') @@ -235,6 +269,58 @@ pub async fn delete_ai_conversation(state: State<'_, Arc>, id: String) #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + use tauri::Manager; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::super::connection::AppState; + use dbx_core::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel}; + + /// Spawn a TCP server that returns 429 and counts connections. + async fn counting_429_server() -> (String, Arc, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}"); + let count = Arc::new(AtomicU32::new(0)); + let count2 = count.clone(); + let srv = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + count2.fetch_add(1, Ordering::SeqCst); + let mut buf = vec![0u8; 4096]; + let _ = socket.read(&mut buf).await; + let resp = b"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(resp).await; + } + }); + (url, count, srv) + } + + fn test_ai_config(endpoint: &str, model: &str) -> AiConfig { + AiConfig { + provider: AiProvider::Claude, + api_key: "sk-test".to_string(), + auth_method: AiAuthMethod::ApiKey, + endpoint: endpoint.to_string(), + model: model.to_string(), + models: vec![], + api_style: AiApiStyle::AnthropicMessages, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: false, + reasoning_level: AiReasoningLevel::Default, + runtime_effort: None, + context_window: None, + max_retries: None, + codex_cli_path: None, + codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), + } + } #[test] fn verify_confirmed_target_allows_matching_connection_and_database() { @@ -310,4 +396,30 @@ mod tests { assert_eq!(allow, Some(false)); assert_eq!(confirmed, None); } + + #[tokio::test] + async fn tauri_entry_respects_global_max_retries_zero() { + let dir = std::env::temp_dir().join(format!("dbx-tauri-mr-{}", uuid::Uuid::new_v4())); + let _ = std::fs::create_dir_all(&dir); + let storage = dbx_core::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); + storage.save_max_retries(0).await.unwrap(); + assert_eq!(storage.load_max_retries().await.unwrap(), 0); + + let (url, count, srv) = counting_429_server().await; + + let app_state = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins"))); + let app = tauri::test::mock_app(); + app.manage(app_state); + + let state: tauri::State<'_, Arc> = app.state(); + let config = test_ai_config(&url, "claude-sonnet-4"); + let result = super::ai_test_connection(state, config).await; + + assert!(result.is_err(), "429 with max_retries=0 must fail, got: {result:?}"); + srv.abort(); + assert_eq!(count.load(Ordering::SeqCst), 1, "max_retries=0 must mean exactly 1 request"); + + drop(app); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index 66db69df54..53f060697f 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -69,6 +69,16 @@ pub async fn save_max_agent_turns(state: State<'_, Arc>, max_agent_tur state.storage.save_max_agent_turns(max_agent_turns).await } +#[tauri::command] +pub async fn load_max_retries(state: State<'_, Arc>) -> Result { + state.storage.load_max_retries().await +} + +#[tauri::command] +pub async fn save_max_retries(state: State<'_, Arc>, max_retries: u32) -> Result<(), String> { + state.storage.save_max_retries(max_retries).await +} + #[tauri::command] pub async fn complete_app_close(app: AppHandle, window: Window, action: String) -> Result<(), String> { match action.as_str() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e8d37401fa..976c5abe3c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1352,6 +1352,8 @@ pub fn run() { commands::app_settings::save_desktop_settings, commands::app_settings::load_max_agent_turns, commands::app_settings::save_max_agent_turns, + commands::app_settings::load_max_retries, + commands::app_settings::save_max_retries, commands::app_settings::set_app_locale, commands::app_settings::complete_app_close, commands::app_settings::mark_frontend_ready,