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
21 changes: 19 additions & 2 deletions src/lib/token-estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,32 @@ function cjkRatio(text: string): number {
return sampled === 0 ? 0 : cjk / sampled;
}

/**
* Cap an estimated token count at a model's context window (codex-router PR #140).
*
* A request the provider answered cannot have exceeded the window, so the estimate is never
* allowed to claim it did: the estimate only ever substitutes a reported-zero (or missing)
* input count, and a value above the window would be provably false. Positive or missing
* reported counts are never rewritten here — this bounds the heuristic estimate itself.
* Non-positive or non-integer windows (unknown model) leave the estimate untouched.
*/
export function capEstimateAtContextWindow(estimate: number, contextWindow: number | undefined): number {
return typeof contextWindow === "number" && Number.isInteger(contextWindow) && contextWindow > 0
? Math.min(estimate, contextWindow)
: estimate;
}

/**
* Estimate the token count of a text blob. Pure and deterministic.
* Returns 0 for empty/whitespace-free-empty input; otherwise ceil(length / ratio), min 1.
* When `contextWindow` is a positive integer the estimate is capped at it: a request the
* provider answered cannot have exceeded the window, so the estimate must not claim it did.
*/
export function estimateTokens(text: string, modelId?: string): number {
export function estimateTokens(text: string, modelId?: string, contextWindow?: number): number {
if (!text) return 0;
const len = text.length;
if (len === 0) return 0;
let ratio = charsPerToken(modelId);
if (cjkRatio(text) > CJK_RATIO_THRESHOLD) ratio = Math.min(ratio, CJK_CHARS_PER_TOKEN);
return Math.max(1, Math.ceil(len / ratio));
return capEstimateAtContextWindow(Math.max(1, Math.ceil(len / ratio)), contextWindow);
}
40 changes: 38 additions & 2 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import {
} from "../usage/debug";
import { matchesLogConversationId } from "./request-log-conversation";
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
import { capEstimateAtContextWindow } from "../lib/token-estimate";
import { inferCursorContextWindow } from "../adapters/cursor/discovery";
import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
import { modelRecordValue } from "../reasoning-effort";

export interface RequestLogContext {
model: string;
Expand Down Expand Up @@ -839,6 +843,7 @@ export function addFinalRequestLog(
logCtx.providerAdapter ?? logCtx.provider,
logCtx.usage,
logCtx.usageLogInputTokens,
contextWindowForModel(logCtx.providerAdapter ?? logCtx.provider, logCtx.model),
);
const attempts = logCtx.attempts?.map(attempt => ({
...attempt,
Expand Down Expand Up @@ -960,15 +965,40 @@ interface FinalizedUsageResult {
totalTokens?: number;
}

/**
* Context window for the routed model, used to cap the token estimate (codex-router PR #140):
* a request the provider answered cannot have exceeded the window, so the estimate must never
* claim it did. The family is picked by the route ADAPTER, not the model id alone, because
* claude-family ids are shared between Kiro and Cursor with different windows. Kiro "auto" is
* a router with no fixed window and is never guessed; unknown adapters/models stay uncapped.
*/
function contextWindowForModel(adapter: string, modelId: string | undefined): number | undefined {
if (!modelId) return undefined;
if (adapter === "kiro" || adapter.startsWith("kiro-")) {
const normalized = normalizeKiroModelId(modelId);
if (normalized === "auto") return undefined;
return modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, modelId)
?? modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, normalized);
}
if (adapter === "cursor" || adapter.startsWith("cursor-")) {
return inferCursorContextWindow(modelId);
}
return undefined;
}

function finalizedUsage(
adapter: string,
usage: OcxUsage | undefined,
inputTokenEstimate: number | undefined,
contextWindow: number | undefined,
): FinalizedUsageResult {
// The ESTIMATE itself is capped at the model's context window (codex-router PR #140). The
// combined value below keeps its max(inputTokens, estimate) behavior — a provider-reported
// positive count is never reduced by this cap, only the estimate that could substitute it.
const estimate = typeof inputTokenEstimate === "number"
&& Number.isFinite(inputTokenEstimate)
&& inputTokenEstimate >= 0
? inputTokenEstimate
? capEstimateAtContextWindow(inputTokenEstimate, contextWindow)
: undefined;
const finalUsage = usageForFinalLog(adapter, usage);
const usageFallback = !finalUsage && estimate !== undefined
Expand Down Expand Up @@ -1030,7 +1060,12 @@ export function noteAttemptSend(
if (typeof inputTokenEstimate === "number"
&& Number.isFinite(inputTokenEstimate)
&& inputTokenEstimate >= 0) {
attempt.inputTokenEstimate = inputTokenEstimate;
// Store the ESTIMATE field already capped at the model's window (codex-router PR #140):
// what gets persisted, and later merged into usage, never claims a count above the window.
attempt.inputTokenEstimate = capEstimateAtContextWindow(
inputTokenEstimate,
contextWindowForModel(attempt.adapter, attempt.model),
);
}
if (recovery && !attempt.recoveryKinds.includes(recovery)) {
attempt.recoveryKinds.push(recovery);
Expand All @@ -1047,6 +1082,7 @@ export function finishRequestAttempt(
attempt.adapter,
usage ?? attempt.usage,
attempt.inputTokenEstimate,
contextWindowForModel(attempt.adapter, attempt.model),
);
attempt.status = status;
attempt.durationMs = Math.max(0, durationMs);
Expand Down
99 changes: 99 additions & 0 deletions tests/request-log-estimate-cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
import {
beginRequestAttempt,
finishRequestAttempt,
noteAttemptSend,
} from "../src/server/request-log";

/**
* The token-estimate context-window cap (codex-router PR #140) in the request log:
* the ESTIMATE field is bounded by the routed model's context window, while the combined
* inputTokens field keeps its max(reported, estimate) behavior. Provider-reported positive
* counts are never reduced by the cap.
*/
describe("request log token-estimate context-window cap (codex-router PR #140)", () => {
const DEEPSEEK_WINDOW = 128_000; // KIRO_MODEL_CONTEXT_WINDOWS["deepseek-3.2"]
const CURSOR_CLAUDE_WINDOW = 200_000; // inferCursorContextWindow("claude-4.6-opus-high")

test("the estimate is capped at the kiro model's context window", () => {
const attempt = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(attempt, 200_000);
finishRequestAttempt(attempt, 200, 10);
expect(attempt.inputTokenEstimate).toBe(DEEPSEEK_WINDOW);
expect(attempt.usage).toEqual({ inputTokens: DEEPSEEK_WINDOW, outputTokens: 0, estimated: true });
});

test("below-window estimates pass through unchanged", () => {
const attempt = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(attempt, 100_000);
finishRequestAttempt(attempt, 200, 10);
expect(attempt.inputTokenEstimate).toBe(100_000);
expect(attempt.usage?.inputTokens).toBe(100_000);
});

test("a positive provider-reported input count is never reduced by the cap", () => {
const attempt = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(attempt, 200_000); // estimate would exceed the window
finishRequestAttempt(attempt, 200, 10, { inputTokens: 150_000, outputTokens: 500 });
// combined = max(reported 150k, capped estimate 128k) = 150k: the reported count wins.
expect(attempt.usage?.inputTokens).toBe(150_000);
});

test("a missing report substitutes the capped estimate (the ESTIMATE field)", () => {
const attempt = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(attempt, 200_000);
finishRequestAttempt(attempt, 200, 10);
// No usage at all: the fallback inputTokens IS the capped estimate — never above the window.
expect(attempt.usage?.inputTokens).toBe(DEEPSEEK_WINDOW);
expect(attempt.usage?.estimated).toBe(true);
expect(attempt.usageStatus).toBe("estimated");
});

test("the ESTIMATE field vs the combined inputTokens field distinction is preserved", () => {
// Reported zero: combined max(0, capped estimate) equals the capped estimate.
const zero = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(zero, 200_000);
finishRequestAttempt(zero, 200, 10, { inputTokens: 0, outputTokens: 5 });
expect(zero.usage?.inputTokens).toBe(DEEPSEEK_WINDOW);

// Reported positive above the capped estimate: combined keeps the reported count.
const positive = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(positive, 200_000);
finishRequestAttempt(positive, 200, 10, { inputTokens: 200_000, outputTokens: 5 });
expect(positive.usage?.inputTokens).toBe(200_000);

// Reported positive below the capped estimate: combined takes the capped estimate.
const middle = beginRequestAttempt(1, "kiro", "deepseek-3.2", "kiro");
noteAttemptSend(middle, 100_000);
finishRequestAttempt(middle, 200, 10, { inputTokens: 50_000, outputTokens: 5 });
expect(middle.usage?.inputTokens).toBe(100_000);
});

test("cursor adapter uses the cursor window inference", () => {
const attempt = beginRequestAttempt(1, "cursor", "claude-4.6-opus-high", "cursor");
noteAttemptSend(attempt, 500_000);
finishRequestAttempt(attempt, 200, 10);
expect(attempt.inputTokenEstimate).toBe(CURSOR_CLAUDE_WINDOW);
});

test("unknown adapters stay uncapped (a window is never invented)", () => {
const attempt = beginRequestAttempt(1, "anthropic", "claude-sonnet-4-6", "anthropic");
noteAttemptSend(attempt, 500_000);
finishRequestAttempt(attempt, 200, 10);
expect(attempt.inputTokenEstimate).toBe(500_000);
});

test("without a window the existing max(reported, estimate) behavior is unchanged", () => {
const attempt = beginRequestAttempt(1, "anthropic", "claude-sonnet-4-6", "anthropic");
noteAttemptSend(attempt, 500_000);
finishRequestAttempt(attempt, 200, 10, { inputTokens: 100_000, outputTokens: 5 });
expect(attempt.usage?.inputTokens).toBe(500_000);
});

test("kiro auto has no fixed window and is never guessed", () => {
const attempt = beginRequestAttempt(1, "kiro", "kiro-auto", "kiro");
noteAttemptSend(attempt, 500_000);
finishRequestAttempt(attempt, 200, 10);
expect(attempt.inputTokenEstimate).toBe(500_000);
});
});
6 changes: 4 additions & 2 deletions tests/request-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,10 +1053,12 @@ describe("request log metadata", () => {
const text = await response.text();
expect(text).toContain("\"input_tokens\":9");
expect(entries).toHaveLength(1);
// The 240k estimate exceeds claude-sonnet-4.5's 200k window; a request the provider
// answered cannot have exceeded the window, so the estimate is capped (codex-router PR #140).
expect(entries[0]).toMatchObject({
usageStatus: "estimated",
totalTokens: 240_004,
usage: { inputTokens: 240_000, outputTokens: 4, estimated: true },
totalTokens: 200_004,
usage: { inputTokens: 200_000, outputTokens: 4, estimated: true },
});
});

Expand Down
41 changes: 40 additions & 1 deletion tests/token-estimate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { charsPerToken, estimateTokens } from "../src/lib/token-estimate";
import { capEstimateAtContextWindow, charsPerToken, estimateTokens } from "../src/lib/token-estimate";

describe("CJK-aware ratio (devlog 260712 B3)", () => {
const korean = "한국어 텍스트는 토큰 밀도가 높아서 영어 기준 추정이 과소계산됩니다 ".repeat(10);
Expand Down Expand Up @@ -61,3 +61,42 @@ describe("token-estimate sidecar", () => {
}
});
});

describe("context-window cap (codex-router PR #140)", () => {
const GPT56_SOL_WINDOW = 272_000;
const DEEPSEEK_WINDOW = 128_000;
// gpt-5.6-sol uses the generic 4-char ratio: 1.2M chars ~= 300k tokens, far over 272k.
const OVER_WINDOW_TEXT = "x".repeat(1_200_000);

test("estimate is capped at the model's context window", () => {
expect(estimateTokens(OVER_WINDOW_TEXT, "gpt-5.6-sol", GPT56_SOL_WINDOW)).toBe(GPT56_SOL_WINDOW);
expect(estimateTokens(OVER_WINDOW_TEXT, "gpt-5.6-sol", DEEPSEEK_WINDOW)).toBe(DEEPSEEK_WINDOW);
});

test("below-window estimates are unchanged", () => {
const text = "x".repeat(100_000); // ~28.5k tokens at the kiro ratio
const plain = estimateTokens(text, "gpt-5.6-sol");
expect(plain).toBeLessThan(GPT56_SOL_WINDOW);
expect(estimateTokens(text, "gpt-5.6-sol", GPT56_SOL_WINDOW)).toBe(plain);
});

test("only a positive integer window caps (unknown window leaves the estimate untouched)", () => {
const text = "x".repeat(100_000);
const plain = estimateTokens(text, "gpt-5.6-sol");
for (const window of [undefined, 0, -1, 1.5, Number.NaN]) {
expect(estimateTokens(text, "gpt-5.6-sol", window)).toBe(plain);
}
});

test("the min-1 floor survives the cap for tiny inputs", () => {
expect(estimateTokens("a", "claude-opus-4.8", 1)).toBe(1);
expect(estimateTokens("a", "claude-opus-4.8", GPT56_SOL_WINDOW)).toBe(1);
});

test("capEstimateAtContextWindow caps only at positive integer windows", () => {
expect(capEstimateAtContextWindow(300_000, GPT56_SOL_WINDOW)).toBe(GPT56_SOL_WINDOW);
expect(capEstimateAtContextWindow(100_000, GPT56_SOL_WINDOW)).toBe(100_000);
expect(capEstimateAtContextWindow(300_000, undefined)).toBe(300_000);
expect(capEstimateAtContextWindow(300_000, 0)).toBe(300_000);
});
});
Loading