mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(cliproxy): harden session affinity behavior
This commit is contained in:
@@ -141,7 +141,19 @@ function getSessionAffinityEnabled(): boolean {
|
||||
|
||||
function getSessionAffinityTtl(): string {
|
||||
const ttl = loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity_ttl?.trim();
|
||||
return ttl && GO_DURATION_PATTERN.test(ttl) ? ttl : '1h';
|
||||
return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h';
|
||||
}
|
||||
|
||||
function hasPositiveDuration(value: string): boolean {
|
||||
const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g'));
|
||||
if (!segments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return segments.some((segment) => {
|
||||
const numeric = parseFloat(segment);
|
||||
return Number.isFinite(numeric) && numeric > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeYamlScalar(rawValue: string): string {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function normalizeCliproxySessionAffinityTtl(value: unknown): string | nu
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) {
|
||||
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -176,18 +176,20 @@ export async function readCliproxySessionAffinityState(): Promise<CliproxySessio
|
||||
const target = getCliproxyRoutingTarget();
|
||||
|
||||
if (target.isRemote) {
|
||||
const reachable = await isLiveCliproxyRoutingReachable();
|
||||
return {
|
||||
source: 'unsupported',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
reachable,
|
||||
manageable: false,
|
||||
message:
|
||||
'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.',
|
||||
message: reachable
|
||||
? 'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.'
|
||||
: 'Remote session-affinity management is not supported from CCS yet, and the remote CLIProxy routing endpoint is not reachable.',
|
||||
};
|
||||
}
|
||||
|
||||
const settings = getConfiguredCliproxySessionAffinitySettings();
|
||||
const reachable = await isLocalCliproxyReachable();
|
||||
const reachable = await isLiveCliproxyRoutingReachable();
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
@@ -197,7 +199,7 @@ export async function readCliproxySessionAffinityState(): Promise<CliproxySessio
|
||||
reachable,
|
||||
manageable: true,
|
||||
message: reachable
|
||||
? 'CCS manages session affinity through the generated local CLIProxy config. Running local CLIProxy should hot-reload this setting.'
|
||||
? 'Showing the saved local session-affinity setting. Running local CLIProxy may hot-reload config changes, but CCS does not verify live selector state.'
|
||||
: 'Local CLIProxy is not reachable. Showing the saved local startup default.',
|
||||
};
|
||||
}
|
||||
@@ -255,14 +257,16 @@ export async function applyCliproxySessionAffinitySettings(
|
||||
): Promise<CliproxySessionAffinityApplyResult> {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
if (target.isRemote) {
|
||||
const reachable = await isLiveCliproxyRoutingReachable();
|
||||
return {
|
||||
source: 'unsupported',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
reachable,
|
||||
manageable: false,
|
||||
applied: 'unsupported',
|
||||
message:
|
||||
'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.',
|
||||
message: reachable
|
||||
? 'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.'
|
||||
: 'Remote session-affinity management is not supported from CCS yet, and the remote CLIProxy routing endpoint is not reachable.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,7 +289,7 @@ export async function applyCliproxySessionAffinitySettings(
|
||||
});
|
||||
regenerateConfig(target.port, { configPath, authDir });
|
||||
|
||||
const reachable = await isLocalCliproxyReachable();
|
||||
const reachable = await isLiveCliproxyRoutingReachable();
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
ttl,
|
||||
@@ -314,7 +318,19 @@ async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrate
|
||||
}
|
||||
}
|
||||
|
||||
async function isLocalCliproxyReachable(): Promise<boolean> {
|
||||
function hasPositiveDuration(value: string): boolean {
|
||||
const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g'));
|
||||
if (!segments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return segments.some((segment) => {
|
||||
const numeric = parseFloat(segment);
|
||||
return Number.isFinite(numeric) && numeric > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function isLiveCliproxyRoutingReachable(): Promise<boolean> {
|
||||
try {
|
||||
await fetchLiveCliproxyRoutingStrategy();
|
||||
return true;
|
||||
|
||||
@@ -157,6 +157,9 @@ export async function handleRoutingAffinityStatus(): Promise<void> {
|
||||
} else {
|
||||
console.log(` Status: ${color(state.enabled ? 'on' : 'off', 'command')}`);
|
||||
}
|
||||
console.log(
|
||||
` Source: ${state.manageable ? color('saved local setting', 'info') : color('unsupported', 'warning')}`
|
||||
);
|
||||
console.log(` Target: ${color(state.target, 'info')}`);
|
||||
if (state.ttl) {
|
||||
console.log(` TTL: ${color(state.ttl, 'info')}`);
|
||||
@@ -228,6 +231,7 @@ export async function handleRoutingAffinitySet(args: string[]): Promise<void> {
|
||||
if (!result.manageable) {
|
||||
console.log(fail(result.message || 'Session affinity is not supported for this target.'));
|
||||
console.log('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,13 +120,25 @@ function normalizeSessionAffinityTtl(value: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) {
|
||||
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function hasPositiveDuration(value: string): boolean {
|
||||
const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g'));
|
||||
if (!segments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return segments.some((segment) => {
|
||||
const numeric = parseFloat(segment);
|
||||
return Number.isFinite(numeric) && numeric > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to unified config.yaml
|
||||
*/
|
||||
|
||||
@@ -68,12 +68,17 @@ router.put('/routing/session-affinity', async (req: Request, res: Response): Pro
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(
|
||||
await applyCliproxySessionAffinitySettings({
|
||||
enabled,
|
||||
ttl: normalizedTtl,
|
||||
})
|
||||
);
|
||||
const result = await applyCliproxySessionAffinitySettings({
|
||||
enabled,
|
||||
ttl: normalizedTtl,
|
||||
});
|
||||
if (!result.manageable || result.applied === 'unsupported') {
|
||||
res.status(400).json({
|
||||
error: result.message || 'Session affinity is not supported for this target.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('1h')).toBe('1h');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('2h30m')).toBe('2h30m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl(' 15m ')).toBe('15m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('0s')).toBeNull();
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('tomorrow')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -241,12 +242,19 @@ describe('cliproxy routing strategy service', () => {
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.target).toBe('remote');
|
||||
expect(state.manageable).toBe(false);
|
||||
expect(state.reachable).toBe(true);
|
||||
expect(state.enabled).toBeUndefined();
|
||||
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
@@ -258,4 +266,23 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(result.manageable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unsupported remote session-affinity as unreachable when remote routing probe fails', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
responseFactory = null;
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.reachable).toBe(false);
|
||||
expect(state.message).toContain('not reachable');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,6 +318,39 @@ describe('cliproxy session-affinity ttl normalization', () => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes non-positive stored ttl values back to the safe default', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-zero-ttl-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'cliproxy:',
|
||||
' routing:',
|
||||
' strategy: round-robin',
|
||||
' session_affinity: true',
|
||||
' session_affinity_ttl: 0s',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('official-channels-config', () => {
|
||||
|
||||
@@ -59,10 +59,11 @@ export function RoutingGuidanceCard({
|
||||
const affinityControlDisabled = isLoading || isSaving || !!error || !sessionAffinityManageable;
|
||||
const affinityActionLabel = sessionAffinityManageable
|
||||
? selectedAffinityEnabled
|
||||
? 'Disable session affinity'
|
||||
: 'Enable session affinity'
|
||||
: 'Session affinity unavailable';
|
||||
? t('routingGuidance.disableSessionAffinity')
|
||||
: t('routingGuidance.enableSessionAffinity')
|
||||
: t('routingGuidance.sessionAffinityUnavailable');
|
||||
const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
|
||||
const suppressNextAffinityBlurRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(currentStrategy);
|
||||
@@ -101,6 +102,10 @@ export function RoutingGuidanceCard({
|
||||
|
||||
const handleAffinityTtlBlur = () => {
|
||||
if (!sessionAffinityManageable || !!error) return;
|
||||
if (suppressNextAffinityBlurRef.current) {
|
||||
suppressNextAffinityBlurRef.current = false;
|
||||
return;
|
||||
}
|
||||
const nextTtl = selectedAffinityTtl.trim() || '1h';
|
||||
if (nextTtl === currentAffinityTtl) {
|
||||
return;
|
||||
@@ -169,9 +174,13 @@ export function RoutingGuidanceCard({
|
||||
|
||||
<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">Session affinity</div>
|
||||
<div className="text-[10px] font-medium text-foreground">
|
||||
{t('routingGuidance.sessionAffinity')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{sessionAffinityManageable ? `TTL ${currentAffinityTtl}` : 'Local-only setting'}
|
||||
{sessionAffinityManageable
|
||||
? t('routingGuidance.ttlBadge', { ttl: currentAffinityTtl })
|
||||
: t('routingGuidance.localOnlySetting')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -194,11 +203,18 @@ export function RoutingGuidanceCard({
|
||||
? 'border-border/70 bg-background text-foreground hover:border-primary/40 hover:text-primary'
|
||||
: 'border-border/60 bg-muted/40 text-muted-foreground'
|
||||
)}
|
||||
onMouseDown={() => {
|
||||
suppressNextAffinityBlurRef.current = true;
|
||||
}}
|
||||
onClick={handleAffinityToggle}
|
||||
disabled={affinityControlDisabled}
|
||||
title={sessionAffinityState?.message}
|
||||
>
|
||||
{sessionAffinityManageable ? (selectedAffinityEnabled ? 'On' : 'Off') : 'Unavailable'}
|
||||
{sessionAffinityManageable
|
||||
? selectedAffinityEnabled
|
||||
? t('routingGuidance.sessionAffinityOn')
|
||||
: t('routingGuidance.sessionAffinityOff')
|
||||
: t('routingGuidance.sessionAffinityUnavailable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,17 +303,23 @@ export function RoutingGuidanceCard({
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 xl:col-span-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium">Session affinity</div>
|
||||
<Badge variant="secondary">{selectedAffinityEnabled ? 'on' : 'off'}</Badge>
|
||||
<div className="text-sm font-medium">{t('routingGuidance.sessionAffinity')}</div>
|
||||
<Badge variant="secondary">
|
||||
{selectedAffinityEnabled
|
||||
? t('routingGuidance.sessionAffinityOn')
|
||||
: t('routingGuidance.sessionAffinityOff')}
|
||||
</Badge>
|
||||
{sessionAffinityState?.ttl ? (
|
||||
<Badge variant="outline">TTL {currentAffinityTtl}</Badge>
|
||||
<Badge variant="outline">
|
||||
{t('routingGuidance.ttlBadge', { ttl: currentAffinityTtl })}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!sessionAffinityManageable ? (
|
||||
<Badge variant="outline">{t('routingGuidance.localOnly')}</Badge>
|
||||
) : null}
|
||||
{!sessionAffinityManageable ? <Badge variant="outline">Local only</Badge> : null}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Keep one conversation pinned to the same account when possible. CLIProxy prefers
|
||||
explicit session or thread identifiers when clients send them, then falls back to
|
||||
request metadata or the opening prompt history when it has to infer a stable key.
|
||||
{t('routingGuidance.sessionAffinityDescription')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
@@ -311,15 +333,14 @@ export function RoutingGuidanceCard({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onMouseDown={() => {
|
||||
suppressNextAffinityBlurRef.current = true;
|
||||
}}
|
||||
onClick={handleAffinityToggle}
|
||||
disabled={affinityControlDisabled}
|
||||
aria-label={affinityActionLabel}
|
||||
>
|
||||
{sessionAffinityManageable
|
||||
? selectedAffinityEnabled
|
||||
? 'Disable session affinity'
|
||||
: 'Enable session affinity'
|
||||
: 'Session affinity unavailable'}
|
||||
{affinityActionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionAffinityState?.message ? (
|
||||
@@ -359,11 +380,11 @@ export function RoutingGuidanceCard({
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className="text-sm font-medium">Session recognition order</div>
|
||||
<div className="text-sm font-medium">
|
||||
{t('routingGuidance.sessionRecognitionTitle')}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
{t('routingGuidance.sessionRecognitionDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +85,7 @@ export function useCliproxySessionAffinity() {
|
||||
|
||||
export function useUpdateCliproxySessionAffinity() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { enabled: boolean; ttl?: string }) =>
|
||||
@@ -92,8 +93,12 @@ export function useUpdateCliproxySessionAffinity() {
|
||||
onSuccess: (result: CliproxySessionAffinityApplyResult) => {
|
||||
queryClient.setQueryData(['cliproxy-session-affinity'], result);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] });
|
||||
const label = result.enabled ? 'enabled' : 'disabled';
|
||||
toast.success(result.message || `Session affinity ${label}.`);
|
||||
const stateLabel = result.enabled
|
||||
? t('routingGuidance.sessionAffinityEnabled')
|
||||
: t('routingGuidance.sessionAffinityDisabled');
|
||||
toast.success(
|
||||
result.message || t('toasts.sessionAffinityUpdated', { state: stateLabel.toLowerCase() })
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -1820,6 +1820,22 @@ const resources = {
|
||||
fillFirst: 'Fill first keeps backup accounts cold until they are needed.',
|
||||
routingStrategy: 'Routing strategy',
|
||||
optionalRouting: 'Optional routing',
|
||||
sessionAffinity: 'Session affinity',
|
||||
sessionAffinityOn: 'On',
|
||||
sessionAffinityOff: 'Off',
|
||||
sessionAffinityEnabled: 'Enabled',
|
||||
sessionAffinityDisabled: 'Disabled',
|
||||
enableSessionAffinity: 'Enable session affinity',
|
||||
disableSessionAffinity: 'Disable session affinity',
|
||||
sessionAffinityUnavailable: 'Session affinity unavailable',
|
||||
localOnly: 'Local only',
|
||||
localOnlySetting: 'Local-only setting',
|
||||
ttlBadge: 'TTL {{ttl}}',
|
||||
sessionAffinityDescription:
|
||||
'Keep one conversation pinned to the same account when possible. CLIProxy prefers explicit session or thread identifiers when clients send them, then falls back to request metadata or the opening prompt history when it has to infer a stable key.',
|
||||
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.',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: 'Extended Context',
|
||||
@@ -2170,6 +2186,7 @@ const resources = {
|
||||
accountsUpdated: 'Accounts updated',
|
||||
noProfilesToSync: 'No profiles to sync',
|
||||
syncFailed: 'Sync failed: {{error}}',
|
||||
sessionAffinityUpdated: 'Session affinity {{state}}.',
|
||||
providerAuthSuccess: '{{provider}} authentication successful',
|
||||
providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode',
|
||||
providerAuthTimeout: 'Authentication timed out. Please try again.',
|
||||
@@ -4283,6 +4300,22 @@ const resources = {
|
||||
fillFirst: '优先填满模式让备用账号保持冷启动直到需要时。',
|
||||
routingStrategy: '路由策略',
|
||||
optionalRouting: '可选路由',
|
||||
sessionAffinity: '会话粘性',
|
||||
sessionAffinityOn: '开启',
|
||||
sessionAffinityOff: '关闭',
|
||||
sessionAffinityEnabled: '已启用',
|
||||
sessionAffinityDisabled: '已禁用',
|
||||
enableSessionAffinity: '启用会话粘性',
|
||||
disableSessionAffinity: '禁用会话粘性',
|
||||
sessionAffinityUnavailable: '会话粘性不可用',
|
||||
localOnly: '仅本地',
|
||||
localOnlySetting: '仅限本地设置',
|
||||
ttlBadge: 'TTL {{ttl}}',
|
||||
sessionAffinityDescription:
|
||||
'尽量将同一对话固定到同一个账号。CLIProxy 会优先使用客户端显式提供的会话或线程标识;如果没有,再回退到请求元数据或开场提示历史来推断稳定键。',
|
||||
sessionRecognitionTitle: '会话识别',
|
||||
sessionRecognitionDescription:
|
||||
'CCS 不承诺所有后端都使用同一优先级顺序。通常上游会优先使用显式会话或线程 ID,然后回退到元数据字段,最后再回退到基于开场提示历史的哈希。',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: '扩展上下文',
|
||||
@@ -4618,6 +4651,7 @@ const resources = {
|
||||
accountsUpdated: '账号已更新',
|
||||
noProfilesToSync: '没有可同步的配置',
|
||||
syncFailed: '同步失败:{{error}}',
|
||||
sessionAffinityUpdated: '会话粘性已{{state}}。',
|
||||
providerAuthSuccess: '{{provider}} 认证成功',
|
||||
providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程',
|
||||
providerAuthTimeout: '认证超时,请重试。',
|
||||
@@ -6816,6 +6850,22 @@ const resources = {
|
||||
fillFirst: 'Fill-first giữ tài khoản dự phòng cho đến khi cần thiết.',
|
||||
routingStrategy: 'Chiến lược định tuyến',
|
||||
optionalRouting: 'Định tuyến tùy chọn',
|
||||
sessionAffinity: 'Ghim phiên',
|
||||
sessionAffinityOn: 'Bật',
|
||||
sessionAffinityOff: 'Tắt',
|
||||
sessionAffinityEnabled: 'bật',
|
||||
sessionAffinityDisabled: 'tắt',
|
||||
enableSessionAffinity: 'Bật ghim phiên',
|
||||
disableSessionAffinity: 'Tắt ghim phiên',
|
||||
sessionAffinityUnavailable: 'Ghim phiên không khả dụng',
|
||||
localOnly: 'Chỉ cục bộ',
|
||||
localOnlySetting: 'Thiết lập chỉ cục bộ',
|
||||
ttlBadge: 'TTL {{ttl}}',
|
||||
sessionAffinityDescription:
|
||||
'Giữ một cuộc hội thoại trên cùng một tài khoản khi có thể. CLIProxy ưu tiên mã phiên hoặc luồng mà client gửi rõ ràng; nếu không có, nó sẽ dùng metadata của request hoặc lịch sử prompt mở đầu để suy ra khóa ổn định.',
|
||||
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.',
|
||||
},
|
||||
extendedContext: {
|
||||
extendedContext: 'Ngữ cảnh mở rộng',
|
||||
@@ -7154,6 +7204,7 @@ const resources = {
|
||||
accountsUpdated: 'Tài khoản đã được cập nhật',
|
||||
noProfilesToSync: 'Không có hồ sơ để đồng bộ',
|
||||
syncFailed: 'Đồng bộ thất bại: {{error}}',
|
||||
sessionAffinityUpdated: 'Ghim phiên đã {{state}}.',
|
||||
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.',
|
||||
@@ -9778,6 +9829,22 @@ const resources = {
|
||||
fillFirst: 'Fill first は、バックアップアカウントが必要になるまで待機させます。',
|
||||
routingStrategy: 'ルーティング戦略',
|
||||
optionalRouting: 'オプションのルーティング',
|
||||
sessionAffinity: 'セッション固定',
|
||||
sessionAffinityOn: 'オン',
|
||||
sessionAffinityOff: 'オフ',
|
||||
sessionAffinityEnabled: '有効',
|
||||
sessionAffinityDisabled: '無効',
|
||||
enableSessionAffinity: 'セッション固定を有効化',
|
||||
disableSessionAffinity: 'セッション固定を無効化',
|
||||
sessionAffinityUnavailable: 'セッション固定は利用できません',
|
||||
localOnly: 'ローカルのみ',
|
||||
localOnlySetting: 'ローカル専用設定',
|
||||
ttlBadge: 'TTL {{ttl}}',
|
||||
sessionAffinityDescription:
|
||||
'可能な場合は 1 つの会話を同じアカウントに固定します。CLIProxy はクライアントが明示的に送るセッション ID やスレッド ID を優先し、それがない場合はリクエストのメタデータや冒頭プロンプト履歴から安定キーを推定します。',
|
||||
sessionRecognitionTitle: 'セッション認識',
|
||||
sessionRecognitionDescription:
|
||||
'CCS はすべてのバックエンドで同一の優先順位を保証しません。一般に上流バックエンドは明示的なセッション / スレッド ID を優先し、その後にメタデータ、最後に冒頭プロンプト履歴のハッシュへフォールバックします。',
|
||||
},
|
||||
settingsDialog: {
|
||||
editProfile: 'プロファイルを編集: {{name}}',
|
||||
@@ -10003,6 +10070,7 @@ const resources = {
|
||||
accountsUpdated: 'アカウントを更新しました',
|
||||
noProfilesToSync: '同期するプロファイルがありません',
|
||||
syncFailed: '同期に失敗しました: {{error}}',
|
||||
sessionAffinityUpdated: 'セッション固定を{{state}}にしました。',
|
||||
providerAuthSuccess: '{{provider}} の認証に成功しました',
|
||||
providerDeviceCodeInCallback:
|
||||
'コールバックモードでプロバイダーがデバイスコードフローを返しました',
|
||||
|
||||
Reference in New Issue
Block a user