Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ npm start

### Running the server

Change into the server directory and run:
The server is a [Next.js](https://nextjs.org/) app (App Router) that proxies requests to the AI providers. Change into the server directory and run:

```sh
npm run dev
```

It starts on port `3050` (matching the bundled `ngrok` scripts). Use `npm run build && npm start` for a production server.

### Environment variables

The server environment variables are available in `server/.env.example`. If already not present, update this file name to `.env` and configure server environment variables. Gemini image generation requires `GEMINI_API_KEY`.
Expand Down Expand Up @@ -108,9 +110,9 @@ For adding models, once the model definition is added to the `MODELS` array, you

### On the server

Create a new file in the `server/src/chat` folder that corresponds to the model type you created in the mobile app. You can probably copy and re-use a lot of the streaming code from the other existing paths to get you started.
Create a new file in the `server/lib/chat` folder that corresponds to the model type you created in the mobile app. Each handler is a function that takes the parsed `ChatRequest` and returns a streaming SSE `Response` (see `sseResponse` in `server/lib/sse.ts`). You can copy and re-use a lot of the streaming code from the other existing handlers to get you started.

Next, update `server/src/chat/chatRouter` to use the new route.
Next, register the handler in `server/lib/chat/index.ts` (the `CHAT_HANDLERS` map). The map key becomes the path segment, e.g. a `myModel` key is served at `POST /chat/myModel` by `server/app/chat/[provider]/route.ts`.

## Configuring Image Models

Expand All @@ -132,10 +134,10 @@ The app is configured to handle both, but you must update the `generate` functio

#### Gemini (Nano Banana)

Gemini image generation is handled in `server/src/images/gemini.ts`. Configure `GEMINI_API_KEY` and select the Nano Banana image models from the settings screen.
Gemini image generation is handled in `server/lib/images/gemini.ts`. Configure `GEMINI_API_KEY` and select the Nano Banana image models from the settings screen.

#### Other API providers

Create a new file in `server/src/images/modelName`, update the handler function to handle the new API call.
Create a new file in `server/lib/images/modelName`, exporting a handler that returns a JSON `Response`.

Next, update `server/src/images/imagesRouter` to use the new route.
Next, register it in the `IMAGE_HANDLERS` map in `server/app/images/[provider]/route.ts` so it is served at `POST /images/modelName`.
5 changes: 4 additions & 1 deletion server/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
node_modules
.env
.next
next-env.d.ts
*.tsbuildinfo
dist
yarn.lock
package-lock.json
package-lock.json
141 changes: 141 additions & 0 deletions server/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { GET as healthGET } from '@/app/health/route'
import { GET as modelsGET } from '@/app/models/route'
import { POST as chatPOST } from '@/app/chat/[provider]/route'

function chatRequest(body: unknown, headers: Record<string, string> = {}) {
return new Request('http://localhost/chat/x', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body)
})
}

function params(provider: string) {
return { params: Promise.resolve({ provider }) }
}

function sseResponse(events: string[]) {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(event))
}
controller.close()
}
})
return new Response(stream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' }
})
}

describe('server', () => {
beforeEach(() => {
delete process.env.API_AUTH_TOKEN
})

afterEach(() => {
vi.restoreAllMocks()
})

it('GET /health returns ok', async () => {
const res = healthGET()
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ status: 'ok' })
})

it('GET /models returns chat and image models', async () => {
const res = modelsGET()
expect(res.status).toBe(200)
const body = await res.json()
expect(body.chatModels.length).toBeGreaterThan(0)
expect(body.imageModels.length).toBeGreaterThan(0)
const claude = body.chatModels.find((m: any) => m.label === 'claudeOpus')
expect(claude.provider).toBe('anthropic')
expect(body.chatModels.every((m: any) => !m.modelId)).toBe(true)
})

it('rejects invalid chat request bodies', async () => {
const res = await chatPOST(chatRequest({ model: 'claudeOpus' }), params('claude'))
expect(res.status).toBe(400)
expect((await res.json()).error).toBe('invalid request body')
})

it('returns 404 for unknown chat providers', async () => {
const res = await chatPOST(
chatRequest({ model: 'claudeOpus', messages: [{ role: 'user', content: 'hi' }] }),
params('nope')
)
expect(res.status).toBe(404)
})

it('requires bearer token when API_AUTH_TOKEN is set', async () => {
process.env.API_AUTH_TOKEN = 'secret'
const res = await chatPOST(
chatRequest({ model: 'claudeOpus', messages: [{ role: 'user', content: 'hi' }] }),
params('claude')
)
expect(res.status).toBe(401)
})

it('streams claude tokens as normalized SSE events', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(
sseResponse([
'data: {"type":"content_block_delta","delta":{"text":"Hel"}}\n\n',
'data: {"type":"content_block_delta","del',
'ta":{"text":"lo"}}\n\n'
]) as any
)
const res = await chatPOST(
chatRequest({ model: 'claudeOpus', messages: [{ role: 'user', content: 'hi' }] }),
params('claude')
)
expect(res.status).toBe(200)
const text = await res.text()
expect(text).toContain('data: {"content":"Hel"}')
expect(text).toContain('data: {"content":"lo"}')
expect(text).toContain('data: [DONE]')
})

it('streams openai-compatible tokens as normalized SSE events', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(
sseResponse([
'data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n',
'data: [DONE]\n\n'
]) as any
)
const res = await chatPOST(
chatRequest({ model: 'gpt52', messages: [{ role: 'user', content: 'hi' }] }),
params('gpt')
)
expect(res.status).toBe(200)
const text = await res.text()
expect(text).toContain('data: {"content":"Hi"}')
expect(text).toContain('data: [DONE]')
})

it('emits an error event for unsupported models', async () => {
const res = await chatPOST(
chatRequest({ model: 'not-a-model', messages: [{ role: 'user', content: 'hi' }] }),
params('gpt')
)
const text = await res.text()
expect(text).toContain('unsupported model')
expect(text).toContain('data: [DONE]')
})

it('emits an error event when the provider fails', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response('rate limited', { status: 429 }) as any
)
const res = await chatPOST(
chatRequest({ model: 'claudeOpus', messages: [{ role: 'user', content: 'hi' }] }),
params('claude')
)
const text = await res.text()
expect(text).toContain('provider error (429)')
expect(text).toContain('data: [DONE]')
})
})
47 changes: 47 additions & 0 deletions server/app/chat/[provider]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { checkAuth } from '@/lib/auth'
import { checkRateLimit } from '@/lib/rateLimit'
import { chatRequestSchema } from '@/lib/types'
import { CHAT_HANDLERS } from '@/lib/chat'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function POST(
req: Request,
ctx: { params: Promise<{ provider: string }> }
) {
const authError = checkAuth(req)
if (authError) return authError

const limited = checkRateLimit(req)
if (limited) return limited

const { provider } = await ctx.params
const handler = CHAT_HANDLERS[provider]
if (!handler) {
return Response.json({ error: `unknown chat provider: ${provider}` }, { status: 404 })
}

let json: unknown
try {
json = await req.json()
} catch {
return Response.json(
{ error: 'invalid request body', details: ['body must be valid JSON'] },
{ status: 400 }
)
}

const result = chatRequestSchema.safeParse(json)
if (!result.success) {
return Response.json(
{
error: 'invalid request body',
details: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`)
},
{ status: 400 }
)
}

return handler(result.data)
}
5 changes: 5 additions & 0 deletions server/app/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic'

export function GET() {
return Response.json({ status: 'ok' })
}
53 changes: 53 additions & 0 deletions server/app/images/[provider]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { checkAuth } from '@/lib/auth'
import { checkRateLimit } from '@/lib/rateLimit'
import { geminiImage } from '@/lib/images/gemini'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

const IMAGE_HANDLERS: Record<string, typeof geminiImage> = {
gemini: geminiImage
}

export async function POST(
req: Request,
ctx: { params: Promise<{ provider: string }> }
) {
const authError = checkAuth(req)
if (authError) return authError

const limited = checkRateLimit(req)
if (limited) return limited

const { provider } = await ctx.params
const handler = IMAGE_HANDLERS[provider]
if (!handler) {
return Response.json({ error: `unknown image provider: ${provider}` }, { status: 404 })
}

const contentType = req.headers.get('content-type') || ''

let model = ''
let prompt: string | undefined
let file: { mimeType: string; data: string } | undefined

if (contentType.includes('multipart/form-data')) {
const form = await req.formData()
model = form.get('model')?.toString() || ''
prompt = form.get('prompt')?.toString() || undefined
const uploaded = form.get('file')
if (uploaded instanceof File) {
const buffer = Buffer.from(await uploaded.arrayBuffer())
file = {
mimeType: uploaded.type || 'application/octet-stream',
data: buffer.toString('base64')
}
}
} else {
const json = await req.json().catch(() => ({}) as any)
model = typeof json.model === 'string' ? json.model : ''
prompt = typeof json.prompt === 'string' ? json.prompt : undefined
}

return handler({ model, prompt, file })
}
14 changes: 14 additions & 0 deletions server/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'

export const metadata = {
title: 'React Native AI Server',
description: 'Next.js backend proxy for React Native AI'
}

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
19 changes: 19 additions & 0 deletions server/app/models/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CHAT_MODELS, IMAGE_MODELS } from '@/lib/models'

export const dynamic = 'force-dynamic'

export function GET() {
return Response.json({
chatModels: Object.values(CHAT_MODELS).map(({ name, label, provider, supportsVision }) => ({
name,
label,
provider,
supportsVision
})),
imageModels: Object.values(IMAGE_MODELS).map(({ name, label, provider }) => ({
name,
label,
provider
}))
})
}
22 changes: 22 additions & 0 deletions server/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export default function Home() {
return (
<main style={{ fontFamily: 'system-ui, sans-serif', padding: 32 }}>
<h1>React Native AI Server</h1>
<p>The backend proxy is running.</p>
<ul>
<li>
<code>GET /health</code>
</li>
<li>
<code>GET /models</code>
</li>
<li>
<code>POST /chat/:provider</code> (claude, gpt, gemini, glm, kimi)
</li>
<li>
<code>POST /images/:provider</code> (gemini)
</li>
</ul>
</main>
)
}
21 changes: 21 additions & 0 deletions server/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { timingSafeEqual } from 'crypto'

/**
* Optional bearer-token auth. Enabled by setting API_AUTH_TOKEN in the
* server environment; clients must then send `Authorization: Bearer <token>`.
* Returns a 401 Response when the check fails, otherwise null.
*/
export function checkAuth(req: Request): Response | null {
const token = process.env.API_AUTH_TOKEN
if (!token) return null

const header = req.headers.get('authorization') || ''
const expected = Buffer.from(`Bearer ${token}`)
const provided = Buffer.from(header)

if (expected.length === provided.length && timingSafeEqual(expected, provided)) {
return null
}

return Response.json({ error: 'unauthorized' }, { status: 401 })
}
Loading
Loading