mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
feat(cliproxy): expose request retry config
The generated CLIProxy config pinned request-retry and max-retry-interval to 0 with no way to change them. Add an opt-in config.cliproxy.retry block (schema validation, generator wiring, dashboard inputs beside the routing controls, i18n x5). Unset config keeps the generated output byte-identical; max-retry-interval unit confirmed as seconds against upstream CLIProxyAPI.
This commit is contained in:
1 parent
51e8716630
commit
d5ea4eeea3
7 files changed
+433
-2
No files matched your search
@@ -7,9 +7,15 @@ import type {
|
||||
RoutingStrategy,
|
||||
CliproxySessionAffinityState,
|
||||
} from '@/lib/api-client';
|
||||
import { useCliproxyRetryConfig, useUpdateCliproxyRetryConfig } from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Retry fields accept only non-negative integers (matches the CLIProxy schema bounds). */
|
||||
function isValidRetryFieldValue(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
interface RoutingGuidanceCardProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
@@ -73,6 +79,17 @@ export function RoutingGuidanceCard({
|
||||
const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
|
||||
const suppressNextAffinityBlurRef = useRef(false);
|
||||
|
||||
const retryConfigQuery = useCliproxyRetryConfig();
|
||||
const updateRetryConfig = useUpdateCliproxyRetryConfig();
|
||||
const currentRequestRetry = retryConfigQuery.data?.request_retry ?? 0;
|
||||
const currentMaxRetryInterval = retryConfigQuery.data?.max_retry_interval ?? 0;
|
||||
const [requestRetryInput, setRequestRetryInput] = useState(String(currentRequestRetry));
|
||||
const [maxRetryIntervalInput, setMaxRetryIntervalInput] = useState(
|
||||
String(currentMaxRetryInterval)
|
||||
);
|
||||
const [retryFieldError, setRetryFieldError] = useState<string | null>(null);
|
||||
const retryControlDisabled = retryConfigQuery.isLoading || updateRetryConfig.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(currentStrategy);
|
||||
}, [currentStrategy]);
|
||||
@@ -82,6 +99,11 @@ export function RoutingGuidanceCard({
|
||||
setSelectedAffinityTtl(currentAffinityTtl);
|
||||
}, [currentAffinityEnabled, currentAffinityTtl]);
|
||||
|
||||
useEffect(() => {
|
||||
setRequestRetryInput(String(currentRequestRetry));
|
||||
setMaxRetryIntervalInput(String(currentMaxRetryInterval));
|
||||
}, [currentRequestRetry, currentMaxRetryInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaving || !pendingAffinityRef.current) {
|
||||
return;
|
||||
@@ -122,6 +144,34 @@ export function RoutingGuidanceCard({
|
||||
onApplyAffinity({ enabled: selectedAffinityEnabled, ttl: nextTtl });
|
||||
};
|
||||
|
||||
const handleRetryBlur = () => {
|
||||
const nextRequestRetry = Number(requestRetryInput.trim());
|
||||
const nextMaxRetryInterval = Number(maxRetryIntervalInput.trim());
|
||||
|
||||
if (
|
||||
!isValidRetryFieldValue(nextRequestRetry) ||
|
||||
!isValidRetryFieldValue(nextMaxRetryInterval)
|
||||
) {
|
||||
setRetryFieldError(t('routingGuidance.retryRangeError'));
|
||||
setRequestRetryInput(String(currentRequestRetry));
|
||||
setMaxRetryIntervalInput(String(currentMaxRetryInterval));
|
||||
return;
|
||||
}
|
||||
|
||||
setRetryFieldError(null);
|
||||
if (
|
||||
nextRequestRetry === currentRequestRetry &&
|
||||
nextMaxRetryInterval === currentMaxRetryInterval
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateRetryConfig.mutate({
|
||||
request_retry: nextRequestRetry,
|
||||
max_retry_interval: nextMaxRetryInterval,
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
const handleApply = (s: RoutingStrategy) => {
|
||||
setSelected(s);
|
||||
@@ -274,6 +324,44 @@ export function RoutingGuidanceCard({
|
||||
{sessionAffinityState.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground">
|
||||
{t('routingGuidance.retryTitle')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('routingGuidance.retryHint')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
aria-label={t('routingGuidance.requestRetryLabel')}
|
||||
inputMode="numeric"
|
||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||
value={requestRetryInput}
|
||||
onChange={(event) => setRequestRetryInput(event.target.value.replace(/\D/g, ''))}
|
||||
onBlur={handleRetryBlur}
|
||||
disabled={retryControlDisabled}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">/</span>
|
||||
<input
|
||||
aria-label={t('routingGuidance.maxRetryIntervalLabel')}
|
||||
inputMode="numeric"
|
||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||
value={maxRetryIntervalInput}
|
||||
onChange={(event) => setMaxRetryIntervalInput(event.target.value.replace(/\D/g, ''))}
|
||||
onBlur={handleRetryBlur}
|
||||
disabled={retryControlDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{retryFieldError ? (
|
||||
<div className="rounded-lg border border-destructive/25 bg-destructive/5 px-2 py-1.5 text-[10px] text-destructive">
|
||||
{retryFieldError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,64 @@ export function useUpdateCliproxySessionAffinity() {
|
||||
});
|
||||
}
|
||||
|
||||
/** CLIProxy request-retry config (config.cliproxy.retry). Defaults to disabled (0/0). */
|
||||
export interface CliproxyRetryConfig {
|
||||
request_retry: number;
|
||||
max_retry_interval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CLIPROXY_RETRY_CONFIG: CliproxyRetryConfig = {
|
||||
request_retry: 0,
|
||||
max_retry_interval: 0,
|
||||
};
|
||||
|
||||
export function useCliproxyRetryConfig() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-retry-config'],
|
||||
queryFn: async (): Promise<CliproxyRetryConfig> => {
|
||||
const config = await api.config.get();
|
||||
const cliproxy = config.cliproxy as { retry?: Partial<CliproxyRetryConfig> } | undefined;
|
||||
return {
|
||||
request_retry:
|
||||
cliproxy?.retry?.request_retry ?? DEFAULT_CLIPROXY_RETRY_CONFIG.request_retry,
|
||||
max_retry_interval:
|
||||
cliproxy?.retry?.max_retry_interval ?? DEFAULT_CLIPROXY_RETRY_CONFIG.max_retry_interval,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CLIProxy retry config. Reuses the generic unified-config save path
|
||||
* (PUT /config) — retry is a static opt-in default, not a live-proxy setting
|
||||
* like routing.strategy/session_affinity, so it does not need a dedicated
|
||||
* management-API round trip.
|
||||
*/
|
||||
export function useUpdateCliproxyRetryConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (retry: CliproxyRetryConfig): Promise<CliproxyRetryConfig> => {
|
||||
const config = await api.config.get();
|
||||
const existingCliproxy = (config.cliproxy ?? {}) as Record<string, unknown>;
|
||||
await api.config.update({
|
||||
...config,
|
||||
cliproxy: { ...existingCliproxy, retry },
|
||||
});
|
||||
return retry;
|
||||
},
|
||||
onSuccess: (retry) => {
|
||||
queryClient.setQueryData(['cliproxy-retry-config'], retry);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-retry-config'] });
|
||||
toast.success(t('toasts.cliproxyRetryUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1954,6 +1954,11 @@ const resources = {
|
||||
sessionRecognitionTitle: 'Session recognition',
|
||||
sessionRecognitionDescription:
|
||||
'CCS does not promise one universal precedence order here. In practice, upstream backends prefer explicit session or thread IDs first, then fall back to metadata fields and finally a hash based on the opening prompt history.',
|
||||
retryTitle: 'Auto-retry',
|
||||
retryHint: 'Retries / max wait (s)',
|
||||
requestRetryLabel: 'Request retry count',
|
||||
maxRetryIntervalLabel: 'Max retry interval (seconds)',
|
||||
retryRangeError: 'Must be a whole number, 0 or greater.',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: 'Extended Context',
|
||||
@@ -2347,6 +2352,7 @@ const resources = {
|
||||
noProfilesToSync: 'No profiles to sync',
|
||||
syncFailed: 'Sync failed: {{error}}',
|
||||
sessionAffinityUpdated: 'Session affinity {{state}}.',
|
||||
cliproxyRetryUpdated: 'Retry settings updated.',
|
||||
providerAuthSuccess: '{{provider}} authentication successful',
|
||||
providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode',
|
||||
providerAuthTimeout: 'Authentication timed out. Please try again.',
|
||||
@@ -4587,6 +4593,11 @@ const resources = {
|
||||
sessionRecognitionTitle: '会话识别',
|
||||
sessionRecognitionDescription:
|
||||
'CCS 不承诺所有后端都使用同一优先级顺序。通常上游会优先使用显式会话或线程 ID,然后回退到元数据字段,最后再回退到基于开场提示历史的哈希。',
|
||||
retryTitle: '自动重试',
|
||||
retryHint: '重试次数 / 最大等待(秒)',
|
||||
requestRetryLabel: '重试次数',
|
||||
maxRetryIntervalLabel: '最大重试间隔(秒)',
|
||||
retryRangeError: '必须是 0 或更大的整数。',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: '扩展上下文',
|
||||
@@ -4963,6 +4974,7 @@ const resources = {
|
||||
noProfilesToSync: '没有可同步的配置',
|
||||
syncFailed: '同步失败:{{error}}',
|
||||
sessionAffinityUpdated: '会话粘性已{{state}}。',
|
||||
cliproxyRetryUpdated: '重试设置已更新。',
|
||||
providerAuthSuccess: '{{provider}} 认证成功',
|
||||
providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程',
|
||||
providerAuthTimeout: '认证超时,请重试。',
|
||||
@@ -7295,6 +7307,11 @@ const resources = {
|
||||
sessionRecognitionTitle: 'Nhận diện phiên',
|
||||
sessionRecognitionDescription:
|
||||
'CCS không cam kết một thứ tự ưu tiên chung cho mọi backend. Trên thực tế, backend upstream thường ưu tiên session hoặc thread id tường minh, rồi mới fallback sang metadata và cuối cùng là hàm băm của lịch sử prompt mở đầu.',
|
||||
retryTitle: 'Tự động thử lại',
|
||||
retryHint: 'Số lần thử lại / chờ tối đa (giây)',
|
||||
requestRetryLabel: 'Số lần thử lại yêu cầu',
|
||||
maxRetryIntervalLabel: 'Khoảng chờ thử lại tối đa (giây)',
|
||||
retryRangeError: 'Phải là số nguyên từ 0 trở lên.',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: 'Ngữ cảnh mở rộng',
|
||||
@@ -7676,6 +7693,7 @@ const resources = {
|
||||
noProfilesToSync: 'Không có hồ sơ để đồng bộ',
|
||||
syncFailed: 'Đồng bộ thất bại: {{error}}',
|
||||
sessionAffinityUpdated: 'Ghim phiên đã {{state}}.',
|
||||
cliproxyRetryUpdated: 'Đã cập nhật cài đặt thử lại.',
|
||||
providerAuthSuccess: 'Xác thực {{provider}} thành công',
|
||||
providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback',
|
||||
providerAuthTimeout: 'Đã hết thời gian xác thực. Vui lòng thử lại.',
|
||||
@@ -10475,6 +10493,11 @@ const resources = {
|
||||
sessionRecognitionTitle: 'セッション認識',
|
||||
sessionRecognitionDescription:
|
||||
'CCS はすべてのバックエンドで同一の優先順位を保証しません。一般に上流バックエンドは明示的なセッション / スレッド ID を優先し、その後にメタデータ、最後に冒頭プロンプト履歴のハッシュへフォールバックします。',
|
||||
retryTitle: '自動リトライ',
|
||||
retryHint: 'リトライ回数 / 最大待機(秒)',
|
||||
requestRetryLabel: 'リトライ回数',
|
||||
maxRetryIntervalLabel: '最大リトライ間隔(秒)',
|
||||
retryRangeError: '0 以上の整数を入力してください。',
|
||||
},
|
||||
settingsDialog: {
|
||||
editProfile: 'プロファイルを編集: {{name}}',
|
||||
@@ -10706,6 +10729,7 @@ const resources = {
|
||||
noProfilesToSync: '同期するプロファイルがありません',
|
||||
syncFailed: '同期に失敗しました: {{error}}',
|
||||
sessionAffinityUpdated: 'セッション固定を{{state}}にしました。',
|
||||
cliproxyRetryUpdated: '再試行設定を更新しました。',
|
||||
providerAuthSuccess: '{{provider}} の認証に成功しました',
|
||||
providerDeviceCodeInCallback:
|
||||
'コールバックモードでプロバイダーがデバイスコードフローを返しました',
|
||||
@@ -12762,6 +12786,11 @@ const resources = {
|
||||
sessionRecognitionTitle: '세션 인식',
|
||||
sessionRecognitionDescription:
|
||||
'CCS는 여기서 보편적인 우선순위를 약속하지 않습니다. 실제로 업스트림 백엔드는 명시적 세션 또는 스레드 ID를 먼저 선호한 다음, 메타데이터 필드로 폴백하고 최종적으로 시작 프롬프트 기록 기반 해시로 폴백합니다.',
|
||||
retryTitle: '자동 재시도',
|
||||
retryHint: '재시도 횟수 / 최대 대기(초)',
|
||||
requestRetryLabel: '재시도 횟수',
|
||||
maxRetryIntervalLabel: '최대 재시도 간격(초)',
|
||||
retryRangeError: '0 이상의 정수여야 합니다.',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: '확장 컨텍스트',
|
||||
@@ -13158,6 +13187,7 @@ const resources = {
|
||||
noProfilesToSync: '동기화할 프로필이 없습니다',
|
||||
syncFailed: '동기화 실패: {{error}}',
|
||||
sessionAffinityUpdated: '세션 어피니티 {{state}}.',
|
||||
cliproxyRetryUpdated: '재시도 설정이 업데이트되었습니다.',
|
||||
providerAuthSuccess: '{{provider}} 인증 성공',
|
||||
providerDeviceCodeInCallback:
|
||||
'프로바이더가 콜백 모드에서 디바이스 코드 흐름을 반환했습니다',
|
||||
|
||||
Reference in new issue
Block a user