Skip to content

Commit 2234f8d

Browse files
Merge branch 'main' into brendan/scim-user-provisioning
2 parents 260b789 + 3863a29 commit 2234f8d

18 files changed

Lines changed: 529 additions & 134 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)
12+
13+
### Fixed
14+
- Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367)
15+
1016
## [5.0.4] - 2026-06-18
1117

1218
### Changed

CONTRIBUTING.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ So you're interested in contributing to Sourcebot. Great! If you have any questi
44

55
## Understanding the Licenses
66

7-
First thing to know is that Sourcebot is not a side-project - it is a product of a company based in San Francisco who builds and maintain it professionally. For this reason, Sourcebot follows the [open core](https://en.wikipedia.org/wiki/Open-core_model) business model, which splits the codebase into two parts: **core** and **ee**, which each have their own license:
7+
First thing to know is that Sourcebot is not a side-project - it is a product of a company based in San Francisco who builds and maintains it professionally. For this reason, Sourcebot follows the [open core](https://en.wikipedia.org/wiki/Open-core_model) business model, which splits the codebase into two parts: **core** and **ee**, which each have their own license:
88

99
- **core** code is licensed under the [Functional Source License](https://fsl.software/), a
1010
mostly permissive non-compete license that converts to Apache 2.0 or MIT after two years. Code shipped in core (without ee) forms the [Sourcebot Community Edition (CE)](https://www.sourcebot.dev/pricing).
@@ -55,14 +55,11 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
5555

5656
After installing, set `CTAGS_COMMAND` in your `.env.development.local` to the path of the installed binary (e.g. `/usr/local/bin/universal-ctags`).
5757

58-
3. Install corepack:
58+
3. Install and enable Corepack so the repository uses the Yarn version pinned in `package.json`:
5959
```sh
6060
npm install -g corepack
61-
```
62-
63-
3. Install `yarn`:
64-
```sh
65-
npm install --global yarn
61+
corepack enable
62+
yarn --version
6663
```
6764

6865
3. Clone the repository with submodules:

install-ctags-macos.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ if ! command -v brew >/dev/null 2>&1; then
2323
exit 1
2424
fi
2525

26-
brew install autoconf automake pkg-config jansson
26+
brew install autoconf automake pkg-config jansson pcre2 libyaml
2727

2828
curl --retry 5 "https://codeload.github.com/universal-ctags/ctags/tar.gz/$CTAGS_VERSION" | tar xz -C /tmp
2929
cd /tmp/$CTAGS_ARCHIVE_TOP_LEVEL_DIR

packages/web/src/app/(app)/components/banners/upgradeAvailableBanner.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ export function UpgradeAvailableBanner({ id, dismissible, currentVersion, latest
1616
dismissible={dismissible}
1717
icon={<CircleArrowUp className="h-4 w-4 mt-0.5" />}
1818
title="New Sourcebot version available"
19-
description={`Upgrade from ${currentVersion} to ${latestVersion}.`}
19+
description={`Update from ${currentVersion} to ${latestVersion}.`}
2020
action={
2121
<Button asChild size="sm" variant="outline">
2222
<Link
2323
href="https://github.com/sourcebot-dev/sourcebot/releases/latest"
2424
target="_blank"
2525
rel="noreferrer"
2626
>
27-
Upgrade
27+
Update
2828
<ExternalLink className="h-3.5 w-3.5" />
2929
</Link>
3030
</Button>

packages/web/src/ee/features/chat/agent.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ const createAssistantMessage = (parts: SBChatMessagePart[]): SBChatMessage => ({
137137
});
138138

139139
const createFakeStreamResult = () => ({
140-
response: Promise.resolve(new Response()),
140+
response: Promise.resolve({ messages: [] }),
141+
steps: Promise.resolve([]),
141142
totalUsage: Promise.resolve({
142143
inputTokens: 1,
143144
outputTokens: 1,

packages/web/src/ee/features/chat/agent.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { SBChatMessage, SBChatMessageMetadata } from "@/features/chat/types";
1+
import { SBChatMessage, SBChatMessageMetadata, StepTokenUsageEntry, ToolTokenUsageEntry } from "@/features/chat/types";
2+
import { estimateModelToolOutputTokens } from "@/ee/features/chat/tokenEstimation";
23
import { getFileSource } from '@/features/git';
34
import { isServiceError } from "@/lib/utils";
45
import { LanguageModelV3 as AISDKLanguageModelV3 } from "@ai-sdk/provider";
@@ -190,19 +191,76 @@ export const createMessageStream = async ({
190191
});
191192

192193
const totalUsage = await researchStream.totalUsage;
194+
const steps = await researchStream.steps;
195+
const response = await researchStream.response;
196+
197+
// Tool output estimates are derived from `response.messages` rather
198+
// than per-step `toolResults` because the response messages cover
199+
// tool calls that never run inside a step — approval-gated tools
200+
// execute before the step loop, and thrown tool errors are recorded
201+
// as `tool-error` parts that `toolResults` excludes. Their
202+
// `tool-result` parts also carry the output in model-visible form
203+
// (`toModelOutput` already applied), which is exactly the payload
204+
// whose token footprint we want to estimate.
205+
const toolUsageByToolCallId = new Map<string, ToolTokenUsageEntry>(
206+
response.messages.flatMap((message) =>
207+
message.role !== 'tool' ? [] : message.content.flatMap((part) =>
208+
part.type !== 'tool-result' ? [] : [[part.toolCallId, {
209+
toolCallId: part.toolCallId,
210+
toolName: part.toolName,
211+
estimatedOutputTokens: estimateModelToolOutputTokens(part.output),
212+
}] as const]
213+
)
214+
)
215+
);
216+
217+
// One entry per step, in step order. The UI joins its step groups
218+
// to these entries by array position, so the order and count must
219+
// mirror the stream's steps exactly. Tool calls nest under the
220+
// step they ran in; `content` is matched rather than `toolResults`
221+
// so that thrown tool errors (`tool-error` parts, which
222+
// `toolResults` excludes) are still attributed to their step.
223+
const stepTokenUsage: StepTokenUsageEntry[] = steps.map(({ usage, content }) => ({
224+
inputTokens: usage.inputTokens,
225+
outputTokens: usage.outputTokens,
226+
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
227+
tools: content.flatMap((part) => {
228+
if (part.type !== 'tool-result' && part.type !== 'tool-error') {
229+
return [];
230+
}
231+
const entry = toolUsageByToolCallId.get(part.toolCallId);
232+
if (!entry) {
233+
return [];
234+
}
235+
toolUsageByToolCallId.delete(part.toolCallId);
236+
return [entry];
237+
}),
238+
}));
239+
240+
// Any estimates left unclaimed belong to tool calls that executed
241+
// before the step loop (approval continuations). Their output
242+
// enters the context as input to this phase's first step, so nest
243+
// them under it.
244+
if (toolUsageByToolCallId.size > 0 && stepTokenUsage.length > 0) {
245+
stepTokenUsage[0].tools.unshift(...toolUsageByToolCallId.values());
246+
}
193247

194248
writer.write({
195249
type: 'message-metadata',
196250
messageMetadata: {
251+
// Spread first so the derived fields below can't be overwritten by caller metadata.
252+
...metadata,
197253
totalTokens: (priorMetadata?.totalTokens ?? 0) + (totalUsage.totalTokens ?? 0),
198254
totalInputTokens: (priorMetadata?.totalInputTokens ?? 0) + (totalUsage.inputTokens ?? 0),
199255
totalOutputTokens: (priorMetadata?.totalOutputTokens ?? 0) + (totalUsage.outputTokens ?? 0),
200256
totalCacheReadTokens: (priorMetadata?.totalCacheReadTokens ?? 0) + (totalUsage.inputTokenDetails?.cacheReadTokens ?? 0),
201257
totalCacheWriteTokens: (priorMetadata?.totalCacheWriteTokens ?? 0) + (totalUsage.inputTokenDetails?.cacheWriteTokens ?? 0),
202258
totalResponseTimeMs: (priorMetadata?.totalResponseTimeMs ?? 0) + (new Date().getTime() - startTime.getTime()),
259+
// Concatenated (not summed) across approval-continuation
260+
// phases so earlier phases' steps are preserved in order.
261+
stepTokenUsage: [...(priorMetadata?.stepTokenUsage ?? []), ...stepTokenUsage],
203262
modelName,
204263
traceId,
205-
...metadata,
206264
}
207265
});
208266

@@ -430,6 +488,13 @@ const createAgentStream = async ({
430488
logger.warn(`Tool call repair failed for "${toolCall.toolName}": ${error.message}`);
431489
return null;
432490
},
491+
// Token usage collection deliberately does NOT happen here: the SDK
492+
// awaits this callback before starting the next step, so it must
493+
// stay cheap, and `toolResults` misses tool calls that never run
494+
// inside a step (approval-gated tools execute before the step loop)
495+
// as well as thrown tool errors (recorded as `tool-error` parts).
496+
// Both are instead derived post-stream in `createMessageStream`
497+
// from `steps` and `response.messages`.
433498
onStepFinish: ({ toolResults }) => {
434499
toolResults.forEach(({ output, dynamic }) => {
435500
if (dynamic || isServiceError(output)) {

packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -91,33 +91,57 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
9191
// should be visible to the user. By "steps", we mean parts that originated
9292
// from the same LLM invocation. By "visibile", we mean parts that have some
9393
// visual representation in the UI (e.g., text, reasoning, tool calls, etc.).
94-
const uiVisibleThinkingSteps = useMemo(() => {
95-
const steps = groupMessageIntoSteps(assistantMessage?.parts ?? []);
96-
97-
// Filter out the answerPart and empty steps
98-
return steps
99-
.map(
100-
(step) => step
101-
// First, filter out any parts that are not text
102-
.filter((part) => {
103-
if (part.type === 'text') {
104-
return !part.text.includes(ANSWER_TAG);
105-
}
106-
107-
return true;
108-
})
109-
.filter((part) => {
110-
// Only include text, reasoning, and tool parts
111-
return (
112-
part.type === 'text' ||
113-
part.type === 'reasoning' ||
114-
part.type.startsWith('tool-') ||
115-
part.type === 'dynamic-tool'
116-
)
117-
})
118-
)
94+
//
95+
// Each step is tagged with its stepIndex — the invocation's position in
96+
// the turn, which indexes into `metadata.stepTokenUsage`. Indices are
97+
// assigned by counting 'step-start' markers (one per invocation) BEFORE
98+
// any filtering, so dropping empty or answer-only steps below cannot
99+
// shift the indices of the steps that remain.
100+
const { uiVisibleThinkingSteps, answerStepIndex } = useMemo(() => {
101+
const groupedParts = groupMessageIntoSteps(assistantMessage?.parts ?? []);
102+
103+
// Parts written before the first step-start (e.g. data parts) don't
104+
// belong to any step; they get stepIndex -1 and never survive the
105+
// visibility filters below.
106+
let stepIndex = -1;
107+
let answerStepIndex: number | undefined = undefined;
108+
109+
const steps = groupedParts
110+
.map((stepParts) => {
111+
if (stepParts[0]?.type === 'step-start') {
112+
stepIndex++;
113+
}
114+
115+
if (stepParts.some((part) => part.type === 'text' && part.text.includes(ANSWER_TAG))) {
116+
answerStepIndex = stepIndex;
117+
}
118+
119+
return {
120+
stepIndex,
121+
parts: stepParts
122+
// First, filter out the answer text
123+
.filter((part) => {
124+
if (part.type === 'text') {
125+
return !part.text.includes(ANSWER_TAG);
126+
}
127+
128+
return true;
129+
})
130+
.filter((part) => {
131+
// Only include text, reasoning, and tool parts
132+
return (
133+
part.type === 'text' ||
134+
part.type === 'reasoning' ||
135+
part.type.startsWith('tool-') ||
136+
part.type === 'dynamic-tool'
137+
)
138+
}),
139+
};
140+
})
119141
// Then, filter out any steps that are empty
120-
.filter(step => step.length > 0);
142+
.filter((step) => step.parts.length > 0);
143+
144+
return { uiVisibleThinkingSteps: steps, answerStepIndex };
121145
}, [assistantMessage?.parts]);
122146

123147
// "thinking" is when the agent is generating output that is not the answer.
@@ -379,6 +403,7 @@ const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThreadListIte
379403
isNetworkActive={isNetworkActive}
380404
isAwaitingToolApproval={isAwaitingToolApproval}
381405
thinkingSteps={uiVisibleThinkingSteps}
406+
answerStepIndex={answerStepIndex}
382407
metadata={assistantMessage?.metadata}
383408
/>
384409

packages/web/src/ee/features/chat/components/chatThread/detailsCard.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ describe('DetailsCard', () => {
111111
isTurnInProgress={true}
112112
isNetworkActive={false}
113113
isAwaitingToolApproval={false}
114-
thinkingSteps={[[failedActivationPart]]}
114+
thinkingSteps={[{ stepIndex: 0, parts: [failedActivationPart] }]}
115115
/>
116116
</TooltipProvider>
117117
);

0 commit comments

Comments
 (0)