Skip to content

Commit 7bb7896

Browse files
committed
initial wrapper
1 parent bcff87e commit 7bb7896

File tree

7 files changed

+258
-5
lines changed

7 files changed

+258
-5
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { handleOpenrouterStream } from '../../../llm-apis/openrouter'
2+
import { extractAuthTokenFromHeader } from '../../../util/auth-helpers'
3+
import { getUserIdFromAuthToken } from '../../../websockets/auth'
4+
5+
import type { Request, Response } from 'express'
6+
7+
export async function completionsStreamHandler(req: Request, res: Response) {
8+
console.log('asdf', { req: { headers: req.headers, body: req.body } })
9+
const token = extractAuthTokenFromHeader(req)
10+
if (!token) {
11+
res.status(401).json({ message: 'Unauthorized' })
12+
return
13+
}
14+
const userId = await getUserIdFromAuthToken(token)
15+
if (!userId) {
16+
res.status(401).json({ message: 'Invalid Codebuff API key' })
17+
return
18+
}
19+
20+
if (req.body.stream) {
21+
return await handleOpenrouterStream({ req, res, userId })
22+
}
23+
res.status(500).json({ message: 'Not implemented. Use stream=true.' })
24+
}

backend/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { validateAgentNameHandler } from './api/agents'
1414
import { isRepoCoveredHandler } from './api/org'
1515
import usageHandler from './api/usage'
16+
import { completionsStreamHandler } from './api/v1/chat/completions'
1617
import { checkAdmin } from './util/check-auth'
1718
import { logger } from './util/logger'
1819
import {
@@ -59,6 +60,9 @@ app.post(
5960
relabelForUserHandler,
6061
)
6162

63+
// Openai compatible completions API
64+
app.post('/api/v1/chat/completions', completionsStreamHandler)
65+
6266
app.use(
6367
(
6468
err: Error,

backend/src/llm-apis/openrouter.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { models } from '@codebuff/common/old-constants'
22
import { isExplicitlyDefinedModel } from '@codebuff/common/util/model-utils'
33
import { env } from '@codebuff/internal/env'
44
import { createOpenRouter } from '@codebuff/internal/openrouter-ai-sdk'
5+
import { cloneDeep } from 'lodash'
6+
import z from 'zod/v4'
57

68
import type { Model } from '@codebuff/common/old-constants'
9+
import type { Request, Response } from 'express'
710

811
// Provider routing documentation: https://openrouter.ai/docs/features/provider-routing
912
const providerOrder = {
@@ -45,3 +48,178 @@ export function openRouterLanguageModel(model: Model) {
4548
logprobs: true,
4649
})
4750
}
51+
52+
const openrouterUsageSchema = z
53+
.object({
54+
prompt_tokens: z.number(),
55+
prompt_tokens_details: z
56+
.object({
57+
cached_tokens: z.number(),
58+
})
59+
.nullish(),
60+
completion_tokens: z.number(),
61+
completion_tokens_details: z
62+
.object({
63+
reasoning_tokens: z.number(),
64+
})
65+
.nullish(),
66+
total_tokens: z.number(),
67+
cost: z.number().optional(),
68+
cost_details: z
69+
.object({
70+
upstream_inference_cost: z.number().nullish(),
71+
})
72+
.nullish(),
73+
})
74+
.nullish()
75+
76+
export async function handleOpenrouterStream({
77+
req,
78+
res,
79+
userId,
80+
}: {
81+
req: Request
82+
res: Response
83+
userId: string
84+
}) {
85+
res.writeHead(200, {
86+
// Mandatory SSE headers
87+
'Content-Type': 'text/event-stream',
88+
'Cache-Control': 'no-cache',
89+
Connection: 'keep-alive',
90+
// (optional) allow local browser demos
91+
'Access-Control-Allow-Origin': '*',
92+
})
93+
94+
res.write(`: connected ${new Date().toISOString()}\n`)
95+
const heartbeat = setInterval(() => {
96+
res.write(`: heartbeat ${new Date().toISOString()}\n\n`)
97+
}, 30000)
98+
res.on('close', () => {
99+
clearInterval(heartbeat)
100+
})
101+
102+
const body = cloneDeep(req.body)
103+
if (body.usage === undefined) {
104+
body.usage = {}
105+
}
106+
body.usage.include = true
107+
const response = await fetch(
108+
'https://openrouter.ai/api/v1/chat/completions',
109+
{
110+
method: 'POST',
111+
headers: {
112+
Authorization: `Bearer ${process.env.OPEN_ROUTER_API_KEY}`,
113+
'HTTP-Referer': 'https://codebuff.com',
114+
'X-Title': 'Codebuff',
115+
'Content-Type': 'application/json',
116+
},
117+
body: JSON.stringify(body),
118+
},
119+
)
120+
121+
const reader = response.body?.getReader()
122+
if (!reader) {
123+
res.status(500).json({ message: 'Failed to get response reader' })
124+
return
125+
}
126+
127+
const decoder = new TextDecoder()
128+
let buffer = ''
129+
try {
130+
while (true) {
131+
const { done, value } = await reader.read()
132+
console.log('asdf', {
133+
done,
134+
value: decoder.decode(value, { stream: true }),
135+
})
136+
if (done) {
137+
break
138+
}
139+
140+
buffer += decoder.decode(value, { stream: true })
141+
let lineEnd = buffer.indexOf('\n')
142+
while (lineEnd !== -1) {
143+
const line = buffer.slice(0, lineEnd + 1)
144+
buffer = buffer.slice(lineEnd + 1)
145+
// if (line.startsWith('data: ')) {
146+
// const data = line.trim().slice('data: '.length)
147+
// await processData(data, userId)
148+
// }
149+
res.write(line)
150+
lineEnd = buffer.indexOf('\n')
151+
}
152+
}
153+
} finally {
154+
reader.cancel()
155+
}
156+
res.end()
157+
}
158+
159+
/*
160+
161+
async function processData(data: string, userId: string) {
162+
if (data === '[DONE]') {
163+
return
164+
}
165+
166+
let obj
167+
try {
168+
obj = JSON.parse(data)
169+
} catch (error) {
170+
trackEvent(
171+
AnalyticsEvent.OPENROUTER_MALFORMED_JSON_RESPONSE_CHUNK,
172+
userId,
173+
{
174+
data,
175+
},
176+
)
177+
return
178+
}
179+
180+
if (typeof obj !== 'object') {
181+
return
182+
}
183+
if (typeof obj.usage !== 'object') {
184+
return
185+
}
186+
const parseResult = openrouterUsageSchema.safeParse(obj.usage)
187+
if (!parseResult.success) {
188+
trackEvent(
189+
AnalyticsEvent.OPENROUTER_MALFORMED_JSON_RESPONSE_CHUNK,
190+
userId,
191+
{
192+
message: `Usage does not match schema:\n${parseResult.error.message}`,
193+
data,
194+
},
195+
)
196+
return
197+
}
198+
199+
const directCost = parseResult?.data?.cost ?? 0
200+
const upstreamCost = parseResult?.data?.cost_details?.upstream_inference_cost
201+
202+
saveMessage({
203+
messageId: obj.id,
204+
userId,
205+
clientSessionId: generateCompactId('direct-'),
206+
fingerprintId: generateCompactId('direct-'),
207+
userInputId: generateCompactId('direct-'),
208+
model,
209+
request,
210+
request: Message[]
211+
response: string
212+
inputTokens: number
213+
outputTokens: number
214+
cacheCreationInputTokens?: number
215+
cacheReadInputTokens?: number
216+
finishedAt: Date
217+
latencyMs: number
218+
usesUserApiKey?: boolean
219+
chargeUser?: boolean
220+
costOverrideDollars?: number
221+
agentId?: string
222+
})
223+
}
224+
225+
*/

backend/src/util/auth-helpers.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import type { Request } from 'express'
22

33
/**
4-
* Extract auth token from x-codebuff-api-key header
4+
* Extract auth token from x-codebuff-api-key header or authorization header
55
*/
66
export function extractAuthTokenFromHeader(req: Request): string | undefined {
7-
const token = req.headers['x-codebuff-api-key'] as string | undefined
8-
// Trim any whitespace that might be present
9-
return token?.trim()
10-
}
7+
const token = req.headers['x-codebuff-api-key']
8+
if (typeof token === 'string' && token) {
9+
return token
10+
}
11+
12+
const authorization = req.headers['authorization']
13+
if (!authorization) {
14+
return undefined
15+
}
16+
if (!authorization.startsWith('Bearer ')) {
17+
return undefined
18+
}
19+
return authorization.slice('Bearer '.length)
20+
}

common/src/constants/analytics-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export enum AnalyticsEvent {
3333
TOOL_USE = 'backend.tool_use',
3434
UNKNOWN_TOOL_CALL = 'backend.unknown_tool_call',
3535
USER_INPUT = 'backend.user_input',
36+
OPENROUTER_MALFORMED_JSON_RESPONSE_CHUNK = 'backend.openrouter_malformed_json_response_chunk',
3637

3738
// Web
3839
SIGNUP = 'web.signup',

npm-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"bun": ">=1.2.11"
3535
},
3636
"dependencies": {
37+
"@ai-sdk/openai-compatible": "^1.0.19",
3738
"@codebuff/code-map": "workspace:*",
3839
"@codebuff/common": "workspace:*",
3940
"@types/diff": "8.0.0",

npm-app/src/asdf.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
2+
import { streamText } from 'ai'
3+
4+
import { backendUrl } from './config'
5+
6+
const codebuffBackendProvider = createOpenAICompatible({
7+
name: 'codebuff',
8+
apiKey: '12345',
9+
baseURL: backendUrl + '/api/v1',
10+
})
11+
12+
const response = streamText({
13+
model: codebuffBackendProvider('anthropic/claude-sonnet-4.5'),
14+
messages: [
15+
{
16+
role: 'user',
17+
content:
18+
'This is a bunch of text just to fill out some space. Ignore this.'.repeat(
19+
1000,
20+
),
21+
},
22+
{
23+
role: 'user',
24+
content: 'Hello',
25+
providerOptions: {
26+
codebuff: {
27+
cacheControl: { type: 'ephemeral' },
28+
},
29+
},
30+
},
31+
],
32+
})
33+
for await (const chunk of response.fullStream) {
34+
console.log('asdf', { chunk })
35+
}

0 commit comments

Comments
 (0)