Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 64 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ describe("ClaudeAdapterV2 context usage", () => {
output_tokens: 1,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 },
},
CLAUDE_TEST_MODEL_SELECTION,
null,
"2026-08-29T00:03:00.000Z",
"",
DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"),
Expand Down Expand Up @@ -2990,6 +2990,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
readonly cache_creation_input_tokens: number;
readonly cache_read_input_tokens: number;
};
readonly modelUsage?: SDKResultMessage["modelUsage"];
}) =>
claudeSdkFrame({
type: "result",
Expand All @@ -3007,7 +3008,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
modelUsage: {},
modelUsage: input.modelUsage ?? {},
permission_denials: [],
uuid: input.uuid,
session_id: WAKE_NATIVE_SESSION,
Expand Down Expand Up @@ -7117,6 +7118,67 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("measures a model outside the catalog against the window Claude reports", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const modelSelection = {
instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
model: "gateway-model",
} satisfies ModelSelection;
assert.strictEqual(harness.runtime.getModelContextWindow?.(modelSelection), undefined);
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-reported-context-window"),
text: "Continue.",
attachments: [],
modelSelection,
}),
);
const usage = { ...zeroUsage, input_tokens: 9_667, output_tokens: 2 };
const frames = [
usageStreamFrame({ type: "message_start", message: { id: "msg_gateway", usage } }),
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000680",
result: "OK",
modelUsage: {
"gateway-model": {
inputTokens: 9_667,
outputTokens: 2,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 1_048_576,
maxOutputTokens: 32_000,
},
},
}),
];
for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
const terminal = yield* Queue.take(harness.terminalReceipts);
assert.equal(terminal.status, "completed");
const windows = harness.events.flatMap((event) =>
event.type === "provider_turn.updated" && event.providerTurn.tokenUsage
? [event.providerTurn.tokenUsage.maxTokens]
: [],
);
assert.deepEqual(windows, [null, 1_048_576]);
assert.strictEqual(harness.runtime.getModelContextWindow?.(modelSelection), 1_048_576);
}).pipe(
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
);

it.effect("merges cumulative stream usage into the message_start counts", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarnessWithOptions({
Expand Down
32 changes: 21 additions & 11 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
addClaudeLedgerUsage,
claudeLedgerDelta,
claudeModelLedger,
claudeReportedContextWindows,
normalizeClaudeTurnTokenUsage,
type ClaudeLedgerUsage,
type ClaudeModelLedger,
Expand Down Expand Up @@ -221,12 +222,6 @@ import {
export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent");
const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const;

function claudeContextWindow(modelSelection: ModelSelection): number | null {
return (
resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null
);
}

interface ClaudeCacheCreation {
readonly ephemeral_1h_input_tokens: number;
readonly ephemeral_5m_input_tokens: number;
Expand Down Expand Up @@ -273,15 +268,14 @@ export function claudeProviderTurnTokenUsage(
readonly cache_creation?: ClaudeCacheCreation | null;
readonly output_tokens: number;
},
modelSelection: ModelSelection,
maxTokens: number | null,
updatedAt: string,
autoCompactWindow = "",
requestedAt?: DateTime.Utc,
) {
const inputTokens = claudeMessageInputTokens(usage);
const outputTokens = usage.output_tokens;
const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation);
const maxTokens = claudeContextWindow(modelSelection);
// The cache lifetime starts with the request, not when its response ends.
const promptCacheExpiresAt =
promptCacheTtlMs === undefined || requestedAt === undefined
Expand Down Expand Up @@ -4620,6 +4614,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
BUNDLED_CLAUDE_MODEL_CATALOG,
adapterOptions.settings.customModels,
);
// Results report the window Claude Code compacts against; outside the catalog it is the only source.
const reportedContextWindows = new Map<string, number>();
const claudeContextWindow = (modelSelection: ModelSelection): number | null =>
resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ??
reportedContextWindows.get(compileClaudeModelSelection(modelSelection).apiModelId) ??
null;
const continuationRequests = adapterOptions.continuationRequests ?? {
offer: () => Effect.void,
};
Expand Down Expand Up @@ -4852,6 +4852,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null);
// Wake buffers replay result frames, so ledger growth is measured once on arrival.
const resultLedgerUsage = new WeakMap<SDKMessage, ClaudeLedgerUsage>();
const resultsReportingNewContextWindow = new WeakSet<SDKMessage>();
// A worktree handoff changes the project settings even within one session.
let bypassCache: { cwd: string | null; availability: ClaudeBypassAvailability } | null =
null;
Expand Down Expand Up @@ -5147,7 +5148,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now;
const tokenUsage = claudeProviderTurnTokenUsage(
usage,
context.input.modelSelection,
claudeContextWindow(context.input.modelSelection),
DateTime.formatIso(now),
adapterOptions.settings.autoCompactWindow,
context.streamUsage?.requestedAt,
Expand Down Expand Up @@ -9584,6 +9585,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}

if (message.type === "result") {
if (resultsReportingNewContextWindow.has(message) && context.streamUsage?.usage) {
yield* emitContextUsage(context, context.streamUsage.usage);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const completedAt = yield* DateTime.now;
const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId);
Expand Down Expand Up @@ -10646,6 +10650,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
growth === undefined && context.modelLedger !== undefined
? undefined
: ledger;
for (const [model, window] of claudeReportedContextWindows(
message.modelUsage,
)) {
if (reportedContextWindows.get(model) === window) continue;
reportedContextWindows.set(model, window);
resultsReportingNewContextWindow.add(message);
}
}
return handleSdkMessage({ query: querySession, message });
}),
Expand Down Expand Up @@ -11348,8 +11359,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
driver: CLAUDE_PROVIDER,
providerSessionId: input.providerSessionId,
providerSession: session,
getModelContextWindow: (selection) =>
resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection),
getModelContextWindow: (selection) => claudeContextWindow(selection) ?? undefined,
events: Stream.fromEffectRepeat(Queue.take(events)),
backgroundTasksEndWithProcess: true,
hasPendingBackgroundWork: Effect.gen(function* () {
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/provider/ClaudeTurnTokenUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | unde
return ledger;
}

/** The window each model in the SDK's `modelUsage` is measured against, as Claude Code resolved it. */
export function claudeReportedContextWindows(modelUsage: unknown): ReadonlyMap<string, number> {
const windows = new Map<string, number>();
if (typeof modelUsage !== "object" || modelUsage === null) return windows;
for (const [model, entry] of Object.entries(modelUsage as Record<string, unknown>)) {
const window = finiteNonNegativeInteger(
(entry as Record<string, unknown> | null)?.contextWindow,
);
if (window !== undefined && window > 0) windows.set(model, window);
}
return windows;
}

/** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */
export function claudeLedgerDelta(
previous: ClaudeModelLedger | undefined,
Expand Down
Loading