diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index c6a2570ac9b..ab613c8ba02 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -200,6 +200,8 @@ quittez complètement puis rouvrez Claude Desktop. Le mode picker fait partie de first-party : le [risque pour le compte du mode first-party](#first-party-sur-demande) s'applique donc aussi à ce mode. +Avant la première réponse du serveur amont, un téléversement picker sans progression pendant 60 secondes est fermé. Ce délai d’inactivité est suspendu lorsque le serveur amont exerce une contre-pression et supprimé dès la fin du téléversement ou la première réponse amont. Aucune limite absolue de durée n’est imposée : les pièces jointes volumineuses dont le transfert progresse et les flux de réponse de longue durée restent pris en charge. + ### Utiliser les modèles opencodex depuis l'onglet Code de Desktop (associations first-party) En mode first-party, le sélecteur de modèles de l'onglet Code appartient à claude.ai : ses lignes diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 76bbc9fd17f..5a2bde829a8 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -349,6 +349,8 @@ applies to it as well. Desktop and CLI catalog rewrites share bounded row and me if adding routed models would exceed a limit, OpenCodex returns the original Anthropic catalog unchanged rather than publishing a partial list. +Before the first upstream response, a picker upload that makes no progress for 60 seconds is closed. This idle timer pauses when the upstream applies backpressure and is removed when the upload completes or the upstream first responds. There is no absolute upload-duration limit: progressing large attachments and long-lived response streams remain supported. + ### Use opencodex models from the Desktop Code tab (first-party bindings) In first-party mode the Code tab's model picker belongs to claude.ai: its rows (Opus 5.5, diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index e9695d0a5f6..e38f5dbe472 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -172,6 +172,8 @@ Picker プロファイルを選択した後は、Claude Desktop を完全に終 Picker モードは 1P の一部なので、[1P のアカウントリスク](#1pオプトイン)も同じように適用されます。 +上流サーバーの最初の応答前に、60 秒間転送が進まない Picker アップロードは終了します。上流のバックプレッシャーで転送が一時停止している間は、このタイマーも停止します。アップロード完了時または上流の最初の応答時にタイマーを解除します。アップロード全体の時間上限は設けないため、転送が進んでいる大きな添付ファイルや長時間の応答ストリームは引き続き利用できます。 + ### Code タブで opencodex モデルを使う(1P バインディング) 1P モードでは、Code タブのモデルピッカーは claude.ai が提供します。各行(Opus 5.5、Sonnet 5、 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 84d243ebeb2..9f82304b6cd 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -194,6 +194,8 @@ Picker 모드를 끄거나 Desktop을 완전히 다시 시작할 때까지 Deskt Picker 모드는 1P의 일부이므로 [1P 계정 위험](#1p직접-선택)도 그대로 적용돼요. +상위 서버의 첫 응답 전에는 60초 동안 전송이 진행되지 않는 Picker 업로드를 종료해요. 상위 서버가 데이터를 받지 못해 전송을 일시 중지한 동안에는 이 타이머도 멈추고, 업로드가 끝나거나 첫 응답이 오면 해제해요. 전체 업로드 시간에는 고정 제한을 두지 않으므로, 계속 전송되는 큰 첨부파일과 오래 유지되는 응답 스트림은 지원해요. + ### Code 탭에서 opencodex 모델 쓰기 (1P 바인딩) 1P 모드에서 Code 탭의 모델 선택기는 claude.ai가 채워요. Opus 5.5, Sonnet 5, Haiku 4.5와 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index c225700017c..c58a8946834 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -205,6 +205,8 @@ loopback-адреса. Обновите или настройте хаб и по проброс Anthropic, но преобразованные маршруты Anthropic могут использовать кеш. Сохранение блоков при повторной передаче и сравнение попаданий в кеш остаются отдельной работой. +До первого ответа вышестоящего сервера загрузка picker закрывается, если передача не продвигается 60 секунд. Таймер бездействия приостанавливается, когда вышестоящий сервер создаёт обратное давление, и отключается после завершения загрузки или первого ответа. Общая длительность загрузки не ограничена: большие вложения с продолжающейся передачей и длительные потоки ответов по-прежнему поддерживаются. + ### Модели opencodex на вкладке Code в Desktop (привязки first-party) В режиме first-party селектор моделей вкладки Code принадлежит claude.ai: его строки (Opus 5.5, diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 5112d2b8f96..dde8aca095a 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -279,6 +279,8 @@ Yeni rotalar varsayılan olarak Opus ailesine gider, ancak bir rotayı taşımak `--static`, `--hybrid` ve `--discovery-only` mevcut betikler için kullanılabilir durumda kalır. +Üst sunucunun ilk yanıtından önce, 60 saniye boyunca ilerlemeyen picker yüklemesi kapatılır. Üst sunucunun geri basıncı nedeniyle aktarım duraklatıldığında bu boşta kalma sayacı da duraklatılır; yükleme tamamlandığında veya ilk yanıt geldiğinde kaldırılır. Toplam yükleme süresine sabit bir sınır konmaz: ilerleyen büyük ekler ve uzun süre açık kalan yanıt akışları desteklenmeye devam eder. + ### Desktop Code sekmesinden opencodex modellerini kullanma (first-party bağlantıları) First-party modunda Code sekmesinin model seçici claude.ai'ye aittir: satırları (Opus 5.5, diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 5943f022eec..d1e3a3920ae 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -179,6 +179,8 @@ Desktop 配置、模型家族分组及默认值由 hub 管理。在 hub 上修 只有代理接入凭证不会启用原生 Anthropic 透传,但经过转换的 Anthropic 路由仍可使用提示缓存。 重放保真和缓存命中率对比仍是独立工作。 +在上游首次响应之前,Picker 上传若连续 60 秒没有进展就会关闭。上游背压导致传输暂停时,空闲计时也会暂停;上传完成或上游首次响应后,计时器会被移除。上传总时长没有固定上限,因此持续传输的大附件和长时间响应流仍受支持。 + ### 在 Desktop Code 标签页使用 opencodex 模型(第一方绑定) 在第一方模式下,Code 标签页的模型选择器属于 claude.ai:其中的条目(Opus 5.5、Sonnet 5、 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index a22ac6bc472..e286c960047 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -264,6 +264,8 @@ Desktop 設定檔、模型家族分組及預設值由 hub 管理。在 hub 上 只有代理存取憑證不會啟用原生 Anthropic 透傳,但經過轉換的 Anthropic 路由仍可使用提示快取。 重播保真與快取命中率比較仍是獨立工作。 +在上游首次回應之前,Picker 上傳若連續 60 秒沒有進展就會關閉。上游背壓導致傳輸暫停時,閒置計時也會暫停;上傳完成或上游首次回應後,計時器會被移除。上傳總時長沒有固定上限,因此持續傳輸的大型附件和長時間回應串流仍受支援。 + ### 在 Desktop Code 分頁使用 opencodex 模型(第一方綁定) 在第一方模式中,Code 分頁的模型選擇器屬於 claude.ai:其中的項目(Opus 5.5、Sonnet 5、 diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index d115c7faad1..30715c73977 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -34,6 +34,8 @@ export interface PickerListenerOptions { maxEncodedBytes?: number; /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */ maxActiveUpstreams?: number; + /** Test seam: no-progress time before an upstream response; never an upload lifetime cap. */ + uploadIdleMs?: number; /** * Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS; * returns a cancel function. Production uses a timer. @@ -54,6 +56,8 @@ export const PICKER_MAX_HEADER_BYTES = 64 * 1024; export const PICKER_MAX_ACTIVE_UPSTREAMS = 256; /** Advertised per HTTP/2 connection; Chromium queues further requests rather than failing them. */ export const PICKER_MAX_CONCURRENT_STREAMS = 100; +/** Only stalled, pre-response uploads expire; progressing attachments have no absolute deadline. */ +export const PICKER_UPLOAD_IDLE_MS = 60_000; /** A connection that has not sent a complete ClientHello by then is dropped. */ const CLIENT_HELLO_TIMEOUT_MS = 10_000; /** @@ -358,9 +362,27 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose); if (uploading) { let uploadFinished = false; + let idleTimer: ReturnType | undefined; + const clearIdle = () => { if (idleTimer !== undefined) clearTimeout(idleTimer); idleTimer = undefined; }; + const armIdle = () => { + clearIdle(); + if (uploadFinished || upstreamResponded || req.isPaused()) return; + idleTimer = setTimeout(() => { + clientGone = true; + options.log?.("picker upload idle timeout"); + stopUnfinishedUpload(); + }, options.uploadIdleMs ?? PICKER_UPLOAD_IDLE_MS); + idleTimer.unref?.(); + }; + const noteProgress = (chunk: Buffer) => { if (chunk.length > 0) armIdle(); }; const finishUpload = () => { if (uploadFinished) return; uploadFinished = true; + clearIdle(); + req.off("data", noteProgress); + req.off("pause", clearIdle); + req.off("resume", armIdle); + upReq.off("response", clearIdle); req.off("end", finishUpload); req.off("close", finishUpload); upReq.off("close", onUpstreamClose); @@ -382,12 +404,18 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const onUpstreamClose = () => { if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload(); }; + // Piping pauses the readable under upstream backpressure; that is not client idleness. + req.on("data", noteProgress); + req.on("pause", clearIdle); + req.on("resume", armIdle); + upReq.once("response", clearIdle); req.once("end", finishUpload); req.once("close", finishUpload); upReq.once("close", onUpstreamClose); responseWritable.once("finish", onResponseFinish); res.once("close", stopUnfinishedUpload); if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload); + armIdle(); } req.pipe(upReq); }; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index dd931e25d7f..f46174cbb51 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -220,7 +220,7 @@ natively, counting name + value + 32 bytes per field and rejecting an oversized The relay retains its 256-request aggregate ceiling across both HTTP versions and all sessions. Upload framing, not the method, determines whether input cleanup is needed: HTTP/2 headers without END_STREAM, or HTTP/1.1 transfer encoding or a positive Content-Length. -Rejected unfinished uploads close after their empty reply's writable finishes; HTTP/2 uses the +Before an upstream response, an accepted upload in `picker-listener.ts` expires after 60 seconds with no body progress. Actual body bytes reset this idle timer, and upstream backpressure pauses it. There is no absolute upload lifetime or additional concurrency cap, so slow uploads can continue as long as they make progress. Body completion, the first upstream response, cancellation and shutdown clear the timer; SSE and long responses do not inherit it. `tests/claude-integration/claude-picker-upload.test.ts` covers silent upstreams, slot recovery, uploads longer than the idle interval and completed/early-response stream preservation. Rejected unfinished uploads close after their empty reply's writable finishes; HTTP/2 uses the underlying stream's finish event rather than the compatibility response's finish event, which waits for both stream halves to close. HTTP/1.1 refusals advertise `Connection: close` only while input is unfinished. Early replies stop relaying unfinished input only after the downstream writable finishes or the diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..03bea242c68 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,3 +378,78 @@ for (const protocol of ["h1", "h2"] as const) { } finally { await wait(f.close()); } }); } + + +for (const protocol of ["h1", "h2"] as const) { + bounded(`${protocol} idle upload releases admission without any upstream timeout`, async wait => { + const entered = Promise.withResolvers(); + const upstreamClosed = Promise.withResolvers(); + const f = await fixture((req, res) => { + req.on("error", () => res.destroy()); req.resume(); + if (req.url === "/idle") { + req.once("data", () => entered.resolve()); + req.once("close", () => upstreamClosed.resolve()); + // No upstream response, socket timeout, or forced close: only the relay can release it. + } else res.end("ordinary"); + }, { uploadIdleMs: 120, maxActiveUpstreams: 1 }); + try { + const session = f.h2(); + const upload = protocol === "h2" + ? session.request({ ":method": "POST", ":path": "/idle", ":authority": "claude.ai" }) + : f.h1("/idle", "POST", { "Content-Length": "1000" }); + const closed = protocol === "h2" ? observeClose(upload) : observeSocketClose(upload as ClientRequest); + upload.write("x"); await wait(entered.promise); + expect((await wait(normalH2(session))).status).toBe(503); + await wait(closed); await wait(upstreamClosed.promise); + expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); + expect(f.logs.filter(line => line === "picker upload idle timeout")).toHaveLength(1); + } finally { await wait(f.close()); } + }); + + bounded(`${protocol} progressing upload outlives the idle interval and preserves every byte`, async wait => { + let received = ""; + const entered = Promise.withResolvers(); + const f = await fixture((req, res) => { + req.on("error", () => res.destroy()); + req.on("data", chunk => { received += chunk.toString(); entered.resolve(); }); + req.once("end", () => res.end(received)); + }, { uploadIdleMs: 160 }); + try { + const upload = protocol === "h2" + ? f.h2().request({ ":method": "POST", ":path": "/progress", ":authority": "claude.ai" }) + : f.h1("/progress", "POST", { "Content-Length": "13" }); + const result = protocol === "h2" ? h2Result(upload as ClientHttp2Stream) : h1Result(upload as ClientRequest); + upload.write("x"); await wait(entered.promise); + for (let i = 0; i < 8; i++) { await wait(Bun.sleep(40)); upload.write("x"); } + upload.end("tail"); + expect(await wait(result)).toEqual({ status: 200, text: "xxxxxxxxxtail" }); + expect(f.logs).not.toContain("picker upload idle timeout"); + } finally { await wait(f.close()); } + }); + + for (const completed of [true, false]) { + bounded(`${protocol} ${completed ? "completed upload" : "early response"} leaves long-lived responses outside the idle guard`, async wait => { + const answered = Promise.withResolvers(); + let response: ServerResponse | undefined; + const f = await fixture((req, res) => { + req.on("error", () => res.destroy()); req.resume(); + const begin = () => { + response = res; res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("data: first\n\n"); answered.resolve(); + }; + if (completed) req.once("end", begin); else req.once("data", begin); + }, { uploadIdleMs: 80 }); + try { + const upload = protocol === "h2" + ? f.h2().request({ ":method": "POST", ":path": "/events", ":authority": "claude.ai" }) + : f.h1("/events", "POST", { "Content-Length": completed ? "1" : "1000" }); + const result = protocol === "h2" ? h2Result(upload as ClientHttp2Stream) : h1Result(upload as ClientRequest); + if (completed) upload.end("x"); else upload.write("x"); + await wait(answered.promise); await wait(Bun.sleep(240)); + response!.end("data: last\n\n"); + expect(await wait(result)).toEqual({ status: 200, text: "data: first\n\ndata: last\n\n" }); + expect(f.logs).not.toContain("picker upload idle timeout"); + } finally { await wait(f.close()); } + }); + } +}