Skip to content

Commit 137a928

Browse files
committed
inject fetch and consumeCreditsWithFallback into web-search
1 parent 202027f commit 137a928

File tree

6 files changed

+144
-114
lines changed

6 files changed

+144
-114
lines changed

backend/src/impl/agent-runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { consumeCreditsWithFallback } from '@codebuff/billing'
12
import { trackEvent } from '@codebuff/common/analytics'
23

34
import { addAgentStep, finishAgentRun, startAgentRun } from '../agent-run'
@@ -21,6 +22,9 @@ export const BACKEND_AGENT_RUNTIME_IMPL: AgentRuntimeDeps = Object.freeze({
2122
finishAgentRun,
2223
addAgentStep,
2324

25+
// Billing
26+
consumeCreditsWithFallback,
27+
2428
// LLM
2529
promptAiSdkStream,
2630
promptAiSdk,

backend/src/llm-apis/__tests__/linkup-api.test.ts

Lines changed: 102 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime'
12
import {
23
clearMockedModules,
34
mockModule,
@@ -10,23 +11,18 @@ import {
1011
describe,
1112
expect,
1213
mock,
13-
spyOn,
1414
test,
1515
} from 'bun:test'
1616

1717
import { searchWeb } from '../linkup-api'
1818

19+
import type { AgentRuntimeDeps } from '@codebuff/common/types/contracts/agent-runtime'
20+
1921
// Mock environment variables
2022
process.env.LINKUP_API_KEY = 'test-api-key'
2123

2224
describe('Linkup API', () => {
23-
// Mock logger with spy functions to verify logging calls
24-
const mockLogger = {
25-
debug: mock(() => {}),
26-
error: mock(() => {}),
27-
info: mock(() => {}),
28-
warn: mock(() => {}),
29-
}
25+
let agentRuntimeImpl: AgentRuntimeDeps
3026

3127
beforeAll(() => {
3228
mockModule('@codebuff/internal', () => ({
@@ -42,13 +38,9 @@ describe('Linkup API', () => {
4238
})
4339

4440
beforeEach(() => {
45-
// Reset fetch mock before each test
46-
spyOn(global, 'fetch').mockResolvedValue(new Response())
47-
// Reset logger mocks
48-
mockLogger.debug.mockClear()
49-
mockLogger.error.mockClear()
50-
mockLogger.info.mockClear()
51-
mockLogger.warn.mockClear()
41+
agentRuntimeImpl = {
42+
...TEST_AGENT_RUNTIME_IMPL,
43+
}
5244
})
5345

5446
afterEach(() => {
@@ -73,24 +65,26 @@ describe('Linkup API', () => {
7365
],
7466
}
7567

76-
spyOn(global, 'fetch').mockResolvedValue(
77-
new Response(JSON.stringify(mockResponse), {
78-
status: 200,
79-
headers: { 'Content-Type': 'application/json' },
80-
}),
81-
)
68+
agentRuntimeImpl.fetch = mock(() => {
69+
return Promise.resolve(
70+
new Response(JSON.stringify(mockResponse), {
71+
status: 200,
72+
headers: { 'Content-Type': 'application/json' },
73+
}),
74+
)
75+
}) as unknown as typeof global.fetch
8276

8377
const result = await searchWeb({
78+
...agentRuntimeImpl,
8479
query: 'React tutorial',
85-
logger: mockLogger,
8680
})
8781

8882
expect(result).toBe(
8983
'React is a JavaScript library for building user interfaces. You can learn how to build your first React application by following the official documentation.',
9084
)
9185

9286
// Verify fetch was called with correct parameters
93-
expect(fetch).toHaveBeenCalledWith(
87+
expect(agentRuntimeImpl.fetch).toHaveBeenCalledWith(
9488
'https://api.linkup.so/v1/search',
9589
expect.objectContaining({
9690
method: 'POST',
@@ -120,25 +114,27 @@ describe('Linkup API', () => {
120114
],
121115
}
122116

123-
spyOn(global, 'fetch').mockResolvedValue(
124-
new Response(JSON.stringify(mockResponse), {
125-
status: 200,
126-
headers: { 'Content-Type': 'application/json' },
127-
}),
128-
)
117+
agentRuntimeImpl.fetch = mock(() => {
118+
return Promise.resolve(
119+
new Response(JSON.stringify(mockResponse), {
120+
status: 200,
121+
headers: { 'Content-Type': 'application/json' },
122+
}),
123+
)
124+
}) as unknown as typeof global.fetch
129125

130126
const result = await searchWeb({
127+
...agentRuntimeImpl,
131128
query: 'React patterns',
132129
depth: 'deep',
133-
logger: mockLogger,
134130
})
135131

136132
expect(result).toBe(
137133
'Advanced React patterns include render props, higher-order components, and custom hooks for building reusable and maintainable components.',
138134
)
139135

140136
// Verify fetch was called with correct parameters
141-
expect(fetch).toHaveBeenCalledWith(
137+
expect(agentRuntimeImpl.fetch).toHaveBeenCalledWith(
142138
'https://api.linkup.so/v1/search',
143139
expect.objectContaining({
144140
body: JSON.stringify({
@@ -151,48 +147,59 @@ describe('Linkup API', () => {
151147
})
152148

153149
test('should handle API errors gracefully', async () => {
154-
spyOn(global, 'fetch').mockResolvedValue(
155-
new Response('Internal Server Error', {
156-
status: 500,
157-
statusText: 'Internal Server Error',
158-
}),
159-
)
150+
agentRuntimeImpl.fetch = mock(() => {
151+
return Promise.resolve(
152+
new Response('Internal Server Error', {
153+
status: 500,
154+
statusText: 'Internal Server Error',
155+
}),
156+
)
157+
}) as unknown as typeof global.fetch
160158

161-
const result = await searchWeb({ query: 'test query', logger: mockLogger })
159+
const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' })
162160

163161
expect(result).toBeNull()
164162
})
165163

166164
test('should handle network errors', async () => {
167-
spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
165+
agentRuntimeImpl.fetch = mock(() => {
166+
return Promise.reject(new Error('Network error'))
167+
}) as unknown as typeof global.fetch
168168

169-
const result = await searchWeb({ query: 'test query', logger: mockLogger })
169+
const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' })
170170

171171
expect(result).toBeNull()
172172
})
173173

174174
test('should handle invalid response format', async () => {
175-
spyOn(global, 'fetch').mockResolvedValue(
176-
new Response(JSON.stringify({ invalid: 'format' }), {
177-
status: 200,
178-
headers: { 'Content-Type': 'application/json' },
179-
}),
180-
)
175+
agentRuntimeImpl.fetch = mock(() => {
176+
return Promise.resolve(
177+
new Response(JSON.stringify({ invalid: 'format' }), {
178+
status: 200,
179+
headers: { 'Content-Type': 'application/json' },
180+
}),
181+
)
182+
}) as unknown as typeof global.fetch
181183

182-
const result = await searchWeb({ query: 'test query', logger: mockLogger })
184+
const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' })
183185

184186
expect(result).toBeNull()
185187
})
186188

187189
test('should handle missing answer field', async () => {
188-
spyOn(global, 'fetch').mockResolvedValue(
189-
new Response(JSON.stringify({ sources: [] }), {
190-
status: 200,
191-
headers: { 'Content-Type': 'application/json' },
192-
}),
193-
)
190+
agentRuntimeImpl.fetch = mock(() => {
191+
return Promise.resolve(
192+
new Response(JSON.stringify({ sources: [] }), {
193+
status: 200,
194+
headers: { 'Content-Type': 'application/json' },
195+
}),
196+
)
197+
}) as unknown as typeof global.fetch
194198

195-
const result = await searchWeb({ query: 'test query', logger: mockLogger })
199+
const result = await searchWeb({
200+
...agentRuntimeImpl,
201+
query: 'test query',
202+
})
196203

197204
expect(result).toBeNull()
198205
})
@@ -202,14 +209,16 @@ describe('Linkup API', () => {
202209
sources: [],
203210
}
204211

205-
spyOn(global, 'fetch').mockResolvedValue(
206-
new Response(JSON.stringify(mockResponse), {
207-
status: 200,
208-
headers: { 'Content-Type': 'application/json' },
209-
}),
210-
)
212+
agentRuntimeImpl.fetch = mock(() => {
213+
return Promise.resolve(
214+
new Response(JSON.stringify(mockResponse), {
215+
status: 200,
216+
headers: { 'Content-Type': 'application/json' },
217+
}),
218+
)
219+
}) as unknown as typeof global.fetch
211220

212-
const result = await searchWeb({ query: 'test query', logger: mockLogger })
221+
const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' })
213222

214223
expect(result).toBeNull()
215224
})
@@ -222,17 +231,19 @@ describe('Linkup API', () => {
222231
],
223232
}
224233

225-
spyOn(global, 'fetch').mockResolvedValue(
226-
new Response(JSON.stringify(mockResponse), {
227-
status: 200,
228-
headers: { 'Content-Type': 'application/json' },
229-
}),
230-
)
234+
agentRuntimeImpl.fetch = mock(() => {
235+
return Promise.resolve(
236+
new Response(JSON.stringify(mockResponse), {
237+
status: 200,
238+
headers: { 'Content-Type': 'application/json' },
239+
}),
240+
)
241+
}) as unknown as typeof global.fetch
231242

232-
await searchWeb({ query: 'test query', logger: mockLogger })
243+
await searchWeb({ ...agentRuntimeImpl, query: 'test query' })
233244

234245
// Verify fetch was called with default parameters
235-
expect(fetch).toHaveBeenCalledWith(
246+
expect(agentRuntimeImpl.fetch).toHaveBeenCalledWith(
236247
'https://api.linkup.so/v1/search',
237248
expect.objectContaining({
238249
body: JSON.stringify({
@@ -245,39 +256,44 @@ describe('Linkup API', () => {
245256
})
246257

247258
test('should handle malformed JSON response', async () => {
248-
spyOn(global, 'fetch').mockResolvedValue(
249-
new Response('invalid json{', {
250-
status: 200,
251-
headers: { 'Content-Type': 'application/json' },
252-
}),
253-
)
259+
agentRuntimeImpl.fetch = mock(() => {
260+
return Promise.resolve(
261+
new Response('invalid json{', {
262+
status: 200,
263+
headers: { 'Content-Type': 'application/json' },
264+
}),
265+
)
266+
}) as unknown as typeof global.fetch
267+
agentRuntimeImpl.logger.error = mock(() => {})
254268

255-
const result = await searchWeb({ query: 'test query', logger: mockLogger })
269+
const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' })
256270

257271
expect(result).toBeNull()
258272
// Verify that error logging was called
259-
expect(mockLogger.error).toHaveBeenCalled()
273+
expect(agentRuntimeImpl.logger.error).toHaveBeenCalled()
260274
})
261275

262276
test('should log detailed error information for 404 responses', async () => {
263277
const mockErrorResponse =
264278
'Not Found - The requested endpoint does not exist'
265-
spyOn(global, 'fetch').mockResolvedValue(
266-
new Response(mockErrorResponse, {
267-
status: 404,
268-
statusText: 'Not Found',
269-
headers: { 'Content-Type': 'text/plain' },
270-
}),
271-
)
279+
agentRuntimeImpl.fetch = mock(() => {
280+
return Promise.resolve(
281+
new Response(mockErrorResponse, {
282+
status: 404,
283+
statusText: 'Not Found',
284+
headers: { 'Content-Type': 'text/plain' },
285+
}),
286+
)
287+
}) as unknown as typeof global.fetch
272288

273289
const result = await searchWeb({
290+
...agentRuntimeImpl,
274291
query: 'test query for 404',
275-
logger: mockLogger,
276292
})
277293

278294
expect(result).toBeNull()
279295
// Verify that detailed error logging was called with 404 info
280-
expect(mockLogger.error).toHaveBeenCalledWith(
296+
expect(agentRuntimeImpl.logger.error).toHaveBeenCalledWith(
281297
expect.objectContaining({
282298
status: 404,
283299
statusText: 'Not Found',

backend/src/tools/handlers/tool/web-search.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { consumeCreditsWithFallback } from '@codebuff/billing'
21
import { PROFIT_MARGIN } from '@codebuff/common/old-constants'
32

43
import { searchWeb } from '../../../llm-apis/linkup-api'
@@ -8,7 +7,12 @@ import type {
87
CodebuffToolCall,
98
CodebuffToolOutput,
109
} from '@codebuff/common/tools/list'
10+
import type {
11+
ConsumeCreditsWithFallbackFn,
12+
CreditFallbackResult,
13+
} from '@codebuff/common/types/contracts/billing'
1114
import type { Logger } from '@codebuff/common/types/contracts/logger'
15+
import type { ErrorOr } from '@codebuff/common/util/error'
1216

1317
export const handleWebSearch = ((params: {
1418
previousToolCallFinished: Promise<void>
@@ -26,6 +30,7 @@ export const handleWebSearch = ((params: {
2630
repoId?: string
2731
}
2832
fetch: typeof globalThis.fetch
33+
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
2934
}): { result: Promise<CodebuffToolOutput<'web_search'>>; state: {} } => {
3035
const {
3136
previousToolCallFinished,
@@ -37,6 +42,7 @@ export const handleWebSearch = ((params: {
3742
repoUrl,
3843
state,
3944
fetch,
45+
consumeCreditsWithFallback,
4046
} = params
4147
const { query, depth } = toolCall.input
4248
const { userId, fingerprintId, repoId } = state
@@ -68,7 +74,7 @@ export const handleWebSearch = ((params: {
6874
const hasResults = Boolean(searchResult && searchResult.trim())
6975

7076
// Charge credits for web search usage
71-
let creditResult = null
77+
let creditResult: ErrorOr<CreditFallbackResult> | null = null
7278
if (userId) {
7379
const creditsToCharge = Math.round(
7480
(depth === 'deep' ? 5 : 1) * (1 + PROFIT_MARGIN),

common/src/types/contracts/agent-runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TrackEventFn } from './analytics'
2+
import type { ConsumeCreditsWithFallbackFn } from './billing'
23
import type {
34
HandleStepsLogChunkFn,
45
RequestFilesFn,
@@ -32,6 +33,9 @@ export type AgentRuntimeDeps = {
3233
finishAgentRun: FinishAgentRunFn
3334
addAgentStep: AddAgentStepFn
3435

36+
// Billing
37+
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
38+
3539
// LLM
3640
promptAiSdkStream: PromptAiSdkStreamFn
3741
promptAiSdk: PromptAiSdkFn

0 commit comments

Comments
 (0)