Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions messages/en/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json";
import providersGuide from "./providers/guide.json";
import providersInlineEdit from "./providers/inlineEdit.json";
import providersList from "./providers/list.json";
import providersRecluster from "./providers/recluster.json";
import providersSchedulingDialog from "./providers/schedulingDialog.json";
import providersSearch from "./providers/search.json";
import providersSection from "./providers/section.json";
Expand Down Expand Up @@ -81,6 +82,7 @@ const providers = {
guide: providersGuide,
inlineEdit: providersInlineEdit,
list: providersList,
recluster: providersRecluster,
schedulingDialog: providersSchedulingDialog,
search: providersSearch,
section: providersSection,
Expand Down
16 changes: 16 additions & 0 deletions messages/en/settings/providers/recluster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"button": "Recluster",
"dialogTitle": "Recluster Providers",
"dialogDescription": "Reorganize providers based on updated clustering rules. For providers without a website URL, host:port will be used as the clustering key.",
"providersMoved": "Providers Moved",
"vendorsCreated": "Vendors Created",
"vendorsToDelete": "Vendors to Delete",
"skipped": "Skipped (Invalid URL)",
"providerHeader": "Provider",
"vendorChangeHeader": "Vendor Change",
"noChanges": "No changes needed (already clustered correctly)",
"moreChanges": "{count} more changes...",
"confirm": "Apply Changes",
"success": "Reclustered {count} providers",
"error": "Recluster failed"
}
2 changes: 2 additions & 0 deletions messages/ja/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json";
import providersGuide from "./providers/guide.json";
import providersInlineEdit from "./providers/inlineEdit.json";
import providersList from "./providers/list.json";
import providersRecluster from "./providers/recluster.json";
import providersSchedulingDialog from "./providers/schedulingDialog.json";
import providersSearch from "./providers/search.json";
import providersSection from "./providers/section.json";
Expand Down Expand Up @@ -81,6 +82,7 @@ const providers = {
guide: providersGuide,
inlineEdit: providersInlineEdit,
list: providersList,
recluster: providersRecluster,
schedulingDialog: providersSchedulingDialog,
search: providersSearch,
section: providersSection,
Expand Down
16 changes: 16 additions & 0 deletions messages/ja/settings/providers/recluster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"button": "再クラスタ",
"dialogTitle": "プロバイダーの再クラスタリング",
"dialogDescription": "更新されたクラスタリングルールに基づいてプロバイダーを再編成します。ウェブサイトURLが設定されていないプロバイダーには、host:port がクラスタリングキーとして使用されます。",
"providersMoved": "移動したプロバイダー",
"vendorsCreated": "作成されるベンダー",
"vendorsToDelete": "削除されるベンダー",
"skipped": "スキップ (無効なURL)",
"providerHeader": "プロバイダー",
"vendorChangeHeader": "ベンダー変更",
"noChanges": "変更不要 (既に正しくクラスタリング済み)",
"moreChanges": "他 {count} 件の変更...",
"confirm": "変更を適用",
"success": "{count} 件のプロバイダーを再クラスタリングしました",
"error": "再クラスタリングに失敗しました"
}
2 changes: 2 additions & 0 deletions messages/ru/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json";
import providersGuide from "./providers/guide.json";
import providersInlineEdit from "./providers/inlineEdit.json";
import providersList from "./providers/list.json";
import providersRecluster from "./providers/recluster.json";
import providersSchedulingDialog from "./providers/schedulingDialog.json";
import providersSearch from "./providers/search.json";
import providersSection from "./providers/section.json";
Expand Down Expand Up @@ -81,6 +82,7 @@ const providers = {
guide: providersGuide,
inlineEdit: providersInlineEdit,
list: providersList,
recluster: providersRecluster,
schedulingDialog: providersSchedulingDialog,
search: providersSearch,
section: providersSection,
Expand Down
16 changes: 16 additions & 0 deletions messages/ru/settings/providers/recluster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"button": "Перегруппировать",
"dialogTitle": "Перегруппировка поставщиков",
"dialogDescription": "Реорганизация поставщиков по обновленным правилам группировки. Для поставщиков без URL сайта в качестве ключа группировки используется host:port.",
"providersMoved": "Перемещено поставщиков",
"vendorsCreated": "Создано групп",
"vendorsToDelete": "Групп к удалению",
"skipped": "Пропущено (неверный URL)",
"providerHeader": "Поставщик",
"vendorChangeHeader": "Изменение группы",
"noChanges": "Изменения не требуются (уже правильно сгруппировано)",
"moreChanges": "Еще {count} изменений...",
"confirm": "Применить изменения",
"success": "Перегруппировано {count} поставщиков",
"error": "Ошибка перегруппировки"
}
2 changes: 2 additions & 0 deletions messages/zh-CN/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json";
import providersGuide from "./providers/guide.json";
import providersInlineEdit from "./providers/inlineEdit.json";
import providersList from "./providers/list.json";
import providersRecluster from "./providers/recluster.json";
import providersSchedulingDialog from "./providers/schedulingDialog.json";
import providersSearch from "./providers/search.json";
import providersSection from "./providers/section.json";
Expand Down Expand Up @@ -81,6 +82,7 @@ const providers = {
guide: providersGuide,
inlineEdit: providersInlineEdit,
list: providersList,
recluster: providersRecluster,
schedulingDialog: providersSchedulingDialog,
search: providersSearch,
section: providersSection,
Expand Down
16 changes: 16 additions & 0 deletions messages/zh-CN/settings/providers/recluster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"button": "重新分组",
"dialogTitle": "重新分组供应商",
"dialogDescription": "根据更新的分组规则重新组织供应商。对于未设置网站URL的供应商,将使用 host:port 作为分组键。",
"providersMoved": "供应商移动",
"vendorsCreated": "新建分组",
"vendorsToDelete": "待删除分组",
"skipped": "跳过(无效URL)",
"providerHeader": "供应商",
"vendorChangeHeader": "分组变更",
"noChanges": "无需更改(已正确分组)",
"moreChanges": "还有 {count} 条变更...",
"confirm": "应用变更",
"success": "已重新分组 {count} 个供应商",
"error": "重新分组失败"
}
2 changes: 2 additions & 0 deletions messages/zh-TW/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json";
import providersGuide from "./providers/guide.json";
import providersInlineEdit from "./providers/inlineEdit.json";
import providersList from "./providers/list.json";
import providersRecluster from "./providers/recluster.json";
import providersSchedulingDialog from "./providers/schedulingDialog.json";
import providersSearch from "./providers/search.json";
import providersSection from "./providers/section.json";
Expand Down Expand Up @@ -81,6 +82,7 @@ const providers = {
guide: providersGuide,
inlineEdit: providersInlineEdit,
list: providersList,
recluster: providersRecluster,
schedulingDialog: providersSchedulingDialog,
search: providersSearch,
section: providersSection,
Expand Down
16 changes: 16 additions & 0 deletions messages/zh-TW/settings/providers/recluster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"button": "重新分組",
"dialogTitle": "重新分組供應商",
"dialogDescription": "根據更新的分組規則重新組織供應商。對於未設定網站URL的供應商,將使用 host:port 作為分組鍵。",
"providersMoved": "供應商移動",
"vendorsCreated": "新建分組",
"vendorsToDelete": "待刪除分組",
"skipped": "跳過(無效URL)",
"providerHeader": "供應商",
"vendorChangeHeader": "分組變更",
"noChanges": "無需更改(已正確分組)",
"moreChanges": "還有 {count} 條變更...",
"confirm": "應用變更",
"success": "已重新分組 {count} 個供應商",
"error": "重新分組失敗"
}
195 changes: 194 additions & 1 deletion src/actions/providers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"use server";

import { eq } from "drizzle-orm";
import { GeminiAuth } from "@/app/v1/_lib/gemini/auth";
import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
import { buildProxyUrl } from "@/app/v1/_lib/url";
import { db } from "@/drizzle/db";
import { providers as providersTable } from "@/drizzle/schema";
import { getSession } from "@/lib/auth";
import { publishProviderCacheInvalidation } from "@/lib/cache/provider-cache";
import {
Expand Down Expand Up @@ -44,7 +47,13 @@ import {
updateProvider,
updateProviderPrioritiesBatch,
} from "@/repository/provider";
import { tryDeleteProviderVendorIfEmpty } from "@/repository/provider-endpoints";
import {
backfillProviderEndpointsFromProviders,
computeVendorKey,
findProviderVendorById,
getOrCreateProviderVendorIdFromUrls,
tryDeleteProviderVendorIfEmpty,
} from "@/repository/provider-endpoints";
import type { CacheTtlPreference } from "@/types/cache";
import type {
CodexParallelToolCallsPreference,
Expand Down Expand Up @@ -3548,3 +3557,187 @@ export async function getModelSuggestionsByProviderGroup(
return { ok: false, error: "获取模型建议列表失败" };
}
}

// ============================================================================
// Recluster Provider Vendors
// ============================================================================

type ReclusterChange = {
providerId: number;
providerName: string;
oldVendorId: number;
oldVendorDomain: string;
newVendorDomain: string;
};

type ReclusterResult = {
preview: {
providersMoved: number;
vendorsCreated: number;
vendorsToDelete: number;
skippedInvalidUrl: number;
};
changes: ReclusterChange[];
applied: boolean;
};

/**
* Recluster provider vendors based on updated clustering rules.
* When websiteUrl is empty, uses host:port as vendor key instead of just hostname.
*
* @param confirm - false=preview mode (calculate changes only), true=apply mode (execute changes)
*/
export async function reclusterProviderVendors(args: {
confirm: boolean;
}): Promise<ActionResult<ReclusterResult>> {
try {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { ok: false, error: "NO_PERMISSION" };
Comment on lines +3589 to +3596
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential performance issue: this loops through all providers and makes a DB call (findProviderVendorById) for each one

consider batch-loading all vendor data upfront:

Suggested change
*/
export async function reclusterProviderVendors(args: {
confirm: boolean;
}): Promise<ActionResult<ReclusterResult>> {
try {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { ok: false, error: "NO_PERMISSION" };
// Get current vendor domain
const allVendorIds = allProviders
.map(p => p.providerVendorId)
.filter((id): id is number => id !== null && id !== undefined);
const vendors = await Promise.all(
[...new Set(allVendorIds)].map(id => findProviderVendorById(id))
);
const vendorMap = new Map(vendors.filter((v): v is NonNullable<typeof v> => v !== null).map(v => [v.id, v]));

then in the loop:

const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/providers.ts
Line: 3589:3596

Comment:
potential performance issue: this loops through all providers and makes a DB call (`findProviderVendorById`) for each one

consider batch-loading all vendor data upfront:

```suggestion
    // Get current vendor domain
    const allVendorIds = allProviders
      .map(p => p.providerVendorId)
      .filter((id): id is number => id !== null && id !== undefined);
    const vendors = await Promise.all(
      [...new Set(allVendorIds)].map(id => findProviderVendorById(id))
    );
    const vendorMap = new Map(vendors.filter((v): v is NonNullable<typeof v> => v !== null).map(v => [v.id, v]));
```

then in the loop:
```
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
```

How can I resolve this? If you propose a fix, please make it concise.

}
Comment on lines +3594 to +3597
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 查找 NO_PERMISSION 的前端映射或显示位置
rg -n -C3 "NO_PERMISSION" -g'*.{ts,tsx,js,jsx}'

Repository: ding113/claude-code-hub

Length of output: 492


🏁 Script executed:

#!/bin/bash
# 查找 i18n/翻译相关的文件
fd -e json -e ts -e tsx | grep -i -E '(i18n|messages|locale|translation|lang)' | head -20

Repository: ding113/claude-code-hub

Length of output: 1134


🏁 Script executed:

#!/bin/bash
# 查看 src/actions/providers.ts 中其他的 error 返回方式
rg -n 'return.*error' src/actions/providers.ts | head -20

Repository: ding113/claude-code-hub

Length of output: 1092


🏁 Script executed:

#!/bin/bash
# 查找i18n翻译文件的位置
fd -type f -E 'node_modules' -E 'dist' -E 'build' | grep -E '(messages|locale|lang)' | grep -v test | head -20

Repository: ding113/claude-code-hub

Length of output: 237


🏁 Script executed:

#!/bin/bash
# 查看 src/actions/providers.ts 中 NO_PERMISSION 行的完整上下文
sed -n '3590,3610p' src/actions/providers.ts

Repository: ding113/claude-code-hub

Length of output: 651


🏁 Script executed:

#!/bin/bash
# 查找翻译文件中是否有 NO_PERMISSION
rg "NO_PERMISSION" -E '.json$|.ts$' --type-list | grep -v node_modules | grep -v test

Repository: ding113/claude-code-hub

Length of output: 456


🏁 Script executed:

#!/bin/bash
# 查找i18n翻译文件的位置
find src -type f \( -name "*messages*" -o -name "*locale*" -o -name "*i18n*" \) 2>/dev/null | head -20

Repository: ding113/claude-code-hub

Length of output: 749


🏁 Script executed:

#!/bin/bash
# 搜索NO_PERMISSION在整个项目中的使用
rg "NO_PERMISSION" -n

Repository: ding113/claude-code-hub

Length of output: 148


🏁 Script executed:

#!/bin/bash
# 查看src目录的结构
ls -la src/ | head -30

Repository: ding113/claude-code-hub

Length of output: 834


🏁 Script executed:

#!/bin/bash
# 查看 src/i18n 目录的内容
ls -la src/i18n/

Repository: ding113/claude-code-hub

Length of output: 481


🏁 Script executed:

#!/bin/bash
# 查看 error-messages.ts 文件内容
cat src/lib/utils/error-messages.ts

Repository: ding113/claude-code-hub

Length of output: 8132


🏁 Script executed:

#!/bin/bash
# 查找翻译文件中是否有 permission 相关的内容
rg -i "permission" src/i18n/ -l

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

#!/bin/bash
# 查看 src/actions/providers.ts 中其他的错误返回是否都使用硬编码中文
grep -n 'error:' src/actions/providers.ts | grep -v ERROR_CODES | grep -v PERMISSION_DENIED | head -10

Repository: ding113/claude-code-hub

Length of output: 722


🏁 Script executed:

#!/bin/bash
# 检查项目中是否有其他地方使用了 NO_PERMISSION 这样的非标准错误代码
rg '"[A-Z_]+"\s*}' src/actions/ -A1 -B1 | grep error | head -20

Repository: ding113/claude-code-hub

Length of output: 222


改用项目标准错误代码 PERMISSION_DENIED

"NO_PERMISSION" 不符合项目的错误代码规范。应根据 src/lib/utils/error-messages.ts 的定义,改用 AUTH_ERRORS.PERMISSION_DENIED,以便:

  • 支持多语言翻译映射
  • 符合 ActionResulterrorCode 规范

其他权限检查(如 490、658 行)也存在同样的硬编码中文问题,建议同步修正。

🤖 Prompt for AI Agents
In `@src/actions/providers.ts` around lines 3594 - 3597, Replace the hard-coded
"NO_PERMISSION" error string with the project-standard error code
AUTH_ERRORS.PERMISSION_DENIED (imported from src/lib/utils/error-messages.ts) so
the returned ActionResult uses the correct errorCode and supports translations;
update the return in the getSession() check around the session.user.role !==
"admin" guard to return { ok: false, error: AUTH_ERRORS.PERMISSION_DENIED } and
add the necessary import for AUTH_ERRORS at the top of the file, and then
propagate the same replacement for other permission checks in this file that
currently return "NO_PERMISSION" (the other similar checks noted in the review).


const allProviders = await findAllProvidersFresh();

if (allProviders.length === 0) {
return {
ok: true,
data: {
preview: {
providersMoved: 0,
vendorsCreated: 0,
vendorsToDelete: 0,
skippedInvalidUrl: 0,
},
changes: [],
applied: args.confirm,
},
};
}

const changes: ReclusterChange[] = [];
const newVendorKeys = new Set<string>();
const oldVendorIds = new Set<number>();
let skippedInvalidUrl = 0;

// Batch load all vendor data upfront to avoid N+1 queries
const uniqueVendorIds = [
...new Set(
allProviders
.map((p) => p.providerVendorId)
.filter((id): id is number => id !== null && id !== undefined && id > 0)
),
];
const vendors = await Promise.all(uniqueVendorIds.map((id) => findProviderVendorById(id)));
const vendorMap = new Map(
vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
);

// Build provider map for quick lookup in transaction
const providerMap = new Map(allProviders.map((p) => [p.id, p]));

// Calculate new vendor key for each provider
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});

if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}

// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";

// If key changed, record the change
Comment on lines +3649 to +3654
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

该行触发格式化失败
CI 已提示此处需要按 Biome 规则换行。

修复示例
-      const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
+      const currentVendor = provider.providerVendorId
+        ? vendorMap.get(provider.providerVendorId)
+        : null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId
? vendorMap.get(provider.providerVendorId)
: null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
🧰 Tools
🪛 GitHub Actions: 🧪 Test Suite

[error] 3649-3653: Formatter would have printed different content. Run the formatter to fix code style issues.

🪛 GitHub Actions: PR Build Check

[error] 3649-3654: Code formatter would reformat the following content. The format:check failed (exit code 1). Run 'bun run format' or 'bun run format:write' to fix formatting in this file.

🤖 Prompt for AI Agents
In `@src/actions/providers.ts` around lines 3649 - 3654, The line declaring
currentDomain violates Biome line-wrapping rules; modify the const declaration
for currentDomain (currently using currentVendor?.websiteDomain ?? "") to span
multiple lines so it wraps properly — e.g., break after the optional chaining or
the nullish coalescing operator and indent the fallback string on the next line;
adjust the const in the same block where provider, vendorMap and currentVendor
are used to preserve semantics.

if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}

const preview = {
providersMoved: changes.length,
vendorsCreated: newVendorKeys.size,
vendorsToDelete: oldVendorIds.size,
skippedInvalidUrl,
};
Comment on lines +3631 to +3675
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

vendorsCreated 预览可能被高估
新 key 可能已存在 vendor(当前无 provider 绑定),统计会偏大;建议扣除已存在域名。

修复示例
     const vendorMap = new Map(
       vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
     );
+    const existingVendorDomains = new Set(
+      Array.from(vendorMap.values()).map((vendor) => vendor.websiteDomain)
+    );
@@
     const preview = {
       providersMoved: changes.length,
-      vendorsCreated: newVendorKeys.size,
+      vendorsCreated: Array.from(newVendorKeys).filter(
+        (key) => !existingVendorDomains.has(key)
+      ).length,
       vendorsToDelete: oldVendorIds.size,
       skippedInvalidUrl,
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const vendorMap = new Map(
vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
);
// Build provider map for quick lookup in transaction
const providerMap = new Map(allProviders.map((p) => [p.id, p]));
// Calculate new vendor key for each provider
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});
if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}
const preview = {
providersMoved: changes.length,
vendorsCreated: newVendorKeys.size,
vendorsToDelete: oldVendorIds.size,
skippedInvalidUrl,
};
const vendorMap = new Map(
vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
);
const existingVendorDomains = new Set(
Array.from(vendorMap.values()).map((vendor) => vendor.websiteDomain)
);
// Build provider map for quick lookup in transaction
const providerMap = new Map(allProviders.map((p) => [p.id, p]));
// Calculate new vendor key for each provider
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});
if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}
const preview = {
providersMoved: changes.length,
vendorsCreated: Array.from(newVendorKeys).filter(
(key) => !existingVendorDomains.has(key)
).length,
vendorsToDelete: oldVendorIds.size,
skippedInvalidUrl,
};
🧰 Tools
🪛 GitHub Actions: 🧪 Test Suite

[error] 3649-3653: Formatter would have printed different content. Run the formatter to fix code style issues.

🪛 GitHub Actions: PR Build Check

[error] 3649-3654: Code formatter would reformat the following content. The format:check failed (exit code 1). Run 'bun run format' or 'bun run format:write' to fix formatting in this file.

🤖 Prompt for AI Agents
In `@src/actions/providers.ts` around lines 3631 - 3675, The preview's
vendorsCreated can be inflated because newVendorKeys currently includes domains
that already exist as vendor.websiteDomain even if they have no providers; to
fix, build a set of existing vendor domains from vendors (e.g.,
existingVendorDomains) and when iterating allProviders and computing
newVendorKey (via computeVendorKey), only add newVendorKey to newVendorKeys if
newVendorKey is truthy and not present in existingVendorDomains (and of course
not already in newVendorKeys); reference vendorMap, newVendorKeys,
computeVendorKey, allProviders and ensure preview.vendorsCreated uses the
filtered newVendorKeys.size.


// Preview mode: return without modifying DB
if (!args.confirm) {
return {
ok: true,
data: {
preview,
changes,
applied: false,
},
};
}

// Apply mode: execute changes in transaction
if (changes.length > 0) {
await db.transaction(async (tx) => {
for (const change of changes) {
// Use pre-built map for O(1) lookup instead of O(N) find()
const provider = providerMap.get(change.providerId);
if (!provider) continue;

// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl ?? null,
});

// Update provider's vendorId
await tx
.update(providersTable)
.set({ providerVendorId: newVendorId, updatedAt: new Date() })
.where(eq(providersTable.id, change.providerId));
}
});

// Backfill provider_endpoints
await backfillProviderEndpointsFromProviders();

// Cleanup empty vendors
for (const oldVendorId of oldVendorIds) {
await tryDeleteProviderVendorIfEmpty(oldVendorId);
}

// Publish cache invalidation
try {
await publishProviderCacheInvalidation();
} catch (error) {
logger.warn("reclusterProviderVendors:cache_invalidation_failed", {
changedCount: changes.length,
error: error instanceof Error ? error.message : String(error),
});
}
}

return {
ok: true,
data: {
preview,
changes,
applied: true,
},
};
} catch (error) {
logger.error("reclusterProviderVendors:error", error);
const message = error instanceof Error ? error.message : "Recluster failed";
return { ok: false, error: message };
}
}
2 changes: 2 additions & 0 deletions src/app/[locale]/dashboard/providers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BarChart3 } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AutoSortPriorityDialog } from "@/app/[locale]/settings/providers/_components/auto-sort-priority-dialog";
import { ProviderManagerLoader } from "@/app/[locale]/settings/providers/_components/provider-manager-loader";
import { ReclusterVendorsDialog } from "@/app/[locale]/settings/providers/_components/recluster-vendors-dialog";
import { SchedulingRulesDialog } from "@/app/[locale]/settings/providers/_components/scheduling-rules-dialog";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -48,6 +49,7 @@ export default async function DashboardProvidersPage({
</Link>
</Button>
<AutoSortPriorityDialog />
<ReclusterVendorsDialog />
<SchedulingRulesDialog />
</>
}
Expand Down
Loading
Loading