Skip to content

Commit 79f654b

Browse files
committed
Make file-picker free
1 parent 8d4290c commit 79f654b

File tree

3 files changed

+117
-19
lines changed

3 files changed

+117
-19
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { parseAgentId } from '../util/agent-id-parsing'
2+
3+
/**
4+
* Agents that don't charge credits.
5+
*
6+
* These are typically lightweight utility agents that:
7+
* - Use cheap models (e.g., Gemini Flash Lite)
8+
* - Have limited, programmatic capabilities
9+
* - Are frequently spawned as subagents
10+
*
11+
* Making them free avoids user confusion when they connect their own
12+
* Claude subscription (BYOK) but still see credit charges for non-Claude models.
13+
*/
14+
export const FREE_TIER_AGENTS = new Set([
15+
'file-picker',
16+
'file-picker-max',
17+
'file-lister',
18+
])
19+
20+
/**
21+
* Check if an agent should be free (no credit charge).
22+
* Handles all agent ID formats:
23+
* - 'file-picker'
24+
* - 'file-picker@1.0.0'
25+
* - 'codebuff/file-picker@0.0.2'
26+
*/
27+
export function isFreeAgent(fullAgentId: string): boolean {
28+
const { agentId } = parseAgentId(fullAgentId)
29+
return agentId ? FREE_TIER_AGENTS.has(agentId) : false
30+
}

web/src/llm-api/helpers.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { setupBigQuery } from '@codebuff/bigquery'
22
import { consumeCreditsAndAddAgentStep } from '@codebuff/billing'
3+
import { isFreeAgent } from '@codebuff/common/constants/free-agents'
34
import { PROFIT_MARGIN } from '@codebuff/common/old-constants'
45

56
import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery'
@@ -101,7 +102,7 @@ export async function consumeCreditsForMessage(params: {
101102
usageData: UsageData
102103
byok: boolean
103104
logger: Logger
104-
}) {
105+
}): Promise<number> {
105106
const {
106107
messageId,
107108
userId,
@@ -118,6 +119,11 @@ export async function consumeCreditsForMessage(params: {
118119
logger,
119120
} = params
120121

122+
// Free tier agents (like file-picker) don't charge credits to avoid confusion
123+
// when users connect their Claude subscription but subagents use other models
124+
const initialCredits = Math.round(usageData.cost * 100 * (1 + PROFIT_MARGIN))
125+
const credits = isFreeAgent(agentId) && initialCredits < 5 ? 0 : initialCredits
126+
121127
await consumeCreditsAndAddAgentStep({
122128
messageId,
123129
userId,
@@ -130,7 +136,7 @@ export async function consumeCreditsForMessage(params: {
130136
reasoningText,
131137
response: responseText,
132138
cost: usageData.cost,
133-
credits: Math.round(usageData.cost * 100 * (1 + PROFIT_MARGIN)),
139+
credits,
134140
inputTokens: usageData.inputTokens,
135141
cacheCreationInputTokens: null,
136142
cacheReadInputTokens: usageData.cacheReadInputTokens,
@@ -140,4 +146,6 @@ export async function consumeCreditsForMessage(params: {
140146
byok,
141147
logger,
142148
})
149+
150+
return credits
143151
}

web/src/llm-api/openrouter.ts

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { PROFIT_MARGIN } from '@codebuff/common/constants/limits'
12
import { getErrorObject } from '@codebuff/common/util/error'
23
import { env } from '@codebuff/internal/env'
34

@@ -17,6 +18,13 @@ import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/b
1718
import type { Logger } from '@codebuff/common/types/contracts/logger'
1819

1920
type StreamState = { responseText: string; reasoningText: string }
21+
22+
/** Result from processing a line, including optional billed credits for final chunk */
23+
type LineResult = {
24+
state: StreamState
25+
billedCredits?: number
26+
}
27+
2028
function createOpenRouterRequest(params: {
2129
body: any
2230
openrouterApiKey: string | null
@@ -143,8 +151,8 @@ export async function handleOpenRouterNonStream({
143151
logger.error({ error }, 'Failed to insert message into BigQuery')
144152
})
145153

146-
// Consume credits
147-
await consumeCreditsForMessage({
154+
// Consume credits and get the actual billed amount
155+
const billedCredits = await consumeCreditsForMessage({
148156
messageId: firstData.id,
149157
userId,
150158
stripeCustomerId,
@@ -175,7 +183,9 @@ export async function handleOpenRouterNonStream({
175183
completion_tokens: aggregatedUsage.outputTokens,
176184
total_tokens:
177185
aggregatedUsage.inputTokens + aggregatedUsage.outputTokens,
178-
cost: aggregatedUsage.cost,
186+
// Overwrite cost so SDK calculates exact credits we charged
187+
cost: creditsToFakeCost(billedCredits),
188+
cost_details: { upstream_inference_cost: 0 },
179189
},
180190
}
181191
}
@@ -211,8 +221,8 @@ export async function handleOpenRouterNonStream({
211221
logger.error({ error }, 'Failed to insert message into BigQuery')
212222
})
213223

214-
// Consume credits
215-
await consumeCreditsForMessage({
224+
// Consume credits and get the actual billed amount
225+
const billedCredits = await consumeCreditsForMessage({
216226
messageId: data.id,
217227
userId,
218228
stripeCustomerId,
@@ -228,6 +238,12 @@ export async function handleOpenRouterNonStream({
228238
logger,
229239
})
230240

241+
// Overwrite cost so SDK calculates exact credits we charged
242+
if (data.usage) {
243+
data.usage.cost = creditsToFakeCost(billedCredits)
244+
data.usage.cost_details = { upstream_inference_cost: 0 }
245+
}
246+
231247
return data
232248
}
233249

@@ -323,7 +339,7 @@ export async function handleOpenRouterStream({
323339
const line = buffer.slice(0, lineEnd + 1)
324340
buffer = buffer.slice(lineEnd + 1)
325341

326-
state = await handleLine({
342+
const lineResult = await handleLine({
327343
userId,
328344
stripeCustomerId,
329345
agentId,
@@ -337,10 +353,15 @@ export async function handleOpenRouterStream({
337353
logger,
338354
insertMessage: insertMessageBigquery,
339355
})
356+
state = lineResult.state
340357

341358
if (!clientDisconnected) {
342359
try {
343-
controller.enqueue(new TextEncoder().encode(line))
360+
// Overwrite cost in final chunk so SDK calculates exact credits we charged
361+
const lineToSend = lineResult.billedCredits !== undefined
362+
? overwriteCostWithBilledCredits(line, lineResult.billedCredits)
363+
: line
364+
controller.enqueue(new TextEncoder().encode(lineToSend))
344365
} catch (error) {
345366
logger.warn(
346367
'Client disconnected during stream, continuing for billing',
@@ -413,14 +434,14 @@ async function handleLine({
413434
state: StreamState
414435
logger: Logger
415436
insertMessage: InsertMessageBigqueryFn
416-
}): Promise<StreamState> {
437+
}): Promise<LineResult> {
417438
if (!line.startsWith('data: ')) {
418-
return state
439+
return { state }
419440
}
420441

421442
const raw = line.slice('data: '.length)
422443
if (raw === '[DONE]\n') {
423-
return state
444+
return { state }
424445
}
425446

426447
// Parse the string into an object
@@ -432,7 +453,7 @@ async function handleLine({
432453
{ error: getErrorObject(error, { includeRawError: true }) },
433454
'Received non-JSON OpenRouter response',
434455
)
435-
return state
456+
return { state }
436457
}
437458

438459
// Extract usage
@@ -442,10 +463,10 @@ async function handleLine({
442463
{ error: getErrorObject(parsed.error, { includeRawError: true }) },
443464
'Unable to parse OpenRouter response',
444465
)
445-
return state
466+
return { state }
446467
}
447468

448-
return await handleResponse({
469+
return handleResponse({
449470
userId,
450471
stripeCustomerId,
451472
agentId,
@@ -487,7 +508,7 @@ async function handleResponse({
487508
state: StreamState
488509
logger: Logger
489510
insertMessage: InsertMessageBigqueryFn
490-
}): Promise<StreamState> {
511+
}): Promise<LineResult> {
491512
const model = 'model' in data ? data.model : undefined
492513
state = await handleStreamChunk({
493514
data,
@@ -500,7 +521,7 @@ async function handleResponse({
500521

501522
if ('error' in data || !data.usage) {
502523
// Stream not finished
503-
return state
524+
return { state }
504525
}
505526

506527
const usageData = extractUsageAndCost(data.usage)
@@ -520,7 +541,8 @@ async function handleResponse({
520541
logger.error({ error }, 'Failed to insert message into BigQuery')
521542
})
522543

523-
await consumeCreditsForMessage({
544+
// Consume credits and get the actual billed amount
545+
const billedCredits = await consumeCreditsForMessage({
524546
messageId: data.id,
525547
userId,
526548
stripeCustomerId,
@@ -536,7 +558,7 @@ async function handleResponse({
536558
logger,
537559
})
538560

539-
return state
561+
return { state, billedCredits }
540562
}
541563

542564
async function handleStreamChunk({
@@ -727,3 +749,41 @@ async function parseOpenRouterError(
727749
}
728750
return new OpenRouterError(response.status, response.statusText, errorBody)
729751
}
752+
753+
/**
754+
* Convert credits (integer cents) back to a cost value that will result in the same
755+
* credits when the SDK applies its formula: credits = Math.round(cost * (1 + PROFIT_MARGIN) * 100)
756+
*/
757+
function creditsToFakeCost(credits: number): number {
758+
return credits / ((1 + PROFIT_MARGIN) * 100)
759+
}
760+
761+
/**
762+
* Overwrite the cost field in the final SSE chunk to reflect actual billed credits.
763+
* This ensures the SDK calculates the exact credits value we stored in the database,
764+
* making the server the single source of truth for credit tracking.
765+
*/
766+
function overwriteCostWithBilledCredits(line: string, billedCredits: number): string {
767+
if (!line.startsWith('data: ')) {
768+
return line
769+
}
770+
771+
const raw = line.slice('data: '.length)
772+
if (raw === '[DONE]\n' || raw === '[DONE]') {
773+
return line
774+
}
775+
776+
try {
777+
const obj = JSON.parse(raw)
778+
// Only modify if there's usage data (final chunk)
779+
if (obj.usage) {
780+
obj.usage.cost = creditsToFakeCost(billedCredits)
781+
obj.usage.cost_details = { upstream_inference_cost: 0 }
782+
return `data: ${JSON.stringify(obj)}\n`
783+
}
784+
} catch {
785+
// If parsing fails, return original line
786+
}
787+
788+
return line
789+
}

0 commit comments

Comments
 (0)