Skip to content

feat(timing): split TTFT into api-server and client portions - #1228

Merged
RealKai42 merged 2 commits into
mainfrom
kaiyi/el-paso-v1
Jun 30, 2026
Merged

feat(timing): split TTFT into api-server and client portions#1228
RealKai42 merged 2 commits into
mainfrom
kaiyi/el-paso-v1

Conversation

@RealKai42

Copy link
Copy Markdown
Collaborator

Why

Time-to-first-token (TTFT) previously lumped together two very different things: the in-process work kimi-code does to build a request (serializing the message history, assembling params) and the network + API-server latency until the first token. When a turn felt slow, there was no way to tell whether the client or the server was responsible — exactly the question that came up while investigating slow sessions.

What

Add an onRequestSent hook to kosong's GenerateOptions, fired by every provider (kimi, openai-legacy, openai-responses, anthropic, google-genai) immediately before it dispatches the network call. TTFT is then split across that boundary:

  • client = request start → dispatch (message serialization, param build)
  • api = dispatch → first streamed token (network + server)

The split flows through step.end / turn.step.completed (and therefore wire.jsonl) and shows up in three places:

  • KIMI_CODE_DEBUG=1: TTFT: 2.5s (api 2.4s + client 100ms)
  • session log: new llm response line with ttftMs / requestBuildMs / serverFirstTokenMs / streamDurationMs / outputTokens
  • vis: firstToken/api + firstToken/client detail rows and a timeline label

When a provider does not report the boundary, the split is omitted and only the total TTFT is shown — fully backward compatible.

Boundary note

The provider SDKs' own JSON serialization happens inside create(), so it is counted in the api portion (typically tens of ms). This is the known trade-off of placing the marker just before the SDK call rather than wrapping fetch.

Test

  • Full typecheck passes across all packages
  • lint: 0 errors
  • New/updated unit tests: TTFT split display + fallback (debug-timing), split computation + graceful degradation when no boundary is reported (kosong-llm), snapshot normalizer, protocol type assertions, vis analysis
  • Full suite: the only failures are pre-existing and unrelated to this change (cli/update/preflight.test.ts — confirmed failing on the base via stash; packages/server/test/fileLaunch.test.ts — a Windows-path assertion in an untouched package)

Time-to-first-token previously lumped in-process request building
(message serialization, param assembly) together with network + server
latency, making it impossible to tell whether a slow turn was the client
or the API server.

Add an `onRequestSent` hook to kosong's GenerateOptions, fired by every
provider immediately before it dispatches the network call. The window
from request start to dispatch is attributed to the client; the window
from dispatch to the first streamed token is attributed to the API
server. The split flows through the step.end / turn.step.completed
events (and therefore wire.jsonl) and is surfaced in three places:

- KIMI_CODE_DEBUG=1: `TTFT: 2.5s (api 2.4s + client 100ms)`
- session log: new `llm response` line with the timing breakdown
- vis: firstToken/api + firstToken/client rows and timeline label

The split is omitted (total only) when a provider does not report the
boundary, preserving backward compatibility.
@changeset-bot

changeset-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fea78a5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 30, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@fea78a5
npx https://pkg.pr.new/@moonshot-ai/kimi-code@fea78a5

commit: fea78a5

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70b5469b85

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const timing = response.streamTiming;
if (timing === undefined) return;
log.info('llm response', {
turnStep: `${turnId}/${String(step)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use consistent turnStep formatting

For session log consumers that correlate the new llm response record with existing llm config/llm request records, this emits turnStep as turnId/step while the existing request logger uses turnId.step (paramsForAttempt produces values like 0.1). That makes the new timing line fail exact-key joins/filtering with the rest of the LLM log entries; emit the same dot-formatted value here.

Useful? React with 👍 / 👎.

Comment on lines +200 to +203
...(timing.requestBuildMs !== undefined ? { requestBuildMs: timing.requestBuildMs } : {}),
...(timing.serverFirstTokenMs !== undefined
? { serverFirstTokenMs: timing.serverFirstTokenMs }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass optional timing fields directly

The repository-level /workspace/kimi-code/AGENTS.md says optional object properties should be passed as undefined directly instead of using conditional spreads. These newly added log fields use conditional spreads for optional values, so this patch violates the local rule; make them direct properties such as requestBuildMs: timing.requestBuildMs to keep the object shape consistent.

Useful? React with 👍 / 👎.

Time-to-first-token now reports a client/server split, but the slow part
of a long turn is the decode window (inter-token streaming), which was
still a single opaque number. Profiling long sessions showed decode
throughput halving over a session's lifetime independent of context
size, which the synchronous per-chunk stream pipeline can cause: kosong
awaits the host callback for every streamed part, so a loaded main
thread throttles how fast tokens are pulled off the wire.

Account for this directly in the stream loop: the time awaiting the next
part (server + network) versus the time spent processing each part
in-process (deep copy, host callback, part merge). The split is reported
through onStreamEnd and flows through the step.end / turn.step.completed
events (and wire.jsonl) into the same three surfaces as the TTFT split:

- KIMI_CODE_DEBUG=1: `TPS: 40.0 tok/s (200 tokens in 5.0s; server 4.6s + client 400ms)`
- session log: serverDecodeMs / clientConsumeMs on the `llm response` line
- vis: streamDuration/server + streamDuration/client rows and timeline label

A large, growing client share confirms host-side throttling; a dominant
server share points at the server/connection. The per-chunk accounting is
wrapped in try/finally so it stays correct across `continue` and aborts,
and is omitted when the stream reports nothing.
@RealKai42
RealKai42 merged commit 42e37eb into main Jun 30, 2026
9 checks passed
@RealKai42
RealKai42 deleted the kaiyi/el-paso-v1 branch June 30, 2026 11:15
@github-actions github-actions Bot mentioned this pull request Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant