diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7dbbea..e5e69ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - 槽位种子页默认锁定:需确认解锁后才能改配置,播种前再次确认;时区/语言提供 en、cn、jp 基础选项。时区跟随当前 SOCKS5(空/非手动钉住)时展示出口时区,播种经 `timezone_follow_proxy` 写入;可改固定 IANA 时区或改回跟随。TZ/LANG/LC_ALL 仍不经 `extra_env` 覆盖契约键。 - 槽位种子页:`settings.json` 的 env 行与 `theme` 可直接编辑并随播种写入;`autoUpdates`、`grove_enabled` 只读且播种时固定为 `false`;TZ/LANG/LC_ALL、遥测 kill-switch、Bedrock/Vertex、gateway hint 等同理只读(播种由契约重写)。缺 `CLAUDE_CODE_ATTRIBUTION_HEADER` 时默认 `0`。调试上传默认关,独立 query 拉最近 40 条上传,切换开关不重置未保存编辑;Go sidecar 写 `vms//run/telemetry-upload.log`,面板 `GET seed-settings?debug=1` 返回 `telemetry_log`。 +- 协议页可配置 `routing.compatibility.auto_compact_window`(100K–1M 或跟随模型);入站在清理 settings 前提取调用方 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,经内部 `auto_compact_window` 字段进入当前 job,清理后的 body 不再携带该 env。 - 槽位种子页按当前槽 `settings.json` 列出真实配置;`claude.setting` 可编辑播种;缺失文件返回 `null`、空文件返回空串,不用策略默认冒充槽内文件;遥测开关与 sidecar 进程状态分开展示,保存不因遥测进程刷新失败而 502。 - fleet 遥测刷新在 kin-egress 二进制被替换(含 `(deleted)` 旧进程与脏 pidfile)时,会按 egress 配置扫停全部 helper、等待 `/proc/` 消失后再绑定新二进制;同一 SOCKS 出口的 stop/start 与 `ensureProxyEgress` 共用 per-proxy 锁,避免并发 telemetry 互相 SIGTERM。(#604) - 面板:侧栏「GitHub / 文档」两枚链接不再折行,字体偏宽时「文档」不会逐字竖排,空间不够先缩箭头再省略文字;VM 列表搜索框关闭浏览器自动填充,不再被回填成登录邮箱。 diff --git a/src/lib/admin/panel-api.mjs b/src/lib/admin/panel-api.mjs index fa2e7720..f5177d62 100644 --- a/src/lib/admin/panel-api.mjs +++ b/src/lib/admin/panel-api.mjs @@ -475,6 +475,12 @@ export function validatePersonaRoutingPatch(body = {}) { if (Object.prototype.hasOwnProperty.call(compat, 'cch_enabled') && typeof compat.cch_enabled !== 'boolean') { problems.push('cch_enabled 必须是布尔') } + if (Object.prototype.hasOwnProperty.call(compat, 'auto_compact_window') && compat.auto_compact_window != null) { + const windowTokens = Number(compat.auto_compact_window) + if (!Number.isInteger(windowTokens) || windowTokens < 100_000 || windowTokens > 1_000_000) { + problems.push('auto_compact_window 必须是 100000–1000000 的整数') + } + } if ( compat.thinking_display != null && compat.thinking_display !== '' && @@ -1406,6 +1412,9 @@ function presentCompatibility(compat) { .trim() .toLowerCase() out.thinking_display = display === 'passthrough' ? 'passthrough' : 'summarized' + const windowTokens = Number(out.auto_compact_window) + out.auto_compact_window = + Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null return out } diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs index 65c8fe43..ad30b11f 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -35,7 +35,7 @@ import { createClaudeMessageAssembler, applyClaudeSSELineToMessage, } from './convert.mjs' -import { sanitizeInboundBody, defaultSeedPolicy } from './seed-policy.mjs' +import { sanitizeInboundBody, defaultSeedPolicy, callerAutoCompactWindow } from './seed-policy.mjs' import { fingerprintRequest } from './client-fingerprint.mjs' import { validateOfficialModel } from './models.mjs' import { handleCodexProtocol, handleCodexSearch } from './handle-codex.mjs' @@ -619,11 +619,13 @@ export function createHandleProtocol(deps) { ) } + const autoCompactWindow = callerAutoCompactWindow(inbound) let ctx = { path: pathName, protocol, body: sanitizeInboundBody(inbound, defaultSeedPolicy()), headers: { ...req.headers }, + autoCompactWindow, } ctx = applyIntercept(cfg.intercept.rules, 'before_convert', ctx) const bodyCheck = validateRequestBody(protocol, ctx.body) @@ -1291,6 +1293,7 @@ export function createHandleProtocol(deps) { preserveSystem: officialPassthrough, preserveThinkingBlocks: preserveCliHopThinkingBlocks, preserveCallerEnvironment, + autoCompactWindow: ctx.autoCompactWindow, }) hopBody = await materializeRemoteImageSources(hopBody) if (panelRewrite) { diff --git a/src/lib/protocol/outbound-attempt.mjs b/src/lib/protocol/outbound-attempt.mjs index 95326c71..7e9be766 100644 --- a/src/lib/protocol/outbound-attempt.mjs +++ b/src/lib/protocol/outbound-attempt.mjs @@ -190,11 +190,16 @@ export function prepareCliHopBody( preserveSystem = false, preserveThinkingBlocks = false, preserveCallerEnvironment = false, + autoCompactWindow = null, } = {}, ) { if (requestContext?.purpose === 'auto_mode_classifier') return prepareClassifierBody(canonicalBody, { stream }) if (preserveSystem) validateCacheBreakpoints(canonicalBody) let body = officialMessagesBody(canonicalBody, { stream }) + const windowTokens = Number(autoCompactWindow) + if (Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000) { + body.auto_compact_window = windowTokens + } delete body.metadata if (!preserveSystem) { const leftover = stripCliOwnedSystem(body.system, { preserveCallerEnvironment }) diff --git a/src/lib/protocol/seed-policy.mjs b/src/lib/protocol/seed-policy.mjs index 81b73c80..6b51a34c 100644 --- a/src/lib/protocol/seed-policy.mjs +++ b/src/lib/protocol/seed-policy.mjs @@ -118,6 +118,16 @@ export function buildSlotSettingsEnv(pol = {}, { timezone, locale, extra } = {}) return out } +export function callerAutoCompactWindow(body = {}) { + const sources = [body?.settings?.env, body?.claude_settings?.env, body?.env] + for (const source of sources) { + if (!source || typeof source !== 'object' || Array.isArray(source)) continue + const value = Number(source.CLAUDE_CODE_AUTO_COMPACT_WINDOW) + if (Number.isInteger(value) && value >= 100_000 && value <= 1_000_000) return value + } + return null +} + export function buildSeedSettingsEnv(pol = {}, extra = {}) { return buildSlotSettingsEnv(pol, { extra }) } diff --git a/src/lib/transport/rust-kernel-supervisor.mjs b/src/lib/transport/rust-kernel-supervisor.mjs index 3c70bf3e..4dd986d5 100644 --- a/src/lib/transport/rust-kernel-supervisor.mjs +++ b/src/lib/transport/rust-kernel-supervisor.mjs @@ -37,6 +37,11 @@ import { } from '../oauth/oauth-credentials.mjs' import { slotHost } from '../vm/slot-host.mjs' +function normalizeAutoCompactWindow(value) { + const windowTokens = Number(value) + return Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000 ? windowTokens : null +} + const starts = new Map() const KVM_HEALTH_MS = { kvm: 120_000, tcg: 480_000 } @@ -693,6 +698,13 @@ export function writeKernelConfig( const previousMax = Number(previous.max_request_bytes) const requestBytes = routing == null && previousMax > 0 ? previousMax : maxRequestBytes(routing) const cchEnabled = routing == null ? previous.cch_enabled !== false : routing?.compatibility?.cch_enabled !== false + const previousWindow = Number(previous.auto_compact_window) + const autoCompactWindow = + routing == null + ? Number.isInteger(previousWindow) && previousWindow >= 100_000 && previousWindow <= 1_000_000 + ? previousWindow + : null + : normalizeAutoCompactWindow(routing?.compatibility?.auto_compact_window) const sessionIdleTtlSeconds = routing == null ? normalizeSessionIdleTtlSeconds(previous.session_idle_ttl_seconds) @@ -726,6 +738,7 @@ export function writeKernelConfig( cli_version: OFFICIAL_CLI_VERSION, default_cache_ttl: defaultCacheTtl, cch_enabled: cchEnabled, + auto_compact_window: autoCompactWindow, } if (tz) config.timezone = tz diff --git a/test/unit/go-worker-client.test.mjs b/test/unit/go-worker-client.test.mjs index 43a1a32e..388d601a 100644 --- a/test/unit/go-worker-client.test.mjs +++ b/test/unit/go-worker-client.test.mjs @@ -14,6 +14,8 @@ import { restoreUncommittedHop, semanticStatusForStreamError, } from '../../src/lib/transport/go-worker-client.mjs' +import { prepareCliHopBody } from '../../src/lib/protocol/outbound-attempt.mjs' +import { callerAutoCompactWindow, defaultSeedPolicy, sanitizeInboundBody } from '../../src/lib/protocol/seed-policy.mjs' import { extractOpenaiUsage } from '../../src/lib/protocol/openai-usage.mjs' import { callRustKernel, rustKernelHealth } from '../../src/lib/transport/rust-kernel-client.mjs' import { forwardApi, readApiJson } from '../../src/lib/transport/api-kernel-client.mjs' @@ -164,6 +166,29 @@ test('cli-hop envelope keeps role=system turns that the VM betas do not declare' assert.deepEqual(out.body.system, body.system) }) +test('cli-hop keeps the caller compact window outside settings', () => { + const inbound = { + model: 'claude-opus-5-5', + messages: [{ role: 'user', content: 'hi' }], + settings: { theme: 'light', env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000', TZ: 'UTC' } }, + } + const autoCompactWindow = callerAutoCompactWindow(inbound) + const sanitized = sanitizeInboundBody(inbound, defaultSeedPolicy()) + assert.equal(callerAutoCompactWindow(sanitized), null) + const prepared = prepareCliHopBody(sanitized, { autoCompactWindow }) + const out = finalizeWorkerPayload({ + body: prepared, + reqHeaders: {}, + exec: { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } }, + identity: null, + cliHop: true, + }) + assert.equal(sanitized.settings, undefined) + assert.equal(out.body.settings, undefined) + assert.equal(out.body.env, undefined) + assert.equal(out.body.auto_compact_window, 1000000) +}) + test('setup-token cli-hop summarizes hidden thinking by default and keeps updates when passthrough', () => { const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } } const updates = finalizeWorkerPayload({ diff --git a/test/unit/kernel-router.test.mjs b/test/unit/kernel-router.test.mjs index cc7d880c..4d670648 100644 --- a/test/unit/kernel-router.test.mjs +++ b/test/unit/kernel-router.test.mjs @@ -631,6 +631,7 @@ test('writeKernelConfig separates container paths from host socket paths', () => assert.equal(doc.https_proxy, undefined) assert.equal(doc.slots_per_worker, WRAP_SLOT_MAX) assert.equal(doc.default_cache_ttl, '1h') + assert.equal(doc.auto_compact_window, null) assert.match(written.socketPath.replace(/\\/g, '/'), /vms\/vm-09\/run\/kernel\.sock$/) assert.equal(fs.readFileSync(written.tokenPath, 'utf8').trim(), 'tok') const exec = { vmId: 'vm-09', homeDir: path.join(root, 'vms', 'vm-09', 'cli-home'), vm: { id: 'vm-09' } } @@ -702,6 +703,7 @@ test('writeKernelConfig cli-hop writes local_cli without secrets', () => { assert.equal(doc.system_layout, 'identity') assert.equal(doc.cli_version, OFFICIAL_CLI_VERSION) assert.equal(doc.default_cache_ttl, '5m') + assert.equal(doc.auto_compact_window, null) assert.equal(doc.timezone, 'America/New_York') assert.equal(doc.proxy_url, '') assert.equal(doc.proxy_required, false) diff --git a/web/src/features/settings/protocol-pane.tsx b/web/src/features/settings/protocol-pane.tsx index 1e38d8cf..253373c2 100644 --- a/web/src/features/settings/protocol-pane.tsx +++ b/web/src/features/settings/protocol-pane.tsx @@ -3,7 +3,14 @@ import { SESSION_IDLE_TTL_SECONDS_MAX, SESSION_IDLE_TTL_SECONDS_MIN, } from '@/types/panel-routing' -import { Brain, Check, Clock3, Database, Hash, ShieldCheck } from 'lucide-react' +import { + Brain, + Check, + Clock3, + Database, + Scaling, + ShieldCheck, +} from 'lucide-react' import { cacheTtlFromCompat, detectProxiedOfficialCcFromCompat, @@ -32,6 +39,13 @@ const CLAUDE_CODE_FINGERPRINT: [string, string][] = [ ['计费块', 'cc_version = UA 版本'], ] +function autoCompactWindowValue(value: unknown) { + const tokens = Number(value) + if (tokens === 200_000 || tokens === 500_000 || tokens === 1_000_000) + return String(tokens) + return 'model' +} + export function ProtocolPane({ compat, inference, @@ -188,7 +202,34 @@ export function ProtocolPane({ + + + onCompatChange({ + ...compat, + auto_compact_window: value === 'model' ? null : Number(value), + }) + } + /> + + +