Skip to content

Commit 7fcaa91

Browse files
committed
fix(logs): classify requested output limits correctly
1 parent c05e88f commit 7fcaa91

8 files changed

Lines changed: 90 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,10 @@ Every non-empty family has one default. You can drag a route, or use its visible
329329
a mouse, touch, or keyboard. **Save and apply** writes the profile to Claude Desktop. JSON export
330330
and import are available for backup or moving the same setup to another machine.
331331

332+
If Claude Desktop's footer picker does not change the model for an already-running 3P
333+
conversation, use `/model <id>` in that conversation. OpenCodex routes the model id carried by
334+
each request; **Logs → requestedModel** shows which id Desktop actually sent.
335+
332336
```bash
333337
ocx claude desktop [apply] # save and apply the current profile
334338
ocx claude desktop show [--json] # inspect routes, families, and defaults

docs-site/src/content/docs/guides/claude-code.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ capabilities (reasoning-effort ladder, thinking types) in the official ModelInfo
151151
Desktop's third-party gateway mode can offer its effort selector. Real Anthropic models keep their
152152
canonical ids. The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases
153153
and `claude-ocx-<provider>--<model>` ids from older configs still resolve.
154+
155+
If Claude Desktop's footer picker does not change the model for an already-running 3P
156+
conversation, use `/model <id>` in that conversation. OpenCodex cannot observe picker state; it
157+
routes the model id carried by each request. Confirm the result under **Logs → requestedModel**.
158+
154159
Models with an authoritative 1M context window get an extra `…[1m]` picker row: selecting it makes
155160
Claude Code account a full 1M context for that model (auto-compaction stays on) — the proxy strips
156161
the marker before routing.

docs-site/src/content/docs/ja/guides/claude-code.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイ
7777
提供します。両系列は継続してデコードできるため、どちらの形式でも `settings.json` に保存したモデルは
7878
引き続き動作します。
7979

80+
Claude Desktop のフッターピッカーで実行中の 3P 会話のモデルが切り替わらない場合は、その会話で
81+
`/model <id>` を使用してください。OpenCodex はピッカーの状態を直接参照できず、各リクエストに
82+
含まれるモデル ID をルーティングします。結果は **Logs → requestedModel** で確認できます。
83+
8084
**エイリアス構文ルール:** provider には `/``--` を含められず `native` と同じでもいけません。model には
8185
`/` を含められません。読みやすい形式で表現できないルートはハッシュエイリアスに置き換えます。モデル
8286
ID には `--` を含め**られます**(解析時は最初の `--` だけを基準に分割します)。`--` を含む

docs-site/src/content/docs/ko/guides/claude-code.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ Claude Code 2.1.129 이상은 `GET /v1/models?limit=1000`에서 게이트웨이
112112
제공해요. 두 계열은 계속 디코딩할 수 있으므로 어느 형식이든 `settings.json`에 저장한 모델이
113113
계속 작동해요.
114114

115+
Claude Desktop의 하단 선택기로 이미 실행 중인 3P 대화의 모델이 바뀌지 않는다면, 그 대화에서
116+
`/model <id>`를 사용하세요. OpenCodex는 선택기 상태를 따로 볼 수 없고 각 요청에 실린 모델 ID를
117+
라우팅해요. 적용 결과는 **Logs → requestedModel**에서 확인할 수 있어요.
118+
115119
**별칭 문법 규칙:** provider에는 `/``--`를 넣을 수 없고 `native`와 같아도 안 돼요. model에는
116120
`/`를 넣을 수 없어요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델
117121
ID에는 `--`를 넣을 **수 있어요**(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된

docs-site/src/content/docs/ru/guides/claude-code.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а
8282
семейства декодируются бессрочно — модель, сохранённая в `settings.json` в любой из форм,
8383
продолжает работать.
8484

85+
Если нижний селектор Claude Desktop не переключает модель в уже запущенном 3P-диалоге,
86+
используйте `/model <id>` внутри этого диалога. OpenCodex не видит состояние селектора и
87+
маршрутизирует id модели из каждого запроса. Результат можно проверить в **Logs → requestedModel**.
88+
8589
**Правила грамматики алиасов:** provider не может содержать `/` или `--` и не может быть равен
8690
`native`; model не может содержать `/`. Маршруты, которые невозможно выразить читаемой формой,
8791
откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении деление

docs-site/src/content/docs/zh-cn/guides/claude-code.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ opencodex 会将已路由模型公开为稳定且可逆的别名:
8484
user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈希形式。两种别名族都会永久
8585
保持可解码——以任一形式保存在 `settings.json` 中的模型都能继续工作。
8686

87+
如果 Claude Desktop 底部的选择器没有切换已运行 3P 对话的模型,请在该对话中使用
88+
`/model <id>`。OpenCodex 无法读取选择器状态,只会路由每个请求实际携带的模型 ID;可在
89+
**Logs → requestedModel** 中确认结果。
90+
8791
**别名语法规则:**provider 不得包含 `/``--`,也不得等于 `native`;model 不得包含
8892
`/`。易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个
8993
`--` 拆分);包含 `--` 的原生 slug 会回退到哈希形式。

src/server/request-log.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export interface RequestLogContext {
7777
upstreamError?: string;
7878
/** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
7979
terminalHttpStatus?: number;
80+
/** Structured reason from `response.incomplete`; internal-only input to log classification. */
81+
terminalIncompleteReason?: string;
8082
affinity?: "reused" | "new_bind" | "rebound" | "cleared";
8183
transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
8284
terminalSource?: "upstream" | "synthetic";
@@ -532,7 +534,7 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
532534
* run it through redactSecretString so secrets never reach /api/logs. Pure; safe on any text.
533535
*/
534536
function captureUpstreamError(logCtx: RequestLogContext, text: string | null): void {
535-
if (!text || logCtx.upstreamError) return;
537+
if (!text) return;
536538
try {
537539
const json = JSON.parse(text) as {
538540
type?: unknown;
@@ -544,6 +546,14 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
544546
};
545547
};
546548
captureTerminalHttpStatus(logCtx, json);
549+
const reason = json?.response?.incomplete_details?.reason;
550+
if (json.type === "response.incomplete"
551+
&& logCtx.terminalIncompleteReason === undefined
552+
&& typeof reason === "string"
553+
&& reason.trim()) {
554+
logCtx.terminalIncompleteReason = reason.trim();
555+
}
556+
if (logCtx.upstreamError) return;
547557
const message = json?.error?.message
548558
?? json?.last_error?.message
549559
?? json?.response?.error?.message;
@@ -555,11 +565,11 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
555565
// the bridge on a stall-timeout or adapter EOF (response.incomplete). Maps the raw reason to a
556566
// reader-facing label so a generic 502 in /api/logs explains WHY the turn ended, not just the
557567
// mapped HTTP code.
558-
const reason = json?.response?.incomplete_details?.reason;
559568
if (typeof reason === "string" && reason.trim()) {
560569
logCtx.upstreamError = redactSecretString(incompleteReasonLabel(reason.trim())).slice(0, 500);
561570
}
562571
} catch {
572+
if (logCtx.upstreamError) return;
563573
const trimmed = text.trim();
564574
if (trimmed) {
565575
logCtx.upstreamError = redactSecretString(trimmed).slice(0, 500);
@@ -570,6 +580,8 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
570580
/** Map a raw `incomplete_details.reason` (emitted by the bridge) to a reader-facing label. */
571581
function incompleteReasonLabel(reason: string): string {
572582
switch (reason) {
583+
case "max_output_tokens":
584+
return `Output reached the requested token limit (${reason})`;
573585
case "upstream_stall_timeout":
574586
return `Upstream stalled: no data for the stall-timeout window (${reason})`;
575587
case "adapter_eof":
@@ -614,6 +626,21 @@ export function httpStatusForRequestLogTerminal(
614626
status: ResponsesTerminalStatus,
615627
logCtx?: RequestLogContext,
616628
): number {
629+
/**
630+
* [Decision Log]
631+
* - 목적과 의도: Keep request logs aligned with the successful HTTP/SSE contract.
632+
* - 기존 구현 및 제약 조건: All incomplete terminals were recorded as 502 even when the
633+
* client-requested output limit was reached normally.
634+
* - 검토한 주요 대안: Treat every incomplete as success, or infer the reason from display text.
635+
* - 선택한 방식: Only structured max_output_tokens incompletes map to 200.
636+
* - 다른 대안 대신 이 방식을 선택한 이유: Stall, EOF, and unknown incompletes must remain
637+
* visible failures, and display text is not a stable classification contract.
638+
* - 장점, 단점 및 영향: Logs stop reporting false upstream errors while retaining the
639+
* incomplete terminal detail; native callers without a structured reason keep old behavior.
640+
*/
641+
if (status === "incomplete" && logCtx?.terminalIncompleteReason === "max_output_tokens") {
642+
return 200;
643+
}
617644
if (status === "failed" && logCtx?.terminalHttpStatus !== undefined) {
618645
return logCtx.terminalHttpStatus;
619646
}

tests/request-log.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,42 @@ describe("request log metadata", () => {
986986
expect(entries[0].upstreamError).toContain("Upstream stalled");
987987
});
988988

989+
test("deferred SSE logging treats the requested output limit as a successful terminal", async () => {
990+
const entries: RequestLogEntry[] = [];
991+
const incompletePayload = JSON.stringify({
992+
type: "response.incomplete",
993+
response: {
994+
status: "incomplete",
995+
incomplete_details: { reason: "max_output_tokens" },
996+
usage: { input_tokens: 9, output_tokens: 64 },
997+
},
998+
});
999+
const body = new ReadableStream<Uint8Array>({
1000+
start(controller) {
1001+
controller.enqueue(new TextEncoder().encode(`data: ${incompletePayload}\n\n`));
1002+
controller.close();
1003+
},
1004+
});
1005+
const response = responseWithDeferredRequestLog(
1006+
new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }),
1007+
"ocx-test-requested-output-limit",
1008+
Date.now(),
1009+
{ model: "anthropic/claude-sonnet-5", provider: "anthropic" },
1010+
entry => entries.push(entry),
1011+
);
1012+
1013+
await response.text();
1014+
expect(entries).toHaveLength(1);
1015+
expect(entries[0]).toMatchObject({
1016+
terminalStatus: "incomplete",
1017+
status: 200,
1018+
usageStatus: "reported",
1019+
usage: { inputTokens: 9, outputTokens: 64 },
1020+
upstreamError: "Output reached the requested token limit (max_output_tokens)",
1021+
});
1022+
expect(entries[0]).not.toHaveProperty("errorCode");
1023+
});
1024+
9891025
test("deferred SSE logging surfaces adapter_eof reason as upstreamError", async () => {
9901026
const entries: RequestLogEntry[] = [];
9911027
const incompletePayload = JSON.stringify({

0 commit comments

Comments
 (0)