Skip to content

Commit e4e66a0

Browse files
committed
initial openrouter wrapper
1 parent 4f3446b commit e4e66a0

File tree

10 files changed

+471
-5
lines changed

10 files changed

+471
-5
lines changed

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 { websiteUrl } from './config'
5+
6+
const codebuffBackendProvider = createOpenAICompatible({
7+
name: 'codebuff',
8+
apiKey: '12345',
9+
baseURL: websiteUrl + '/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+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { NextResponse } from 'next/server'
2+
3+
import type { NextRequest } from 'next/server'
4+
5+
import { getUserInfoFromApiKey } from '@/db/user'
6+
import { handleOpenrouterStream } from '@/llm-api/openrouter'
7+
import { extractApiKeyFromHeader } from '@/util/auth'
8+
import { errorToObject } from '@/util/error'
9+
import { logger } from '@/util/logger'
10+
11+
export async function POST(req: NextRequest) {
12+
try {
13+
const body = await req.json()
14+
15+
console.log('asdf', {
16+
req: { headers: Object.fromEntries(req.headers), body },
17+
})
18+
19+
const apiKey = extractApiKeyFromHeader(req)
20+
21+
if (!apiKey) {
22+
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const userInfo = await getUserInfoFromApiKey({ apiKey, fields: ['id'] })
26+
if (!userInfo) {
27+
return NextResponse.json(
28+
{ message: 'Invalid Codebuff API key' },
29+
{ status: 401 }
30+
)
31+
}
32+
33+
if (body.stream) {
34+
try {
35+
const stream = await handleOpenrouterStream({
36+
body,
37+
})
38+
39+
return new NextResponse(stream, {
40+
headers: {
41+
'Content-Type': 'text/event-stream',
42+
'Cache-Control': 'no-cache',
43+
Connection: 'keep-alive',
44+
'Access-Control-Allow-Origin': '*',
45+
},
46+
})
47+
} catch (error) {
48+
logger.error(
49+
errorToObject(error),
50+
'Error setting up OpenRouter stream:'
51+
)
52+
return NextResponse.json(
53+
{ error: 'Failed to initialize stream' },
54+
{ status: 500 }
55+
)
56+
}
57+
}
58+
59+
return NextResponse.json(
60+
{ message: 'Not implemented. Use stream=true.' },
61+
{ status: 500 }
62+
)
63+
} catch (error) {
64+
logger.error(
65+
errorToObject(error),
66+
'Error processing chat completions request:'
67+
)
68+
return NextResponse.json(
69+
{ error: 'Internal server error' },
70+
{ status: 500 }
71+
)
72+
}
73+
}

web/src/db/user.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import db from '@codebuff/common/db'
2+
import * as schema from '@codebuff/common/db/schema'
3+
import { eq } from 'drizzle-orm'
4+
5+
import type { InferSelectModel } from 'drizzle-orm'
6+
7+
type UserTable = typeof schema.user
8+
type UserColumn = UserTable['_']['columns']
9+
type User = InferSelectModel<UserTable>
10+
11+
export async function getUserInfoFromApiKey<
12+
T extends readonly (keyof UserColumn)[],
13+
>({
14+
apiKey,
15+
fields,
16+
}: {
17+
apiKey: string
18+
fields: T
19+
}): Promise<
20+
| {
21+
[K in T[number]]: User[K]
22+
}
23+
| undefined
24+
> {
25+
// Build a typed selection object for user columns
26+
const userSelection = Object.fromEntries(
27+
fields.map((field) => [field, schema.user[field]])
28+
) as { [K in T[number]]: UserColumn[K] }
29+
30+
const rows = await db
31+
.select({ user: userSelection }) // <-- important: nest under 'user'
32+
.from(schema.user)
33+
.leftJoin(schema.session, eq(schema.user.id, schema.session.userId))
34+
.where(eq(schema.session.sessionToken, apiKey))
35+
.limit(1)
36+
37+
// Drizzle returns { user: ..., session: ... }, we return only the user part
38+
return rows[0]?.user
39+
}

0 commit comments

Comments
 (0)