Skip to content

Commit 7cbd50b

Browse files
committed
initial openrouter wrapper
1 parent ec7caf7 commit 7cbd50b

File tree

7 files changed

+277
-0
lines changed

7 files changed

+277
-0
lines changed

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+
}

web/src/llm-api/openrouter.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { env } from '@codebuff/internal/env'
2+
3+
export async function handleOpenrouterStream({ body }: { body: any }) {
4+
// Ensure usage tracking is enabled
5+
if (body.usage === undefined) {
6+
body.usage = {}
7+
}
8+
body.usage.include = true
9+
10+
const response = await fetch(
11+
'https://openrouter.ai/api/v1/chat/completions',
12+
{
13+
method: 'POST',
14+
headers: {
15+
Authorization: `Bearer ${env.OPEN_ROUTER_API_KEY}`,
16+
'HTTP-Referer': 'https://codebuff.com',
17+
'X-Title': 'Codebuff',
18+
'Content-Type': 'application/json',
19+
},
20+
body: JSON.stringify(body),
21+
}
22+
)
23+
24+
if (!response.ok) {
25+
throw new Error(`OpenRouter API error: ${response.statusText}`)
26+
}
27+
28+
const reader = response.body?.getReader()
29+
if (!reader) {
30+
throw new Error('Failed to get response reader')
31+
}
32+
33+
let heartbeatInterval: ReturnType<typeof setInterval>
34+
35+
// Create a ReadableStream that Next.js can handle
36+
const stream = new ReadableStream({
37+
async start(controller) {
38+
const decoder = new TextDecoder()
39+
let buffer = ''
40+
41+
// Send initial connection message
42+
controller.enqueue(
43+
new TextEncoder().encode(`: connected ${new Date().toISOString()}\n`)
44+
)
45+
46+
// Start heartbeat
47+
heartbeatInterval = setInterval(() => {
48+
controller.enqueue(
49+
new TextEncoder().encode(
50+
`: heartbeat ${new Date().toISOString()}\n\n`
51+
)
52+
)
53+
}, 30000)
54+
55+
try {
56+
while (true) {
57+
const { done, value } = await reader.read()
58+
59+
if (done) {
60+
break
61+
}
62+
63+
buffer += decoder.decode(value, { stream: true })
64+
let lineEnd = buffer.indexOf('\n')
65+
66+
while (lineEnd !== -1) {
67+
const line = buffer.slice(0, lineEnd + 1)
68+
buffer = buffer.slice(lineEnd + 1)
69+
70+
// Forward the line to the client
71+
controller.enqueue(new TextEncoder().encode(line))
72+
73+
lineEnd = buffer.indexOf('\n')
74+
}
75+
}
76+
77+
controller.close()
78+
} catch (error) {
79+
controller.error(error)
80+
} finally {
81+
clearInterval(heartbeatInterval)
82+
reader.cancel()
83+
}
84+
},
85+
cancel() {
86+
clearInterval(heartbeatInterval)
87+
reader.cancel()
88+
},
89+
})
90+
91+
return stream
92+
}

web/src/util/auth.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { NextRequest } from 'next/server'
2+
3+
/**
4+
* Extract api key from x-codebuff-api-key header or authorization header
5+
*/
6+
export function extractApiKeyFromHeader(req: NextRequest): string | undefined {
7+
const token = req.headers.get('x-codebuff-api-key')
8+
if (typeof token === 'string' && token) {
9+
return token
10+
}
11+
12+
const authorization = req.headers.get('Authorization')
13+
if (!authorization) {
14+
return undefined
15+
}
16+
if (!authorization.startsWith('Bearer ')) {
17+
return undefined
18+
}
19+
return authorization.slice('Bearer '.length)
20+
}

web/src/util/error.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function errorToObject(error: any): object {
2+
if (error instanceof Error) {
3+
return {
4+
name: error.name,
5+
message: error.message,
6+
stack: error.stack,
7+
}
8+
}
9+
10+
if (typeof error === 'string') {
11+
return {
12+
message: error,
13+
}
14+
}
15+
16+
return error
17+
}

0 commit comments

Comments
 (0)