Skip to content
Draft
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
8 changes: 8 additions & 0 deletions packages/ai-bot/lib/responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,12 @@ export class Responder {
let cachedTokens = (chunk.usage as any).prompt_tokens_details
?.cached_tokens;
let costUsd = (chunk.usage as any).cost;
// OpenRouter also stamps each chunk with the provider that served the
// request and the generation id. Recording them alongside the counts
// makes a cache miss attributable: a cachedTokens collapse with a
// provider change is a routing miss, not a prompt-shape bug.
let provider = (chunk as any).provider;
let generationId = chunk.id;
// Hand the counts to the publisher so they ride on the final room
// event. When the final edit has already gone out (the usage chunk
// trails the finish chunk), finalize() sends one more edit to carry
Expand All @@ -318,6 +324,8 @@ export class Responder {
completionTokens: chunk.usage.completion_tokens,
...(typeof cachedTokens === 'number' ? { cachedTokens } : {}),
...(typeof costUsd === 'number' ? { costUsd } : {}),
...(typeof provider === 'string' ? { provider } : {}),
...(typeof generationId === 'string' ? { generationId } : {}),
};
log.info(
`Request used ${chunk.usage.prompt_tokens} prompt tokens (${
Expand Down
11 changes: 11 additions & 0 deletions packages/ai-bot/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ class Assistant {
// the cast.
(request as Record<string, unknown>).usage = { include: true };

// Prompt caches live per provider, and the router is otherwise free to
// spread a room's requests across providers — which turns a warm cache
// prefix into a full-price miss mid-conversation. Bias Anthropic-model
// requests to Anthropic itself, keeping fallbacks for availability.
if (this.getModel(prompt).startsWith('anthropic/')) {
(request as Record<string, unknown>).provider = {
order: ['anthropic'],
allow_fallbacks: true,
};
}

if (prompt.reasoningEffort !== undefined) {
request.reasoning_effort = prompt.reasoningEffort;
}
Expand Down
58 changes: 39 additions & 19 deletions packages/ai-bot/tests/prompt-construction-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,7 @@ Current date and time: 2025-06-11T11:43:00.533Z
assert.equal(attachedCards.length, 0);
});

test('downloads and includes most recent version of attached files', async () => {
test('each message keeps its own attached-file snapshot content', async () => {
const history: DiscreteMatrixEvent[] = [
{
type: 'm.room.message',
Expand Down Expand Up @@ -1042,7 +1042,24 @@ Current date and time: 2025-06-11T11:43:00.533Z
},
];

// Set up mock responses for file downloads
// Set up mock responses for file downloads — every message's snapshot
// is downloaded now, so each version needs a response.
mockResponses.set('http://test.com/spaghetti-recipe-a.gts', {
ok: true,
text: 'spaghetti content version a',
});
mockResponses.set('http://test.com/best-friends-a.txt', {
ok: true,
text: 'best friends version a',
});
mockResponses.set('http://test.com/spaghetti-recipe-b.gts', {
ok: true,
text: 'spaghetti content version b',
});
mockResponses.set('http://test.com/best-friends-b.txt', {
ok: true,
text: 'best friends version b',
});
mockResponses.set('http://test.com/spaghetti-recipe-c.gts', {
ok: true,
text: 'this is the content of the spaghetti-recipe.gts file',
Expand Down Expand Up @@ -1071,33 +1088,35 @@ Current date and time: 2025-06-11T11:43:00.533Z
assert.ok(
messageText(userMessages[0]).includes(
`
Attached Files (files with newer versions don't show their content):
[spaghetti-recipe.gts](http://test-realm-server/my-realm/spaghetti-recipe.gts)
[best-friends.txt](http://test-realm-server/my-realm/best-friends.txt)
[spaghetti-recipe.gts](http://test-realm-server/my-realm/spaghetti-recipe.gts):
1: spaghetti content version a
[best-friends.txt](http://test-realm-server/my-realm/best-friends.txt):
1: best friends version a
`.trim(),
),
'first message keeps its own snapshot content',
);
assert.ok(
messageText(userMessages[1]).includes(
`
Attached Files (files with newer versions don't show their content):
[spaghetti-recipe.gts](http://test-realm-server/my-realm/spaghetti-recipe.gts)
[best-friends.txt](http://test-realm-server/my-realm/best-friends.txt)
[file-that-does-not-exist.txt](http://test.com/my-realm/file-that-does-not-exist.txt)
[example.pdf](http://test.com/my-realm/example.pdf): [application/pdf]
[spaghetti-recipe.gts](http://test-realm-server/my-realm/spaghetti-recipe.gts):
1: spaghetti content version b
[best-friends.txt](http://test-realm-server/my-realm/best-friends.txt):
1: best friends version b
`.trim(),
),
'second message keeps its own snapshot content',
);
assert.ok(
messageText(userMessages[2]).includes(
`
Attached Files (files with newer versions don't show their content):
[spaghetti-recipe.gts](http://test-realm-server/my-realm/spaghetti-recipe.gts):
1: this is the content of the spaghetti-recipe.gts file
[best-friends.txt](http://test-realm-server/my-realm/best-friends.txt):
1: this is the content of the best-friends.txt file
`.trim(),
),
'latest message includes its content with line numbers',
);

assert.ok(
Expand Down Expand Up @@ -1408,9 +1427,9 @@ Attached Files (files with newer versions don't show their content):
'http://localhost:4201/experiments/Author/1',
),
);
assert.false(
assert.true(
messageText(userMessages[0]).includes('"firstName": "Terry"'),
'should not include the contents of the first version of the card in the first user message',
'each message keeps its own snapshot of the card content',
);
assert.true(
messageText(userMessages[1]).includes(
Expand Down Expand Up @@ -4145,7 +4164,7 @@ Current date and time: 2025-06-11T11:43:00.533Z
assert.true(
messageText(toolCallMessage!).includes(
`
Attached Files (files with newer versions don't show their content):
Attached Files (each shows its content as of this message; a later attachment of the same file supersedes it):
[postcard.gts](http://test-realm-server/user/test-realm/postcard.gts):
1: export default Postcard extends CardDef {}
`.trim(),
Expand Down Expand Up @@ -4183,7 +4202,7 @@ Attached Files (files with newer versions don't show their content):
assert.true(
messageText(toolCallMessage!).includes(
`
Attached Cards (cards with newer versions don't show their content):
Attached Cards (each shows its content as of this message; a later attachment of the same card supersedes it):
[
{
"url": "mxc://mock-server/nashville",
Expand Down Expand Up @@ -5941,7 +5960,7 @@ new
'the surviving user message keeps its body',
);
});
test('only the most recent message attachments include file content in the prompt', async () => {
test('every message keeps its attached file content in the prompt', async () => {
// Policy: files attached to older messages should show metadata only,
// even if they are NOT re-attached in later messages.
// Only the most recent user message's attachments should include content.
Expand Down Expand Up @@ -6041,14 +6060,15 @@ new

let userMessages = prompt.filter((m) => m.role === 'user');

// Older message's unique file (config.json) should show metadata only, not content
// The older message keeps its own snapshot's content — re-rendering it
// later would change already-sent history bytes and break prompt caching.
assert.ok(
messageText(userMessages[0]).includes('[config.json]'),
'First message mentions config.json',
);
assert.notOk(
assert.ok(
messageText(userMessages[0]).includes('"key": "value"'),
'First message should NOT include config.json content (not the current message)',
'First message keeps its config.json snapshot content',
);

// Most recent message's file (utils.ts) should include content
Expand Down
7 changes: 7 additions & 0 deletions packages/base/matrix-event.gts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ export interface TokenUsage {
// What the provider charged for the whole request, in USD. Absent when
// the provider reports no inline cost.
costUsd?: number;
// Which upstream provider served the request (the router's routing
// target). Prompt caches live per provider, so a surprising cache miss is
// attributable when this changes between turns. Absent when unreported.
provider?: string;
// The router-side generation id for the request, for post-hoc lookup of
// routing and cache detail. Absent when unreported.
generationId?: string;
}

export interface SkillsConfigEvent extends RoomStateEvent {
Expand Down
Loading
Loading