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
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,12 @@ func (r *Repo) ListUpstreams(ctx context.Context, input repository.ListChannelUp
).
Joins(
`LEFT JOIN (
SELECT upstream_id,
COUNT(*) AS models_count,
COUNT(*) FILTER (WHERE status = 'active') AS active_models_count
FROM llm_upstream_models
GROUP BY upstream_id
SELECT um.upstream_id,
COUNT(r.id) AS models_count,
COUNT(r.id) FILTER (WHERE r.status = 'active' AND um.status = 'active') AS active_models_count
FROM llm_upstream_models um
LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id
GROUP BY um.upstream_id
) AS stats ON stats.upstream_id = u.id`,
)
listQuery = applyUpstreamListFilters(listQuery, input)
Expand Down Expand Up @@ -692,10 +693,10 @@ func applyUpstreamModelListFilters(query *gorm.DB, input repository.ListChannelU
)
}
switch strings.TrimSpace(input.RouteStatus) {
case "bound":
query = query.Where("r.id IS NOT NULL")
case "active", "inactive":
query = query.Where("r.id IS NOT NULL AND r.status = ?", input.RouteStatus)
case "unbound":
query = query.Where("r.id IS NULL")
}
if status := strings.TrimSpace(input.UpstreamStatus); status == "active" || status == "inactive" {
query = query.Where("um.status = ?", status)
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/transport/http/channel/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ func (h *Handler) ResetUpstreamCircuit(c *gin.Context) {
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param q query string false "搜索关键词"
// @Param route_status query string false "路由状态:active/inactive/unbound"
// @Param route_status query string false "路由状态:bound/active/inactive"
// @Param upstream_status query string false "上游模型状态:active/inactive"
// @Param protocol query string false "接口协议"
// @Param sort query string false "排序:upstream_asc/upstream_desc/platform_asc/platform_desc/status_asc/protocol_asc"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,11 @@ const ModelRow = React.memo(function ModelRow({ row, isSelected, onSelect, onUpd
const t = useTranslations("adminChannels");
const platformModelName = row.platformModelNameDraft.trim();
const hasBindingDraft = platformModelName.length > 0;
const isUnboundCatalogRow = row.routeID === 0 && !hasBindingDraft;
const isPendingDelete = row.routeID > 0 && row.routeStatus === "inactive" && !hasBindingDraft;
const routeChecked = row.routeStatus === "active" || (!row.routeStatus && hasBindingDraft);
const routeChecked = row.routeStatus === "active";
const protocolValue = hasBindingDraft ? row.protocol || row.suggestedProtocol || AUTO_PROTOCOL_VALUE : AUTO_PROTOCOL_VALUE;

const handlePlatformModelChange = (value: string) => {
const patch: Partial<Omit<RowDraft, "draftKey" | "isDirty">> = {
platformModelNameDraft: value,
};
if (row.routeID === 0) {
patch.routeStatus = value.trim() ? row.routeStatus || "active" : "";
}
onUpdate(row.draftKey, patch);
onUpdate(row.draftKey, { platformModelNameDraft: value });
};

return (
Expand All @@ -238,16 +230,12 @@ const ModelRow = React.memo(function ModelRow({ row, isSelected, onSelect, onUpd
</div>
</TableCell>
<TableCell className="w-[56px] whitespace-nowrap">
{isUnboundCatalogRow ? (
<span className="text-xs text-muted-foreground">{t("modelsDialog.unbound")}</span>
) : (
<Switch
size="sm"
checked={routeChecked}
onCheckedChange={(checked) => onUpdate(row.draftKey, { routeStatus: checked ? "active" : "inactive" })}
aria-label={t("modelsDialog.routeStatusFor", { name: row.upstreamModelName })}
/>
)}
<Switch
size="sm"
checked={routeChecked}
onCheckedChange={(checked) => onUpdate(row.draftKey, { routeStatus: checked ? "active" : "inactive" })}
aria-label={t("modelsDialog.routeStatusFor", { name: row.upstreamModelName })}
/>
</TableCell>
<TableCell className="max-w-[220px] font-mono text-xs text-muted-foreground">
<span className="block truncate" title={row.upstreamModelName}>
Expand All @@ -265,7 +253,7 @@ const ModelRow = React.memo(function ModelRow({ row, isSelected, onSelect, onUpd
<TableCell className="w-[176px] whitespace-nowrap">
{!hasBindingDraft ? (
<span className="text-xs text-muted-foreground">
{isPendingDelete ? t("modelsDialog.deleteAfterSave") : t("modelsDialog.unbound")}
{t("modelsDialog.deleteAfterSave")}
</span>
) : (
<Select
Expand Down Expand Up @@ -307,9 +295,9 @@ type RemoteModelsDialogProps = {
onImported: () => void;
};

function remoteModelStatusKey(item: AdminLLMRemoteModelItem): "bound" | "synced" | "unsynced" {
function remoteModelStatusKey(item: AdminLLMRemoteModelItem): "bound" | "unbound" | "unsynced" {
if (item.alreadyBound) return "bound";
return item.alreadySynced ? "synced" : "unsynced";
return item.alreadySynced ? "unbound" : "unsynced";
}

function dedupeRemoteModels(items: AdminLLMRemoteModelItem[]): AdminLLMRemoteModelItem[] {
Expand Down Expand Up @@ -386,10 +374,10 @@ function RemoteModelsDialog({
try {
const token = await resolveAccessToken();
const data = await listAdminLLMRemoteModels(token, upstream.id);
const unbound = dedupeRemoteModels(data.items.filter((i) => !i.alreadyBound));
setRemoteItems(unbound);
setSelected(new Set(unbound.map((i) => i.upstreamModelName)));
setDraftPlatformModelNames(createDraftPlatformModelNameMap(unbound));
const syncableItems = dedupeRemoteModels(data.items.filter((i) => !i.alreadyBound));
setRemoteItems(syncableItems);
setSelected(new Set(syncableItems.map((i) => i.upstreamModelName)));
setDraftPlatformModelNames(createDraftPlatformModelNameMap(syncableItems));
} catch (err) {
toast.error(resolveErrorMessage(err, t("modelsDialog.remoteLoadFailed")));
onOpenChange(false);
Expand Down Expand Up @@ -738,7 +726,7 @@ type UpstreamModelsDialogProps = {
onRemoteOpenHandled?: () => void;
};

type RouteStatusFilter = "all" | "active" | "inactive" | "unbound";
type RouteStatusFilter = "bound" | "active" | "inactive";
type UpstreamStatusFilter = "all" | "active" | "inactive";
type RouteSortValue = "upstream_asc" | "upstream_desc" | "platform_asc" | "platform_desc" | "status_asc" | "protocol_asc";

Expand All @@ -762,7 +750,7 @@ const DEFAULT_ROUTE_LIST_PARAMS: RouteListParams = {
page: 1,
pageSize: PAGE_SIZE_DEFAULT,
query: "",
routeStatusFilter: "all",
routeStatusFilter: "bound",
upstreamStatusFilter: "all",
protocolFilter: "",
sortValue: "upstream_asc",
Expand Down Expand Up @@ -809,7 +797,7 @@ export function UpstreamModelsDialog({
page: params.page,
pageSize: params.pageSize,
query: params.query,
routeStatus: params.routeStatusFilter === "all" ? "" : params.routeStatusFilter,
routeStatus: params.routeStatusFilter,
upstreamStatus: params.upstreamStatusFilter === "all" ? "" : params.upstreamStatusFilter,
protocol: params.protocolFilter,
sort: params.sortValue,
Expand Down Expand Up @@ -896,7 +884,7 @@ export function UpstreamModelsDialog({
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasActiveListQuery =
listParams.query !== "" ||
routeStatusFilter !== "all" ||
routeStatusFilter !== "bound" ||
upstreamStatusFilter !== "all" ||
protocolFilter !== "";

Expand Down Expand Up @@ -985,6 +973,7 @@ export function UpstreamModelsDialog({
});
}
void loadBindings();
onUpstreamUpdated({ ...upstream });
} catch (err) {
toast.error(resolveErrorMessage(err, t("toast.deleteFailed")));
} finally {
Expand Down Expand Up @@ -1064,6 +1053,7 @@ export function UpstreamModelsDialog({
toast.success(t("modelsDialog.savedChanges", { savedCount }));
}
await loadBindings();
onUpstreamUpdated({ ...upstream });
} catch (err) {
toast.error(resolveErrorMessage(err, t("toast.updateFailed")));
} finally {
Expand Down Expand Up @@ -1101,13 +1091,12 @@ export function UpstreamModelsDialog({
{
key: "route-status",
label: t("modelsDialog.routeStatus"),
value: routeStatusFilter === "all" ? "" : routeStatusFilter,
onValueChange: (value) => updateListParams({ routeStatusFilter: (value || "all") as RouteStatusFilter }),
value: routeStatusFilter === "bound" ? "" : routeStatusFilter,
onValueChange: (value) => updateListParams({ routeStatusFilter: (value || "bound") as RouteStatusFilter }),
options: [
{ label: t("modelsDialog.allRoutes"), value: "" },
{ label: t("status.active"), value: "active" },
{ label: t("status.inactive"), value: "inactive" },
{ label: t("modelsDialog.unbound"), value: "unbound" },
],
},
{
Expand Down Expand Up @@ -1308,7 +1297,10 @@ export function UpstreamModelsDialog({
open={newBindingOpen}
onOpenChange={setNewBindingOpen}
upstreamId={upstream.id}
onCreated={loadBindings}
onCreated={() => {
void loadBindings();
onUpstreamUpdated({ ...upstream });
}}
/>
)}

Expand Down
1 change: 1 addition & 0 deletions frontend/features/admin/hooks/use-admin-upstreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export function useAdminUpstreams(): UseAdminUpstreamsState {
if (modelsTarget?.id === updated.id) {
setModelsTarget(updated);
}
void load();
}

return {
Expand Down
13 changes: 6 additions & 7 deletions frontend/i18n/messages/en-US/admin-channels.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,17 @@
"creating": "Creating...",
"saving": "Saving..."
},
"modelsDialog": {
"modelsDialog": {
"selectKind": "Select kind",
"selectModel": "Select {name}",
"unbound": "Unbound",
"routeStatusFor": "{name} status",
"platformModelName": "Platform model name",
"platformModel": "Platform model",
"deleteAfterSave": "Delete after saving",
"autoProtocol": "Auto match",
"remoteStatus": {
"bound": "Bound",
"synced": "Synced",
"unbound": "Unbound",
"unsynced": "Not synced"
},
"remoteLoadFailed": "Failed to fetch remote models",
Expand All @@ -183,18 +182,18 @@
"loadFailed": "Load failed",
"batchDeletePartialFailed": "Some bindings failed to delete",
"batchDeleteDone": "Bindings deleted",
"batchDeleteSummary": "Deleted {successCount}, not found {notFoundCount}, failed {failedCount}. The upstream model catalog is kept.",
"batchDeleteSummary": "Deleted {successCount}, not found {notFoundCount}, failed {failedCount}. The upstream model catalog is kept and can be rebound by syncing.",
"noPendingChanges": "No pending changes",
"upstreamModelRequired": "Upstream model name is required",
"activeRouteRequiresPlatformModel": "Active routes require a platform model name. To unbind, disable the route before saving.",
"duplicateBinding": "Upstream model \"{upstreamModelName}\" is already bound to platform model \"{platformModelName}\"",
"noSavableChanges": "No savable binding changes",
"savedAndDeleted": "Saved {savedCount} changes and deleted {deletedCount} bindings",
"deletedBindings": "Deleted {deletedCount} bindings",
"deleteBindingDescription": "Only platform route bindings are deleted. The upstream model catalog remains as unbound rows.",
"deleteBindingDescription": "Only platform route bindings are deleted. The upstream model catalog is kept and can be rebound by syncing.",
"savedChanges": "Saved {savedCount} changes",
"manageTitle": "Manage route bindings",
"manageDescription": "Manage route bindings from upstream models to platform model names.",
"manageDescription": "Manage configured route bindings. Rebind models through sync when needed.",
"manageSearchPlaceholder": "Search upstream model, platform model, binding code, or protocol",
"refreshBindings": "Refresh bindings",
"allRoutes": "All",
Expand All @@ -215,7 +214,7 @@
"noMatchedBindings": "No matching route bindings",
"noBindings": "No route bindings. Sync models or create a binding manually.",
"batchDeleteTitle": "Delete bindings",
"batchDeleteDescription": "Delete {count} selected bindings from this upstream. The model catalog is kept.",
"batchDeleteDescription": "Delete {count} selected bindings from this upstream. The model catalog is kept and can be rebound by syncing.",
"bulkConfirmTitle": "Confirm bulk update",
"bulkConfirmDescription": "Apply this bulk update to {count} selected bindings.",
"bulkConfirmApply": "Apply",
Expand Down
13 changes: 6 additions & 7 deletions frontend/i18n/messages/zh-CN/admin-channels.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,17 @@
"creating": "创建中…",
"saving": "保存中…"
},
"modelsDialog": {
"modelsDialog": {
"selectKind": "选择类别",
"selectModel": "选择 {name}",
"unbound": "未绑定",
"routeStatusFor": "{name} 状态",
"platformModelName": "平台模型名",
"platformModel": "平台模型",
"deleteAfterSave": "保存后删除",
"autoProtocol": "自动匹配",
"remoteStatus": {
"bound": "已绑定",
"synced": "已同步",
"unbound": "未绑定",
"unsynced": "未同步"
},
"remoteLoadFailed": "获取远端模型失败",
Expand All @@ -183,18 +182,18 @@
"loadFailed": "加载失败",
"batchDeletePartialFailed": "批量删除绑定部分失败",
"batchDeleteDone": "批量删除绑定完成",
"batchDeleteSummary": "已删除绑定 {successCount} 条,未找到 {notFoundCount} 条,失败 {failedCount} 条;上游模型目录会保留。",
"batchDeleteSummary": "已删除绑定 {successCount} 条,未找到 {notFoundCount} 条,失败 {failedCount} 条;上游模型目录会保留,可通过同步重新创建绑定。",
"noPendingChanges": "没有待保存的修改",
"upstreamModelRequired": "上游真实模型名不能为空",
"activeRouteRequiresPlatformModel": "启用路由必须填写平台模型名;如果要解除绑定,请先关闭路由后保存。",
"duplicateBinding": "上游模型「{upstreamModelName}」已绑定平台模型「{platformModelName}」",
"noSavableChanges": "没有可保存的绑定变更",
"savedAndDeleted": "已保存 {savedCount} 处修改,删除 {deletedCount} 条绑定",
"deletedBindings": "已删除 {deletedCount} 条绑定",
"deleteBindingDescription": "仅删除平台路由关系,上游模型目录会保留为未绑定行。",
"deleteBindingDescription": "仅删除平台路由关系,上游模型目录会保留,可通过同步重新创建绑定。",
"savedChanges": "已保存 {savedCount} 处修改",
"manageTitle": "管理路由绑定",
"manageDescription": "管理上游模型到平台模型名的路由绑定。",
"manageDescription": "管理已配置的路由绑定;需要重新绑定的模型请通过同步添加。",
"manageSearchPlaceholder": "搜索上游模型名、平台模型名、绑定编码或协议",
"refreshBindings": "刷新绑定",
"allRoutes": "全部",
Expand All @@ -215,7 +214,7 @@
"noMatchedBindings": "没有匹配的路由绑定",
"noBindings": "暂无任何路由绑定,请同步模型或手动新增绑定",
"batchDeleteTitle": "批量删除绑定",
"batchDeleteDescription": "将删除当前上游下已选的 {count} 条绑定关系,但会保留模型目录。",
"batchDeleteDescription": "将删除当前上游下已选的 {count} 条绑定关系,但会保留模型目录。后续可通过同步重新创建绑定。",
"bulkConfirmTitle": "确认批量修改",
"bulkConfirmDescription": "将对已选的 {count} 条绑定应用此批量修改。",
"bulkConfirmApply": "确认应用",
Expand Down
30 changes: 30 additions & 0 deletions frontend/i18n/resolve-error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,54 @@ type RequestBodyErrorDetails = {

const REQUEST_FIELD_LABELS: Record<AppLocale, Record<string, string>> = {
"en-US": {
apiKeys: "API keys",
avatarURL: "Avatar URL",
baseURL: "Base URL",
cbDurationMin: "Circuit duration",
cbFailureThreshold: "Failure threshold",
cbModelThreshold: "Model threshold",
cbThresholdLogic: "Threshold logic",
cbWindowMin: "Circuit window",
compatible: "Compatibility mode",
connectTimeoutMS: "Connect timeout",
displayName: "Display name",
email: "Email",
headersJSON: "Headers JSON",
locale: "Language",
name: "Name",
password: "Password",
phone: "Phone",
protocolDefaultsJSON: "Protocol defaults JSON",
readTimeoutMS: "Read timeout",
status: "Status",
streamIdleTimeoutMS: "Stream idle timeout",
subscriptionExpiresAt: "Subscription expiry",
subscriptionTier: "Subscription plan",
timezone: "Timezone",
username: "Username",
},
"zh-CN": {
apiKeys: "API Keys",
avatarURL: "头像地址",
baseURL: "Base URL",
cbDurationMin: "熔断时长",
cbFailureThreshold: "失败阈值",
cbModelThreshold: "模型阈值",
cbThresholdLogic: "阈值逻辑",
cbWindowMin: "统计窗口",
compatible: "兼容模式",
connectTimeoutMS: "连接超时",
displayName: "昵称",
email: "邮箱",
headersJSON: "请求头 JSON",
locale: "语言",
name: "名称",
password: "密码",
phone: "手机号",
protocolDefaultsJSON: "协议默认配置 JSON",
readTimeoutMS: "读取超时",
status: "状态",
streamIdleTimeoutMS: "流式空闲超时",
subscriptionExpiresAt: "订阅到期时间",
subscriptionTier: "订阅方案",
timezone: "时区",
Expand Down
4 changes: 4 additions & 0 deletions frontend/i18n/use-localized-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export function useLocalizedErrorMessage() {
return React.useCallback(
(error: unknown, fallback?: string) => {
if (error instanceof ApiError && error.errorCode) {
if (error.errorCode === "request.invalid_body") {
return resolveLocalizedErrorMessage(error, fallback || common("unknown"));
}

const key = toMessageKey(error.errorCode);
try {
const translated = errors(key);
Expand Down
Loading