fix: improve GPT compatibility and request logs - #4
Conversation
voidsteed
left a comment
There was a problem hiding this comment.
Thanks for this — the max_completion_tokens routing and the strict: false tool fix are real bugs caught correctly, and I want both.
I reviewed the branch locally (typecheck + bun test both pass, 153/153) and also had a second model review it independently. We converged on the same set of issues, plus it found a crash I'd missed. Details inline; summary of what blocks a merge:
Blocking
padColumncrashes the response body on non-string metadata — client-triggerable, corrupts the response and swallows the log line. This is the most serious one.- Requests whose body is never consumed produce zero log lines (
HEAD /on this server is a live example).hono/loggerlogged unconditionally. MIN_RESPONSES_OUTPUT_TOKENSsilently raises a client-suppliedmax_tokens, undetectably.MODEL_COLUMN_WIDTH = 14rendersclaude-sonnet-5andclaude-sonnet-4.6identically. On a proxy whose core job is model-name mapping, that's the one column that can't collide.
Non-blocking, worth a look: 2 MB response-body buffering to read one usage field; the consola.box/consola.info → debug demotions are a deliberate UX change that deserves a line in the PR description; isRecord returns true for arrays.
The branch also needs a rebase — it's based on v0.10.28 and conflicts with master in responses-bridge.ts, responses/types.ts, server.ts, create-responses.ts, and start.ts.
If you'd rather land the wins fast: split the max_completion_tokens + strict: false + cached-usage fixes into their own PR and I'll merge that quickly, leaving the logging rewrite to iterate separately. Happy either way.
| return typeof value === "object" && value !== null | ||
| } | ||
|
|
||
| function padColumn(value: string, width: number): string { |
There was a problem hiding this comment.
Blocking — this crashes the response body for any client that sends a non-string model or effort.
value.slice is called with no type guard. Both ResponsesApiRequest["model"] and reasoning.effort are TypeScript-only types on an unvalidated c.req.json<T>() cast, so nothing stops a client sending:
{"model":"gpt-5.5","input":"hello","reasoning":{"effort":123}}Reproduced against the real server:
status=400 bodyErr=value.slice is not a function body="" logs=0
The exception escapes through the response observer and errors the stream: the client gets an empty body instead of its response, and no log line is written either. A numeric model does the same.
Two things needed:
- Coerce/guard here (
String(value), or reject non-strings insetRequestLogMetadata). - Wrap the
finishRequestLogcall sites in try/catch. Observability code must never be able to break response delivery — even with the guard, that invariant is worth enforcing structurally.
| } | ||
|
|
||
| const response = c.res | ||
| if (!response.body) { |
There was a problem hiding this comment.
Blocking — requests whose response body is never consumed emit no log line at all.
Every log write happens inside the observed stream's pull/cancel. If nobody reads the body, neither fires. !response.body covers the null case, but not "body exists and is dropped unread."
Concrete on this server: HEAD / returns 200 and logs nothing, because Hono discards the body. hono/logger logged both an incoming and an outgoing line without consuming anything.
Suggest logging from the middleware body after await next() for the non-streaming case, and reserving the stream-observer path for when you actually need end-of-stream usage numbers — with the finished flag still guarding against a double write.
| const MAX_JSON_BODY_CHARS = 2_000_000 | ||
| const MAX_SSE_LINE_CHARS = 512_000 | ||
| const ROUTE_COLUMN_WIDTH = 26 | ||
| const MODEL_COLUMN_WIDTH = 14 |
There was a problem hiding this comment.
Blocking — 14 chars makes the most important column ambiguous.
Measured against the model names this proxy actually maps to:
| model | rendered |
|---|---|
claude-sonnet-5 |
claude-sonnet… |
claude-sonnet-4.6 |
claude-sonnet… |
claude-opus-4.6 |
claude-opus-4… |
claude-haiku-4.5 |
claude-haiku-… |
Sonnet 5 and Sonnet 4.6 are indistinguishable, and the version suffix is exactly what someone reads this log to confirm — it's the output of translateModelName(), the mapping in CLAUDE.md that this proxy exists to perform.
18 fits every name in that table. Alternatively truncate from the middle to always preserve the suffix.
|
|
||
| import { state } from "./state" | ||
|
|
||
| const MAX_JSON_BODY_CHARS = 2_000_000 |
There was a problem hiding this comment.
Non-blocking: this buffers up to 2 MB of every non-SSE response body into a string purely to read usage. /v1/models and non-streaming completions all get fully copied.
Since the only consumer is mergeResponseMetrics, consider capping this far lower for non-streaming JSON, or skipping observation entirely on routes that never carry usage.
| writeSSE(event: { data: string; event: string }): Promise<void> | ||
| } | ||
|
|
||
| const MIN_RESPONSES_OUTPUT_TOKENS = 16 |
There was a problem hiding this comment.
Blocking — this silently raises a limit the client explicitly set, with no way to detect it.
max_tokens: 1 through /v1/messages becomes max_output_tokens: 16 upstream; every value 1–15 is rewritten, streaming and non-streaming alike. The client is never told its cap was overridden.
The modified test makes this the specification (max_tokens: 1 in → max_output_tokens: 16 asserted), which is what I'd push back on — it encodes the surprising behavior rather than the intended one.
This is the same class of problem as #5: the proxy pre-emptively rewriting a client value on a guess about what upstream will accept. If Copilot genuinely rejects max_output_tokens < 16, I'd rather let that rejection through, or clamp and log loudly. Silent widening is the one option that leaves the caller unable to reason about what it asked for.
What was the upstream error that motivated this? If you have the response body I'll take a look — there may be a narrower fix.
6788f01 to
f372551
Compare
|
I've pushed the review fixes directly to this branch (maintainer edits were enabled) rather than leaving you to do the round-trip — your commit is rebased onto master with authorship intact, and my changes are a separate commit on top. Conflicts are resolved and the PR is mergeable again. Rebase: the only real conflict was in
1. While testing that I found two related injection paths, both now closed by stripping control characters:
2. Unread bodies. Non-streaming responses are logged when the handler returns rather than when the body drains. 3. Model column 14 → 18. 4. Removed Added 6 regression tests ( Not changed, still yours if you want them: the 2 MB non-SSE body buffering (much less pressing now that the buffered path reads an already-complete body), and The |
Normalize GPT-5 token limits, preserve non-strict Anthropic tool semantics on Responses models, and add compact model/token/cache/timing request summaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review follow-ups to the request-log middleware, plus removal of the
Responses output-token floor.
padColumn() called .slice() on values that are only structurally typed by a
c.req.json<T>() cast, so `{"reasoning":{"effort":123}}` threw inside the
response-body observer — the client got an empty body instead of its response,
and no log line was written either. It now coerces non-strings and strips
control characters, which also closes two injection paths: a newline in `model`
could forge an additional log line, and an ESC could repaint the operator's
terminal. finishRequestLog() is wrapped so observability can never break
request handling regardless.
Non-streaming responses are now logged when the handler returns rather than
when their body is drained. Nothing reads the body of a `HEAD /`, so the
end-of-stream observer never fired and the request vanished from the log;
hono/logger emitted it unconditionally. SSE still goes through the streaming
observer, which is where incremental usage parsing is actually needed.
MODEL_COLUMN_WIDTH 14 -> 18: `claude-sonnet-5` and `claude-sonnet-4.6` both
rendered as `claude-sonnet…`, hiding exactly what translateModelName() chose.
MIN_RESPONSES_OUTPUT_TOKENS is removed. It silently raised any max_tokens below
16, so a client could not reason about the cap it set. If Copilot rejects a
small value, that rejection is the honest answer and now reaches the client.
Rebased onto master; the responses-bridge conflict keeps both sides (max_tokens
stop_reason from master, cached-token accounting from this branch).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f372551 to
509a6ce
Compare
Summary
max_completion_tokenswhile preserving legacy model behaviorValidation
bun run verifybun run buildgpt-5.6-solMCP tool call completed successfully with full arguments