Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/fr/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Un envoi en attente de la réponse du serveur amont est arrêté après 60 secondes au total ou 15 secondes sans progression. La contre-pression du serveur amont ne compte pas comme une période d’inactivité. Les limites sont supprimées une fois le corps envoyé ou dès la première réponse amont, afin que les réponses longues et les flux SSE puissent continuer.

### 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
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,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.

Uploads waiting for an upstream reply are stopped after 60 seconds in total or 15 seconds without upload progress. Upstream backpressure does not count as idleness. Completing the body or receiving the first upstream reply removes these limits, so long responses and SSE streams can continue.

### 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,
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ja/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ Picker プロファイルを選択した後は、Claude Desktop を完全に終

Picker モードは 1P の一部なので、[1P のアカウントリスク](#1pオプトイン)も同じように適用されます。

上流からの応答を待つアップロードは、開始から60秒、または進行がない状態が15秒続くと停止します。上流のバックプレッシャーによる停止は無通信として扱いません。本文の送信が完了するか上流の応答が始まると、この制限は解除され、長い応答やSSEストリームを継続できます。

### Code タブで opencodex モデルを使う(1P バインディング)

1P モードでは、Code タブのモデルピッカーは claude.ai が提供します。各行(Opus 5.5、Sonnet 5、
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ Picker 모드를 끄거나 Desktop을 완전히 다시 시작할 때까지 Deskt

Picker 모드는 1P의 일부이므로 [1P 계정 위험](#1p직접-선택)도 그대로 적용돼요.

상류 응답을 기다리는 업로드는 전체 60초가 지나거나 업로드 진행이 15초 동안 없으면 중단됩니다. 상류의 흐름 제어로 전송이 멈춘 시간은 정체로 계산하지 않습니다. 본문 전송을 완료하거나 상류가 응답을 시작하면 이 제한이 해제되므로 긴 응답과 SSE 스트림은 계속 진행할 수 있습니다.

### Code 탭에서 opencodex 모델 쓰기 (1P 바인딩)

1P 모드에서 Code 탭의 모델 선택기는 claude.ai가 채워요. Opus 5.5, Sonnet 5, Haiku 4.5와
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ru/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ Desktop будет офлайн, пока вы полностью не пере

Режим picker входит в first-party, поэтому к нему также применяется [риск для аккаунта first-party](#first-party-по-выбору).

Загрузка, ожидающая ответа вышестоящего сервера, прекращается через 60 секунд суммарно или через 15 секунд без прогресса. Обратное давление со стороны сервера не считается бездействием. После завершения отправки тела или получения первого ответа эти ограничения снимаются, поэтому долгие ответы и потоки SSE могут продолжаться.

## Claude Desktop через удалённый хаб

На подключённой машине `ocx claude desktop apply` или `ocx claude desktop` получает снимок
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/tr/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ seçildikten sonra Claude Desktop'ı tamamen kapatıp yeniden açın.
Picker modu first-party'nin parçasıdır; bu nedenle [first-party hesap riski](#first-party-isteğe-bağlı)
aynı şekilde geçerlidir.

Üst sunucudan yanıt bekleyen yüklemeler toplam 60 saniye sonra veya 15 saniye boyunca ilerleme olmadığında durdurulur. Üst sunucunun geri basıncı hareketsizlik sayılmaz. Gövde gönderimi tamamlandığında veya ilk üst sunucu yanıtı alındığında bu sınırlar kaldırılır; uzun yanıtlar ve SSE akışları devam edebilir.

## Claude Desktop profili

Bu profil yalnızca ağ geçidi modunda Desktop'a yazılır.
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ Picker 模式开启期间,Claude Desktop 通过 OpenCodex 访问网络。如

Picker 模式属于第一方模式,因此[第一方账户风险](#第一方主动选择)同样适用。

等待上游响应的上传会在总计60秒后,或连续15秒没有上传进展时停止。上游背压不视为空闲。请求正文发送完成或收到首个上游响应后,这些限制会解除,因此长响应和SSE流可以继续。

## 连接远程 hub 的 Claude Desktop

已连接的机器运行 `ocx claude desktop apply` 或 `ocx claude desktop` 时,会读取 hub 的
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-tw/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ Picker 模式開啟期間,Claude Desktop 會透過 OpenCodex 存取網路。

Picker 模式屬於第一方模式,因此[第一方帳號風險](#第一方自行選擇)同樣適用。

等待上游回應的上傳會在總計60秒後,或連續15秒沒有上傳進展時停止。上游背壓不視為閒置。請求本文傳送完成或收到首個上游回應後,這些限制會解除,因此長回應和SSE串流可以繼續。

## Claude Desktop 設定檔

只有閘道模式會將以下設定檔寫入 Desktop。
Expand Down
52 changes: 52 additions & 0 deletions src/claude/intercept/picker-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface PickerListenerOptions {
maxEncodedBytes?: number;
/** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
maxActiveUpstreams?: number;
/** Test seam: absolute deadline until upload completion or the first upstream reply. */
uploadDeadlineMs?: number;
/** Test seam: maximum time without upload progress before an upstream reply. */
uploadIdleTimeoutMs?: number;
/** Test seam: observe or hold the upstream writable before piping input. */
onUpstreamRequest?: (request: ReturnType<typeof httpsRequest>) => void;
/**
* 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.
Expand All @@ -56,6 +62,9 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
export const PICKER_MAX_CONCURRENT_STREAMS = 100;
/** A connection that has not sent a complete ClientHello by then is dropped. */
const CLIENT_HELLO_TIMEOUT_MS = 10_000;
const PICKER_UPLOAD_DEADLINE_MS = 60_000;
const PICKER_UPLOAD_IDLE_TIMEOUT_MS = 15_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
/**
* Once the backend has ended a connection and its reply has been flushed, input the client still
* sends is discarded for at most this long before the connection is dropped. A cooperative client
Expand Down Expand Up @@ -163,6 +172,13 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
const uploadDeadlineMs = options.uploadDeadlineMs ?? PICKER_UPLOAD_DEADLINE_MS;
const uploadIdleTimeoutMs = options.uploadIdleTimeoutMs ?? PICKER_UPLOAD_IDLE_TIMEOUT_MS;
for (const [name, value] of [["uploadDeadlineMs", uploadDeadlineMs], ["uploadIdleTimeoutMs", uploadIdleTimeoutMs]] as const) {
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
throw new RangeError(`${name} must be a positive finite timer duration`);
}
}
const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => {
const timer = setTimeout(drop, ms);
return () => clearTimeout(timer);
Expand Down Expand Up @@ -271,6 +287,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding());
let upReq: ReturnType<typeof httpsRequest>;
let upstreamResponded = false;
let stopUploadClock = () => {};
try {
// The HTTP/1.1 client validates the method, path and header values synchronously.
upReq = httpsRequest({
Expand All @@ -279,6 +296,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES,
}, upRes => {
upstreamResponded = true;
stopUploadClock();
const status = upRes.statusCode ?? 502;
const originalHeaders = filteredHeaders(upRes.rawHeaders);
const sendHead = (raw: string[]) => {
Expand Down Expand Up @@ -358,9 +376,32 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose);
if (uploading) {
let uploadFinished = false;
let deadlineTimer: NodeJS.Timeout | undefined;
let idleTimer: NodeJS.Timeout | undefined;
const onUploadIdle = () => {
idleTimer = undefined;
// Pipe backpressure pauses data events without making the client idle.
// The absolute clock still bounds an upload whose upstream never drains.
if (upReq.writableNeedDrain) { onUploadProgress(); return; }
expireUpload();
};
const onUploadProgress = () => {
if (uploadFinished || upstreamResponded) return;
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(onUploadIdle, uploadIdleTimeoutMs);
idleTimer.unref?.();
};
stopUploadClock = () => {
if (deadlineTimer) clearTimeout(deadlineTimer);
if (idleTimer) clearTimeout(idleTimer);
deadlineTimer = undefined;
idleTimer = undefined;
req.off("data", onUploadProgress);
};
const finishUpload = () => {
if (uploadFinished) return;
uploadFinished = true;
stopUploadClock();
req.off("end", finishUpload);
req.off("close", finishUpload);
upReq.off("close", onUpstreamClose);
Expand All @@ -378,17 +419,28 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
upReq.destroy();
}
};
const expireUpload = () => {
if (uploadFinished || upstreamResponded) return;
clientGone = true;
stopUnfinishedUpload();
release();
};
const onResponseFinish = () => stopUnfinishedUpload(true);
const onUpstreamClose = () => {
if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload();
};
req.once("end", finishUpload);
req.once("close", finishUpload);
req.on("data", onUploadProgress);
upReq.once("close", onUpstreamClose);
responseWritable.once("finish", onResponseFinish);
res.once("close", stopUnfinishedUpload);
if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload);
deadlineTimer = setTimeout(expireUpload, uploadDeadlineMs);
deadlineTimer.unref?.();
onUploadProgress();
}
options.onUpstreamRequest?.(upReq);
req.pipe(upReq);
};
server.on("request", relay);
Expand Down
9 changes: 7 additions & 2 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,13 @@ After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ER
HTTP/1.1 refusals, generated 502s and early replies advertise `Connection: close` only while input is unfinished; after the writable finishes,
the listener unpipes the upstream upload, reads and discards remaining input, and ends the socket
gracefully so queued response bytes flush. Errors and premature downstream closes still destroy
the request. The request-side drain adds no upload deadline or byte cap and does not wait for request-body
completion before ending the response side. Bun 1.4.3's HTTP server ends a `Connection: close`
the request. The request-side drain adds no byte cap and does not wait for request-body
completion before ending the response side. Accepted uploads awaiting an upstream reply have a
60-second absolute and 15-second idle-progress deadline. Expiry closes that input and upstream
work and releases its aggregate slot; upstream backpressure suspends only the idle clock.
Body completion or the first upstream reply removes both clocks, so they cannot truncate a slow
early response or SSE stream. The existing reply cleanup and TCP linger policy take over after
an upstream reply. Bun 1.4.3's HTTP server ends a `Connection: close`
response with `socket.destroySoon()` before the listener's own finish handler runs, which reset
the splice before the reply reached the client; every HTTP/1.1 reply that closes unfinished input
(refusal, generated 502, early upstream reply) therefore replaces that socket's `destroySoon` with
Expand Down
137 changes: 137 additions & 0 deletions tests/claude-integration/claude-picker-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,140 @@ for (const protocol of ["h1", "h2"] as const) {
} finally { await wait(f.close()); }
});
}

bounded("unfinished uploads share the aggregate budget across HTTP/2 sessions and recover on idle timeout", async wait => {
const arrived = Promise.withResolvers<void>();
let requests = 0;
const f = await fixture((req, res) => {
requests++;
if (req.url === "/stall") {
req.on("data", () => {});
req.once("end", () => res.end("uploaded"));
arrived.resolve();
} else res.end("ordinary");
}, { maxActiveUpstreams: 1, uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 350 });
try {
const firstSession = f.h2();
const secondSession = f.h2();
const upload = firstSession.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" });
const uploadClosed = observeClose(upload);
upload.write("partial");
await wait(arrived.promise);
const blocked = await wait(normalH2(secondSession, "/blocked"));
expect(blocked.status).toBe(503);
await wait(uploadClosed);
expect((await wait(normalH2(secondSession, "/ok"))).status).toBe(200);
expect(requests).toBe(2);
} finally { await wait(f.close()); }
});

bounded("absolute upload deadline expires despite continued progress", async wait => {
const arrived = Promise.withResolvers<void>();
const f = await fixture((req, res) => {
req.on("data", () => {});
arrived.resolve();
req.once("end", () => res.end("uploaded"));
}, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 });
let progress: NodeJS.Timeout | undefined;
try {
const session = f.h2();
const upload = session.request({ ":method": "POST", ":path": "/trickle", ":authority": "claude.ai" });
const closed = observeClose(upload);
progress = setInterval(() => upload.write("x"), 80);
upload.write("start");
await wait(arrived.promise);
await wait(closed);
expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL);
} finally { if (progress) clearInterval(progress); await wait(f.close()); }
});

bounded("upstream backpressure does not expire an active upload as idle", async wait => {
const upstreamRequest = Promise.withResolvers<ClientRequest>();
const f = await fixture((_req, res) => res.end("ordinary"), {
uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 250,
onUpstreamRequest: request => {
// Hold the actual relay writable so the fixture, not socket buffer sizing,
// determines how long upstream backpressure persists.
request.cork();
upstreamRequest.resolve(request);
},
});
try {
const session = f.h2();
const upload = session.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" });
const closed = observeClose(upload);
let closedEarly = false;
upload.once("close", () => { closedEarly = true; });
upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61));
const relayRequest = await wait(upstreamRequest.promise);
await wait((async () => {
while (!relayRequest.writableNeedDrain) {
if (relayRequest.destroyed) throw new Error("relay closed before upstream backpressure");
await new Promise(resolve => setTimeout(resolve, 5));
}
})());
await wait(new Promise(resolve => setTimeout(resolve, 600)));
expect(relayRequest.writableNeedDrain).toBe(true);
expect(closedEarly).toBe(false);
await wait(closed);
expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL);
} finally { await wait(f.close()); }
});

bounded("completed uploads are not timed out while a long response is pending", async wait => {
const f = await fixture((req, res) => {
req.resume();
req.once("end", () => setTimeout(() => res.end("slow response"), 120));
}, { uploadDeadlineMs: 50, uploadIdleTimeoutMs: 25 });
try {
const session = f.h2();
const upload = session.request({ ":method": "POST", ":path": "/complete", ":authority": "claude.ai" });
const result = h2Result(upload);
upload.end("complete");
expect(await wait(result)).toEqual({ status: 200, text: "slow response" });
} finally { await wait(f.close()); }
});

bounded("h1 idle upload releases the shared slot for a healthy h2 request", async wait => {
const arrived = Promise.withResolvers<void>();
let requests = 0;
const f = await fixture((req, res) => {
requests++;
req.on("error", () => res.destroy()); req.resume();
if (req.url === "/stall") { arrived.resolve(); req.once("end", () => res.end("uploaded")); }
else res.end("ordinary");
}, { maxActiveUpstreams: 1, uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 350 });
try {
const upload = f.h1("/stall", "POST", { "Content-Length": "100" });
const closed = observeSocketClose(upload);
upload.write("partial");
await wait(arrived.promise);
const session = f.h2();
expect((await wait(normalH2(session, "/blocked"))).status).toBe(503);
await wait(closed);
expect((await wait(normalH2(session, "/ok"))).status).toBe(200);
expect(requests).toBe(2);
} finally { await wait(f.close()); }
});

for (const protocol of ["h1", "h2"] as const) {
bounded(`${protocol} an early reply disables pre-response clocks while its upload is unfinished`, async wait => {
const f = await fixture((req, res) => {
req.on("error", () => res.destroy()); req.resume();
const socket = req.socket;
socket.destroySoon = () => { socket.end(); return socket; };
res.writeHead(200, { "Content-Length": "5" });
res.write("s");
setTimeout(() => res.end("low!"), 600);
}, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 });
try {
const session = f.h2();
const upload = protocol === "h2"
? session.request({ ":method": "POST", ":path": "/early", ":authority": "claude.ai" })
: f.h1("/early", "POST", { "Content-Length": "100" });
const result = protocol === "h2" ? h2Result(upload as ClientHttp2Stream) : h1Result(upload as ClientRequest);
upload.write("partial");
expect(await wait(result)).toEqual({ status: 200, text: "slow!" });
} finally { await wait(f.close()); }
});
}
Loading