Skip to content
Closed
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
365 changes: 62 additions & 303 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

137 changes: 137 additions & 0 deletions src/lib/api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Context, Next } from "hono"

import consola from "consola"

import { state } from "~/lib/state"

const AUTH_WINDOW_MS = 5 * 60 * 1000
const AUTH_MAX_FAILURES = 10
const AUTH_BLOCK_MS = 15 * 60 * 1000

function getBearerToken(
authorizationHeader: string | undefined,
): string | undefined {
if (!authorizationHeader) return undefined

const [scheme, token] = authorizationHeader.trim().split(/\s+/, 2)
if (scheme.toLowerCase() !== "bearer" || !token) return undefined

return token
}

function getForwardedIp(
forwardedHeader: string | undefined,
): string | undefined {
if (!forwardedHeader) return undefined

const match = forwardedHeader.match(/for="?\[?([^;,"]+)/i)
return match?.[1]?.trim()
}

function getClientAddress(c: Context): string {
const candidates = [
c.req.header("cf-connecting-ip"),
c.req.header("x-real-ip"),
c.req.header("x-client-ip"),
c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
getForwardedIp(c.req.header("forwarded")),
c.req.header("fly-client-ip"),
]

return candidates.find((value) => value && value.length > 0) ?? "unknown"
}

function getRequestTarget(c: Context): string {
try {
const url = new URL(c.req.url)
return `${c.req.method} ${url.pathname}`
} catch {
return `${c.req.method} unknown`
}
}

function isClientBlocked(clientAddress: string, now: number): boolean {
const entry = state.authFailures.get(clientAddress)
if (!entry?.blockedUntil) return false

if (entry.blockedUntil <= now) {
state.authFailures.delete(clientAddress)
return false
}

return true
}

function recordAuthFailure(clientAddress: string, now: number): void {
const entry = state.authFailures.get(clientAddress)

if (!entry || entry.resetAt <= now) {
state.authFailures.set(clientAddress, {
blockedUntil: undefined,
count: 1,
resetAt: now + AUTH_WINDOW_MS,
})
return
}

entry.count += 1
if (entry.count >= AUTH_MAX_FAILURES) {
entry.blockedUntil = now + AUTH_BLOCK_MS
}
}

function clearAuthFailures(clientAddress: string): void {
state.authFailures.delete(clientAddress)
}

function rejectUnauthorized() {
return {
error: {
message: "Invalid API key",
type: "authentication_error",
},
}
}

export async function safeRequestLogger(c: Context, next: Next) {
const startedAt = Date.now()
const target = getRequestTarget(c)

consola.info(`<-- ${target}`)
await next()
consola.info(`--> ${target} ${c.res.status} ${Date.now() - startedAt}ms`)
Comment on lines +101 to +102
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safeRequestLogger doesn’t use a try/finally, so if downstream middleware/handlers throw, the outbound log line (--> ...) will never be emitted. Wrapping await next() in try/finally (and logging in finally) will ensure consistent request timing logs even on errors.

Suggested change
await next()
consola.info(`--> ${target} ${c.res.status} ${Date.now() - startedAt}ms`)
try {
await next()
} finally {
consola.info(`--> ${target} ${c.res.status} ${Date.now() - startedAt}ms`)
}

Copilot uses AI. Check for mistakes.
}

export async function requireApiKey(c: Context, next: Next) {
if (!state.apiKey) {
await next()
return
}

const now = Date.now()
const clientAddress = getClientAddress(c)

if (isClientBlocked(clientAddress, now)) {
consola.warn(
`Blocked API key request from ${clientAddress} to ${getRequestTarget(c)}`,
)
return c.json(rejectUnauthorized(), 429)
}

const authorization = c.req.header("authorization")
const bearerToken = getBearerToken(authorization)
const xApiKey = c.req.header("x-api-key")

if (bearerToken === state.apiKey || xApiKey === state.apiKey) {
clearAuthFailures(clientAddress)
await next()
return
}

recordAuthFailure(clientAddress, now)
consola.warn(
`Rejected API key request from ${clientAddress} to ${getRequestTarget(c)}`,
)

return c.json(rejectUnauthorized(), 401)
}
134 changes: 124 additions & 10 deletions src/lib/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"

import consola from "consola"

import { buildPassthroughHeaders } from "~/lib/transport"

export class HTTPError extends Error {
response: Response

Expand All @@ -12,8 +14,107 @@ export class HTTPError extends Error {
}
}

function getJsonErrorBody(errorJson: unknown, fallbackText: string) {
if (
typeof errorJson === "object"
&& errorJson !== null
&& "error" in errorJson
&& typeof errorJson.error === "object"
&& errorJson.error !== null
) {
return errorJson
}

return {
error: {
message: fallbackText,
type: "error",
},
}
}

function isAnthropicRoute(c: Context): boolean {
return (
c.req.path === "/messages"
|| c.req.path === "/v1/messages"
|| c.req.path === "/messages/count_tokens"
|| c.req.path === "/v1/messages/count_tokens"
)
}

function extractErrorMessage(errorJson: unknown, fallbackText: string): string {
if (
typeof errorJson === "object"
&& errorJson !== null
&& "error" in errorJson
&& typeof errorJson.error === "object"
&& errorJson.error !== null
&& "message" in errorJson.error
&& typeof errorJson.error.message === "string"
) {
return errorJson.error.message
}

return fallbackText
}

function extractErrorType(errorJson: unknown): string {
if (
typeof errorJson === "object"
&& errorJson !== null
&& "error" in errorJson
&& typeof errorJson.error === "object"
&& errorJson.error !== null
&& "type" in errorJson.error
&& typeof errorJson.error.type === "string"
) {
return errorJson.error.type
}

return "api_error"
}

function getAnthropicErrorBody(errorJson: unknown, fallbackText: string) {
if (
typeof errorJson === "object"
&& errorJson !== null
&& "type" in errorJson
&& errorJson.type === "error"
&& "error" in errorJson
&& typeof errorJson.error === "object"
&& errorJson.error !== null
) {
return errorJson
}

if (
typeof errorJson === "object"
&& errorJson !== null
&& "error" in errorJson
&& typeof errorJson.error === "object"
&& errorJson.error !== null
) {
return {
type: "error",
error: {
type: extractErrorType(errorJson),
message: extractErrorMessage(errorJson, fallbackText),
},
}
}

return {
type: "error",
error: {
type: "api_error",
message: fallbackText,
},
}
}

export async function forwardError(c: Context, error: unknown) {
consola.error("Error occurred:", error)
const surface = isAnthropicRoute(c) ? "anthropic" : "openai"

if (error instanceof HTTPError) {
const errorText = await error.response.text()
Expand All @@ -24,24 +125,37 @@ export async function forwardError(c: Context, error: unknown) {
errorJson = errorText
}
consola.error("HTTP error:", errorJson)
return c.json(

return new Response(
JSON.stringify(
surface === "anthropic" ?
getAnthropicErrorBody(errorJson, errorText)
: getJsonErrorBody(errorJson, errorText),
),
{
error: {
message: errorText,
type: "error",
},
status: error.response.status as ContentfulStatusCode,
headers: buildPassthroughHeaders(error.response.headers, surface, {
includeContentType: true,
}),
},
error.response.status as ContentfulStatusCode,
)
}

return c.json(
{
error: {
message: (error as Error).message,
surface === "anthropic" ?
{
type: "error",
error: {
type: "api_error",
message: (error as Error).message,
},
}
: {
error: {
message: (error as Error).message,
type: "error",
},
},
},
500,
)
}
Loading