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
1 change: 1 addition & 0 deletions messages/en/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"RESOURCE_BUSY": "Resource is currently in use",
"INVALID_STATE": "Operation not allowed in current state",
"CONFLICT": "Operation conflict",
"ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "This endpoint is still referenced by {count} enabled providers: {providers}",

"RATE_LIMIT_RPM_EXCEEDED": "Rate limit exceeded: {current} requests per minute (limit: {limit}). Resets at {resetTime}",
"RATE_LIMIT_5H_EXCEEDED": "5-hour cost limit exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}",
Expand Down
1 change: 1 addition & 0 deletions messages/ja/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"RESOURCE_BUSY": "リソースは現在使用中です",
"INVALID_STATE": "現在の状態では操作が許可されていません",
"CONFLICT": "操作の競合",
"ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "このエンドポイントは {count} 件の有効なプロバイダーから参照されています: {providers}",

"USER_NOT_FOUND": "ユーザーが見つかりません",
"USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "一般ユーザーはクォータ制限とプロバイダーグループを変更できません",
Expand Down
1 change: 1 addition & 0 deletions messages/ru/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"RESOURCE_BUSY": "Ресурс в настоящее время используется",
"INVALID_STATE": "Операция не разрешена в текущем состоянии",
"CONFLICT": "Конфликт операции",
"ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "Этот endpoint все еще используется {count} активными провайдерами: {providers}",

"USER_NOT_FOUND": "Пользователь не найден",
"USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "Обычные пользователи не могут изменять лимиты квоты и группы провайдеров",
Expand Down
1 change: 1 addition & 0 deletions messages/zh-CN/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"RESOURCE_BUSY": "资源正在使用中",
"INVALID_STATE": "当前状态不允许此操作",
"CONFLICT": "操作冲突",
"ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "该端点仍被 {count} 个启用中的供应商引用:{providers}",

"USER_NOT_FOUND": "用户不存在",
"USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "普通用户不能修改账户限额和供应商分组",
Expand Down
1 change: 1 addition & 0 deletions messages/zh-TW/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"RESOURCE_BUSY": "資源正在使用中",
"INVALID_STATE": "當前狀態不允許此操作",
"CONFLICT": "操作衝突",
"ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "此端點仍被 {count} 個啟用中的供應商引用:{providers}",

"USER_NOT_FOUND": "使用者不存在",
"USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "普通使用者不能修改帳戶額度和供應商分組",
Expand Down
64 changes: 57 additions & 7 deletions src/actions/provider-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import {
resetEndpointCircuit as resetEndpointCircuitState,
} from "@/lib/endpoint-circuit-breaker";
import { logger } from "@/lib/logger";
import { PROVIDER_ENDPOINT_CONFLICT_CODE } from "@/lib/provider-endpoint-error-codes";
import {
ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE,
PROVIDER_ENDPOINT_CONFLICT_CODE,
} from "@/lib/provider-endpoint-error-codes";
import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe";
import { SessionManager } from "@/lib/session-manager";
import { ERROR_CODES } from "@/lib/utils/error-messages";
import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n";
import {
Expand All @@ -35,8 +39,9 @@ import {
} from "@/repository";
import {
findDashboardProviderEndpointsByVendorAndType,
findEnabledProviderIdsByVendorAndType,
findEnabledProviderReferencesForVendorTypeUrl,
findEnabledProviderVendorTypePairs,
hasEnabledProviderReferenceForVendorTypeUrl,
} from "@/repository/provider-endpoints";
import {
findProviderEndpointProbeLogsBatch,
Expand Down Expand Up @@ -219,6 +224,25 @@ function isForeignKeyViolationError(error: unknown): boolean {
);
}

function formatProviderReferenceSummary(
references: Array<{ id: number; name: string }>,
maxDisplayCount: number = 3
): string {
const uniqueNames = Array.from(
new Set(references.map((reference) => reference.name.trim()).filter(Boolean))
);
if (uniqueNames.length === 0) {
return "";
}

if (uniqueNames.length <= maxDisplayCount) {
return uniqueNames.join(", ");
}

const displayed = uniqueNames.slice(0, maxDisplayCount).join(", ");
return `${displayed} +${uniqueNames.length - maxDisplayCount}`;
}
Comment on lines +227 to +244

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

不要在 action 层拼装新的用户可见文案。

这里新增的 error 中文句子,以及 providers"A, B, C +1" 摘要,都会绕过现有 i18n 流程。既然已经有 errorCodeerrorParams,更稳妥的是只返回原始数据(例如 provider names/count),由展示层按 locale 组装文案。

As per coding guidelines, "All user-facing strings must use i18n (5 languages supported: zh-CN, zh-TW, en, ja, ru). Never hardcode display text"

Also applies to: 593-606

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/provider-endpoints.ts` around lines 227 - 244, The function
formatProviderReferenceSummary is assembling user-facing summary text (e.g., "A,
B, C +1") which bypasses i18n; instead, change formatProviderReferenceSummary
(and the similar logic around the other occurrence) to stop returning formatted
strings and return raw data for the presentation layer — e.g., return an object
with unique provider names array and a total count (or just the names array and
length) so the UI can compose localized text using errorCode/errorParams; remove
any hardcoded concatenation or "+N" formatting from the action layer.


export async function getProviderVendors(): Promise<ProviderVendor[]> {
try {
const session = await getAdminSession();
Expand Down Expand Up @@ -496,6 +520,21 @@ export async function editProviderEndpoint(
}
}

const shouldTerminateStickySessions =
parsed.data.url !== undefined ||
parsed.data.sortOrder !== undefined ||
parsed.data.isEnabled !== undefined;
if (shouldTerminateStickySessions) {
const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
endpoint.vendorId,
endpoint.providerType
);
await SessionManager.terminateStickySessionsForProviders(
affectedProviderIds,
"editProviderEndpoint"
);
}

try {
await publishProviderCacheInvalidation();
} catch (error) {
Expand Down Expand Up @@ -551,18 +590,20 @@ export async function removeProviderEndpoint(input: unknown): Promise<ActionResu
};
}

// 若该端点仍被启用 provider 引用,则不允许删除:否则会导致运行时 endpoint pool 变空/回填复活,
// 产生“删了但还在/仍被探测”的困惑(#781)。
const referencedByEnabledProvider = await hasEnabledProviderReferenceForVendorTypeUrl({
const references = await findEnabledProviderReferencesForVendorTypeUrl({
vendorId: endpoint.vendorId,
providerType: endpoint.providerType,
url: endpoint.url,
});
if (referencedByEnabledProvider) {
if (references.length > 0) {
return {
ok: false,
error: "该端点仍被启用的供应商引用,请先修改或禁用相关供应商的 URL 后再删除",
errorCode: ERROR_CODES.CONFLICT,
errorCode: ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE,
errorParams: {
count: references.length,
providers: formatProviderReferenceSummary(references),
},
};
}

Expand All @@ -585,6 +626,15 @@ export async function removeProviderEndpoint(input: unknown): Promise<ActionResu
});
}

const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
endpoint.vendorId,
endpoint.providerType
);
await SessionManager.terminateStickySessionsForProviders(
affectedProviderIds,
"removeProviderEndpoint"
);
Comment on lines +629 to +636

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unguarded DB call after soft-delete can produce misleading error

findEnabledProviderIdsByVendorAndType is called outside any try-catch at line 629, after the endpoint has already been soft-deleted at line 610. Every other non-critical post-delete operation in this function (resetEndpointCircuitState, tryDeleteProviderVendorIfEmpty, publishProviderCacheInvalidation) is wrapped in its own try-catch precisely to prevent this scenario. If the DB query at line 629 throws (e.g. transient DB connectivity issue), the outer catch at line 660 returns { ok: false, errorCode: DELETE_FAILED } — even though the deletion already succeeded. The UI and the caller will believe the operation failed and likely show an error, while the endpoint is actually gone.

Suggested change
const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
endpoint.vendorId,
endpoint.providerType
);
await SessionManager.terminateStickySessionsForProviders(
affectedProviderIds,
"removeProviderEndpoint"
);
try {
const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
endpoint.vendorId,
endpoint.providerType
);
await SessionManager.terminateStickySessionsForProviders(
affectedProviderIds,
"removeProviderEndpoint"
);
} catch (error) {
logger.warn("removeProviderEndpoint:terminate_sessions_failed", {
endpointId: parsed.data.endpointId,
vendorId: endpoint.vendorId,
error: error instanceof Error ? error.message : String(error),
});
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/provider-endpoints.ts
Line: 629-636

Comment:
**Unguarded DB call after soft-delete can produce misleading error**

`findEnabledProviderIdsByVendorAndType` is called outside any try-catch at line 629, after the endpoint has already been soft-deleted at line 610. Every other non-critical post-delete operation in this function (`resetEndpointCircuitState`, `tryDeleteProviderVendorIfEmpty`, `publishProviderCacheInvalidation`) is wrapped in its own try-catch precisely to prevent this scenario. If the DB query at line 629 throws (e.g. transient DB connectivity issue), the outer catch at line 660 returns `{ ok: false, errorCode: DELETE_FAILED }` — even though the deletion already succeeded. The UI and the caller will believe the operation failed and likely show an error, while the endpoint is actually gone.

```suggestion
    try {
      const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
        endpoint.vendorId,
        endpoint.providerType
      );
      await SessionManager.terminateStickySessionsForProviders(
        affectedProviderIds,
        "removeProviderEndpoint"
      );
    } catch (error) {
      logger.warn("removeProviderEndpoint:terminate_sessions_failed", {
        endpointId: parsed.data.endpointId,
        vendorId: endpoint.vendorId,
        error: error instanceof Error ? error.message : String(error),
      });
    }
```

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


// Auto cleanup: if the vendor has no active providers/endpoints, delete it as well.
try {
await tryDeleteProviderVendorIfEmpty(endpoint.vendorId);
Expand Down
31 changes: 31 additions & 0 deletions src/actions/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
saveProviderCircuitConfig,
} from "@/lib/redis/circuit-breaker-config";
import { RedisKVStore } from "@/lib/redis/redis-kv-store";
import { SessionManager } from "@/lib/session-manager";
import { maskKey } from "@/lib/utils/validation";
import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n";
import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url";
Expand Down Expand Up @@ -183,6 +184,28 @@ async function broadcastProviderCacheInvalidation(context: {
}
}

const STICKY_SESSION_INVALIDATING_PROVIDER_KEYS = new Set<string>([
"url",
"websiteUrl",
"providerType",
"groupTag",
"isEnabled",
"allowedModels",
"allowedClients",
"blockedClients",
"modelRedirects",
"activeTimeStart",
"activeTimeEnd",
]);

function shouldInvalidateStickySessionsOnProviderEdit(
changedProviderFields: Record<string, unknown>
): boolean {
return Object.keys(changedProviderFields).some((key) =>
STICKY_SESSION_INVALIDATING_PROVIDER_KEYS.has(key)
);
}

// 获取服务商数据
export async function getProviders(): Promise<ProviderDisplay[]> {
try {
Expand Down Expand Up @@ -776,6 +799,10 @@ export async function editProvider(
return { ok: false, error: "供应商不存在" };
}

if (shouldInvalidateStickySessionsOnProviderEdit(preimageFields)) {
await SessionManager.terminateStickySessionsForProviders([providerId], "editProvider");
}

// 同步熔断器配置到 Redis(如果配置有变化)
const hasCircuitConfigChange =
validated.circuit_breaker_failure_threshold !== undefined ||
Expand Down Expand Up @@ -848,6 +875,8 @@ export async function removeProvider(
const provider = await findProviderById(providerId);
await deleteProvider(providerId);

await SessionManager.terminateStickySessionsForProviders([providerId], "removeProvider");

const undoToken = createProviderPatchUndoToken();
const operationId = createProviderPatchOperationId();

Expand Down Expand Up @@ -1280,6 +1309,8 @@ const SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY: Record<string, keyof Provider>
active_time_end: "activeTimeEnd",
model_redirects: "modelRedirects",
allowed_models: "allowedModels",
allowed_clients: "allowedClients",
blocked_clients: "blockedClients",
limit_5h_usd: "limit5hUsd",
limit_daily_usd: "limitDailyUsd",
daily_reset_mode: "dailyResetMode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,10 +427,8 @@ describe("provider-chain-popover hedge/abort reason handling", () => {
// hedge_triggered is informational, not an actual request
// so the request count should be 2 (winner + loser), not 3
const document = parseHtml(html);
const countBadge = Array.from(document.querySelectorAll('[data-slot="badge"]')).find((node) =>
(node.textContent ?? "").includes("times")
);
expect(countBadge?.textContent).toContain("2");
const requestRows = document.querySelectorAll("#root .relative.flex.gap-2");
expect(requestRows).toHaveLength(2);
});

test("hedge_winner is treated as successful provider", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { getProviderEndpoints, getProviderVendors } from "@/actions/provider-endpoints";
import { getProviderEndpoints } from "@/actions/provider-endpoints";
import {
addProvider,
editProvider,
Expand All @@ -27,12 +27,7 @@ import {
import { Button } from "@/components/ui/button";
import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
import { isValidUrl } from "@/lib/utils/validation";
import type {
ProviderDisplay,
ProviderEndpoint,
ProviderType,
ProviderVendor,
} from "@/types/provider";
import type { ProviderDisplay, ProviderEndpoint, ProviderType } from "@/types/provider";
import { FormTabNav, NAV_ORDER, PARENT_MAP, TAB_ORDER } from "./components/form-tab-nav";
import { ProviderFormProvider, useProviderForm } from "./provider-form-context";
import type { NavTargetId, SubTabId, TabId } from "./provider-form-types";
Expand All @@ -43,29 +38,6 @@ import { OptionsSection } from "./sections/options-section";
import { RoutingSection } from "./sections/routing-section";
import { TestingSection } from "./sections/testing-section";

function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null {
const trimmed = rawUrl.trim();
if (!trimmed) return null;

const candidates = [trimmed];
if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
candidates.push(`https://${trimmed}`);
}

for (const candidate of candidates) {
try {
const parsed = new URL(candidate);
const hostname = parsed.hostname?.toLowerCase();
if (!hostname) continue;
return hostname.startsWith("www.") ? hostname.slice(4) : hostname;
} catch {
// ignore
}
}

return null;
}

export interface ProviderFormProps {
mode: "create" | "edit";
onSuccess?: () => void;
Expand Down Expand Up @@ -101,29 +73,10 @@ function ProviderFormContent({
const isEdit = mode === "edit";

const queryClient = useQueryClient();
const { data: vendors = [] } = useQuery<ProviderVendor[]>({
queryKey: ["provider-vendors"],
queryFn: getProviderVendors,
staleTime: 60_000,
refetchOnWindowFocus: false,
});

const websiteDomain = useMemo(
() => normalizeWebsiteDomainFromUrl(state.basic.websiteUrl),
[state.basic.websiteUrl]
);

const resolvedEndpointPoolVendorId = useMemo(() => {
// Edit mode: vendor id already attached to provider record
if (isEdit) {
return provider?.providerVendorId ?? null;
}

// Create/clone: resolve vendor from websiteUrl hostname
if (!websiteDomain) return null;
const vendor = vendors.find((v) => v.websiteDomain === websiteDomain);
return vendor?.id ?? null;
}, [isEdit, provider?.providerVendorId, vendors, websiteDomain]);
return isEdit ? (provider?.providerVendorId ?? null) : null;
}, [isEdit, provider?.providerVendorId]);

const endpointPoolQueryKey = useMemo(() => {
if (resolvedEndpointPoolVendorId == null) return null;
Expand Down Expand Up @@ -162,22 +115,6 @@ function ProviderFormContent({
!hideUrl && resolvedEndpointPoolVendorId != null && endpointPoolHasEnabledEndpoints;

// Keep state.basic.url usable across other sections when legacy URL input is hidden.
useEffect(() => {
if (isEdit) return;
if (hideUrl) return;
if (!endpointPoolHideLegacyUrlInput) return;
if (!endpointPoolPreferredUrl) return;
if (state.basic.url.trim()) return;
dispatch({ type: "SET_URL", payload: endpointPoolPreferredUrl });
}, [
isEdit,
hideUrl,
endpointPoolHideLegacyUrlInput,
endpointPoolPreferredUrl,
state.basic.url,
dispatch,
]);

// Update URL when resolved URL changes
useEffect(() => {
if (resolvedUrl && !state.basic.url && !isEdit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ function EndpointRow({
circuitState: EndpointCircuitState | null;
}) {
const t = useTranslations("settings.providers");
const tErrors = useTranslations("errors");
const tStatus = useTranslations("settings.providers.endpointStatus");
const tCommon = useTranslations("settings.common");
const queryClient = useQueryClient();
Expand Down Expand Up @@ -274,16 +275,31 @@ function EndpointRow({
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await removeProviderEndpoint({ endpointId: endpoint.id });
if (!res.ok) throw new Error(res.error);
if (!res.ok) {
throw Object.assign(new Error(res.error), { actionResult: res });
}
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["provider-endpoints", endpoint.vendorId] });
queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
toast.success(t("endpointDeleteSuccess"));
},
onError: () => {
toast.error(t("endpointDeleteFailed"));
onError: (
error: Error & {
actionResult?: {
error?: string;
errorCode?: string;
errorParams?: Record<string, string | number>;
};
}
) => {
const actionResult = error.actionResult;
toast.error(
actionResult?.errorCode
? getErrorMessage(tErrors, actionResult.errorCode, actionResult.errorParams)
: (actionResult?.error ?? t("endpointDeleteFailed"))
);
},
});

Expand Down
2 changes: 2 additions & 0 deletions src/lib/provider-endpoint-error-codes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const PROVIDER_ENDPOINT_CONFLICT_CODE = "PROVIDER_ENDPOINT_CONFLICT";
export const PROVIDER_ENDPOINT_WRITE_READ_INCONSISTENCY_CODE =
"PROVIDER_ENDPOINT_WRITE_READ_INCONSISTENCY";
export const ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE =
"ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS";
Loading