traicebox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/00-app-databases-mx6j59m0.sh +28 -0
- package/dist/Caddyfile-at2nzhxs. +18 -0
- package/dist/Dockerfile-bzexf8bh. +7 -0
- package/dist/Dockerfile-tvb2c6ma. +7 -0
- package/dist/bootstrap-client-key-v79zzaxg.sh +39 -0
- package/dist/compose-1b8sxndd.yml +266 -0
- package/dist/compose-vz8yebk4.yml +266 -0
- package/dist/config-tt3vhpk0.yaml +101 -0
- package/dist/config-ymzwdk89.yaml +7 -0
- package/dist/index.js +16487 -0
- package/dist/logging-jyk2svr4.json +35 -0
- package/dist/request_session_metadata_callback-wfkph4zx.py +109 -0
- package/dist/server-bm43enwc.ts +257 -0
- package/dist/server-d2v9t7c1.ts +257 -0
- package/dist/server-dchc55xz.ts +259 -0
- package/dist/server-k3xr84w3.ts +259 -0
- package/dist/start-litellm-1rtmgp2c.sh +16 -0
- package/dist/traicebox-7pysd22b.yaml +2 -0
- package/dist/traicebox-d8k281x5.yaml +2 -0
- package/package.json +50 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"disable_existing_loggers": false,
|
|
4
|
+
"formatters": {
|
|
5
|
+
"default": {
|
|
6
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
7
|
+
"fmt": "%(levelprefix)s %(message)s",
|
|
8
|
+
"use_colors": null
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"handlers": {
|
|
12
|
+
"default": {
|
|
13
|
+
"class": "logging.StreamHandler",
|
|
14
|
+
"formatter": "default",
|
|
15
|
+
"stream": "ext://sys.stderr"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"loggers": {
|
|
19
|
+
"uvicorn": {
|
|
20
|
+
"handlers": ["default"],
|
|
21
|
+
"level": "WARNING",
|
|
22
|
+
"propagate": false
|
|
23
|
+
},
|
|
24
|
+
"uvicorn.error": {
|
|
25
|
+
"handlers": ["default"],
|
|
26
|
+
"level": "WARNING",
|
|
27
|
+
"propagate": false
|
|
28
|
+
},
|
|
29
|
+
"uvicorn.access": {
|
|
30
|
+
"handlers": [],
|
|
31
|
+
"level": "WARNING",
|
|
32
|
+
"propagate": false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from litellm.integrations.custom_logger import CustomLogger
|
|
4
|
+
from litellm.litellm_core_utils.llm_request_utils import (
|
|
5
|
+
get_proxy_server_request_headers,
|
|
6
|
+
)
|
|
7
|
+
from litellm.proxy.proxy_server import DualCache, UserAPIKeyAuth
|
|
8
|
+
from litellm.types.utils import CallTypesLiteral
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SESSION_HEADER_CANDIDATES = (
|
|
12
|
+
"x-litellm-trace-id",
|
|
13
|
+
"x-litellm-session-id",
|
|
14
|
+
"x-session-affinity",
|
|
15
|
+
"x-opencode-session",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_headers(headers: dict) -> dict[str, str]:
|
|
20
|
+
normalized_headers: dict[str, str] = {}
|
|
21
|
+
for key, value in headers.items():
|
|
22
|
+
if key is None or value is None:
|
|
23
|
+
continue
|
|
24
|
+
normalized_headers[str(key).lower()] = str(value).strip()
|
|
25
|
+
return normalized_headers
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_session_id(headers: dict[str, str]) -> Optional[str]:
|
|
29
|
+
for header_name in SESSION_HEADER_CANDIDATES:
|
|
30
|
+
value = headers.get(header_name)
|
|
31
|
+
if value:
|
|
32
|
+
return value
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_incoming_headers(data: dict) -> dict[str, str]:
|
|
37
|
+
secret_fields = data.get("secret_fields")
|
|
38
|
+
if isinstance(secret_fields, dict):
|
|
39
|
+
raw_headers = secret_fields.get("raw_headers")
|
|
40
|
+
if isinstance(raw_headers, dict) and raw_headers:
|
|
41
|
+
return _normalize_headers(raw_headers)
|
|
42
|
+
|
|
43
|
+
litellm_params = data.get("litellm_params")
|
|
44
|
+
if isinstance(litellm_params, dict):
|
|
45
|
+
headers = get_proxy_server_request_headers(litellm_params)
|
|
46
|
+
if headers:
|
|
47
|
+
return _normalize_headers(headers)
|
|
48
|
+
|
|
49
|
+
proxy_server_request = data.get("proxy_server_request")
|
|
50
|
+
if isinstance(proxy_server_request, dict):
|
|
51
|
+
headers = proxy_server_request.get("headers")
|
|
52
|
+
if isinstance(headers, dict) and headers:
|
|
53
|
+
return _normalize_headers(headers)
|
|
54
|
+
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ensure_mapping(data: dict, key: str) -> dict:
|
|
59
|
+
current_value = data.get(key)
|
|
60
|
+
if isinstance(current_value, dict):
|
|
61
|
+
return current_value
|
|
62
|
+
|
|
63
|
+
replacement: dict = {}
|
|
64
|
+
data[key] = replacement
|
|
65
|
+
return replacement
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RequestSessionMetadataCallback(CustomLogger):
|
|
69
|
+
async def async_pre_call_hook(
|
|
70
|
+
self,
|
|
71
|
+
user_api_key_dict: UserAPIKeyAuth,
|
|
72
|
+
cache: DualCache,
|
|
73
|
+
data: dict,
|
|
74
|
+
call_type: CallTypesLiteral,
|
|
75
|
+
):
|
|
76
|
+
del user_api_key_dict, cache, call_type
|
|
77
|
+
|
|
78
|
+
if data.get("litellm_session_id"):
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
metadata = _ensure_mapping(data, "metadata")
|
|
82
|
+
if metadata.get("session_id"):
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
litellm_metadata = _ensure_mapping(data, "litellm_metadata")
|
|
86
|
+
if litellm_metadata.get("session_id"):
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
headers = _get_incoming_headers(data)
|
|
90
|
+
session_id = _extract_session_id(headers)
|
|
91
|
+
if not session_id:
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
parent_session_id = headers.get("x-parent-session-id")
|
|
95
|
+
data["litellm_session_id"] = session_id
|
|
96
|
+
data["litellm_trace_id"] = session_id
|
|
97
|
+
metadata.setdefault("session_id", session_id)
|
|
98
|
+
metadata.setdefault("trace_id", session_id)
|
|
99
|
+
litellm_metadata.setdefault("session_id", session_id)
|
|
100
|
+
litellm_metadata.setdefault("trace_id", session_id)
|
|
101
|
+
|
|
102
|
+
if parent_session_id:
|
|
103
|
+
metadata.setdefault("parent_session_id", parent_session_id)
|
|
104
|
+
litellm_metadata.setdefault("parent_session_id", parent_session_id)
|
|
105
|
+
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
proxy_handler_instance = RequestSessionMetadataCallback()
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const upstreamOrigin = requiredEnv("LANGFUSE_UPSTREAM_ORIGIN");
|
|
2
|
+
const publicOrigin = requiredEnv("LANGFUSE_PUBLIC_ORIGIN");
|
|
3
|
+
const adminEmail = requiredEnv("LANGFUSE_INIT_USER_EMAIL");
|
|
4
|
+
const adminPassword = requiredEnv("LANGFUSE_INIT_USER_PASSWORD");
|
|
5
|
+
const autoLoginEnabled = envFlag("LANGFUSE_PROXY_AUTOLOGIN", true);
|
|
6
|
+
const port = Number(Bun.env.PORT ?? "3000");
|
|
7
|
+
const retryDelayMs = Number(Bun.env.LANGFUSE_PROXY_RETRY_DELAY_MS ?? "500");
|
|
8
|
+
const maxRetries = Number(Bun.env.LANGFUSE_PROXY_MAX_RETRIES ?? "20");
|
|
9
|
+
|
|
10
|
+
const sessionCookieNames = [
|
|
11
|
+
"__Secure-authjs.session-token",
|
|
12
|
+
"authjs.session-token",
|
|
13
|
+
"__Secure-next-auth.session-token",
|
|
14
|
+
"next-auth.session-token",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function requiredEnv(name: string): string {
|
|
18
|
+
const value = Bun.env[name];
|
|
19
|
+
if (!value) {
|
|
20
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envFlag(name: string, fallback: boolean): boolean {
|
|
26
|
+
const value = Bun.env[name];
|
|
27
|
+
if (value == null || value === "") {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
return !["0", "false", "no", "off"].includes(value.toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getSetCookies(headers: Headers): string[] {
|
|
34
|
+
const bunHeaders = headers as Headers & { getSetCookie?: () => string[] };
|
|
35
|
+
if (typeof bunHeaders.getSetCookie === "function") {
|
|
36
|
+
return bunHeaders.getSetCookie();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const singleValue = headers.get("set-cookie");
|
|
40
|
+
return singleValue ? [singleValue] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function serializeCookieHeader(setCookies: string[]): string {
|
|
44
|
+
return setCookies
|
|
45
|
+
.map((cookie) => cookie.split(";", 1)[0]?.trim())
|
|
46
|
+
.filter((cookie): cookie is string => Boolean(cookie))
|
|
47
|
+
.join("; ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasSessionCookie(request: Request): boolean {
|
|
51
|
+
const cookieHeader = request.headers.get("cookie");
|
|
52
|
+
if (!cookieHeader) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return sessionCookieNames.some((name) => cookieHeader.includes(`${name}=`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendSetCookies(headers: Headers, cookies: string[]): void {
|
|
60
|
+
for (const cookie of cookies) {
|
|
61
|
+
headers.append("set-cookie", cookie);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sleep(ms: number): Promise<void> {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchWithRetry(url: URL, init: RequestInit): Promise<Response> {
|
|
70
|
+
let lastError: unknown;
|
|
71
|
+
|
|
72
|
+
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
73
|
+
try {
|
|
74
|
+
return await fetch(url, init);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
lastError = error;
|
|
77
|
+
if (attempt === maxRetries) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
await sleep(retryDelayMs);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw lastError;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function copyRequestHeaders(request: Request): Headers {
|
|
88
|
+
const headers = new Headers(request.headers);
|
|
89
|
+
headers.delete("host");
|
|
90
|
+
headers.delete("connection");
|
|
91
|
+
headers.delete("content-length");
|
|
92
|
+
return headers;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loginToLangfuse(): Promise<string[]> {
|
|
96
|
+
const csrfResponse = await fetchWithRetry(
|
|
97
|
+
new URL("/api/auth/csrf", upstreamOrigin),
|
|
98
|
+
{
|
|
99
|
+
redirect: "manual",
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (!csrfResponse.ok) {
|
|
104
|
+
throw new Error(`Langfuse CSRF request failed with ${csrfResponse.status}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const csrfPayload = (await csrfResponse.json()) as { csrfToken?: string };
|
|
108
|
+
if (!csrfPayload.csrfToken) {
|
|
109
|
+
throw new Error("Langfuse CSRF response did not include csrfToken");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const csrfCookies = getSetCookies(csrfResponse.headers);
|
|
113
|
+
const callbackBody = new URLSearchParams({
|
|
114
|
+
email: adminEmail,
|
|
115
|
+
password: adminPassword,
|
|
116
|
+
csrfToken: csrfPayload.csrfToken,
|
|
117
|
+
callbackUrl: publicOrigin,
|
|
118
|
+
json: "true",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const callbackResponse = await fetchWithRetry(
|
|
122
|
+
new URL("/api/auth/callback/credentials?json=true", upstreamOrigin),
|
|
123
|
+
{
|
|
124
|
+
method: "POST",
|
|
125
|
+
redirect: "manual",
|
|
126
|
+
headers: {
|
|
127
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
128
|
+
cookie: serializeCookieHeader(csrfCookies),
|
|
129
|
+
},
|
|
130
|
+
body: callbackBody.toString(),
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (callbackResponse.status >= 400) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Langfuse credentials callback failed with ${callbackResponse.status}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mergedCookies = [
|
|
141
|
+
...csrfCookies,
|
|
142
|
+
...getSetCookies(callbackResponse.headers),
|
|
143
|
+
];
|
|
144
|
+
if (!serializeCookieHeader(mergedCookies)) {
|
|
145
|
+
throw new Error("Langfuse login callback did not return any cookies");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return mergedCookies;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function checkLangfuseReadiness(): Promise<void> {
|
|
152
|
+
const loginCookies = autoLoginEnabled ? await loginToLangfuse() : [];
|
|
153
|
+
const headers = new Headers();
|
|
154
|
+
const cookieHeader = serializeCookieHeader(loginCookies);
|
|
155
|
+
if (cookieHeader) {
|
|
156
|
+
headers.set("cookie", cookieHeader);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const response = await fetchWithRetry(new URL("/", upstreamOrigin), {
|
|
160
|
+
headers,
|
|
161
|
+
redirect: "manual",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
165
|
+
if (response.status >= 400 || !contentType.includes("text/html")) {
|
|
166
|
+
throw new Error(`Langfuse readiness failed with ${response.status}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function proxyRequest(
|
|
171
|
+
request: Request,
|
|
172
|
+
extraCookies: string[] = [],
|
|
173
|
+
): Promise<Response> {
|
|
174
|
+
const upstreamUrl = new URL(request.url);
|
|
175
|
+
upstreamUrl.protocol = new URL(upstreamOrigin).protocol;
|
|
176
|
+
upstreamUrl.host = new URL(upstreamOrigin).host;
|
|
177
|
+
|
|
178
|
+
const headers = copyRequestHeaders(request);
|
|
179
|
+
if (extraCookies.length > 0) {
|
|
180
|
+
const existingCookies = request.headers.get("cookie");
|
|
181
|
+
const loginCookies = serializeCookieHeader(extraCookies);
|
|
182
|
+
const combinedCookies = [existingCookies, loginCookies]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join("; ");
|
|
185
|
+
if (combinedCookies) {
|
|
186
|
+
headers.set("cookie", combinedCookies);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const response = await fetchWithRetry(upstreamUrl, {
|
|
191
|
+
method: request.method,
|
|
192
|
+
headers,
|
|
193
|
+
body:
|
|
194
|
+
request.method === "GET" || request.method === "HEAD"
|
|
195
|
+
? undefined
|
|
196
|
+
: request.body,
|
|
197
|
+
redirect: "manual",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const responseHeaders = new Headers(response.headers);
|
|
201
|
+
responseHeaders.delete("content-encoding");
|
|
202
|
+
responseHeaders.delete("content-length");
|
|
203
|
+
|
|
204
|
+
return new Response(response.body, {
|
|
205
|
+
status: response.status,
|
|
206
|
+
statusText: response.statusText,
|
|
207
|
+
headers: responseHeaders,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const server = Bun.serve({
|
|
212
|
+
port,
|
|
213
|
+
async fetch(request: Request): Promise<Response> {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
|
|
216
|
+
if (url.pathname === "/healthz") {
|
|
217
|
+
return new Response("ok");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (url.pathname === "/readyz") {
|
|
221
|
+
try {
|
|
222
|
+
await checkLangfuseReadiness();
|
|
223
|
+
return new Response("ok");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error("[langfuse-proxy] readiness check failed", error);
|
|
226
|
+
return new Response("not ready", { status: 503 });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!autoLoginEnabled || url.pathname.startsWith("/api/auth/")) {
|
|
231
|
+
return proxyRequest(request);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (hasSessionCookie(request)) {
|
|
235
|
+
return proxyRequest(request);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const loginCookies = await loginToLangfuse();
|
|
240
|
+
const response = await proxyRequest(request, loginCookies);
|
|
241
|
+
const headers = new Headers(response.headers);
|
|
242
|
+
appendSetCookies(headers, loginCookies);
|
|
243
|
+
headers.set("cache-control", "no-store");
|
|
244
|
+
|
|
245
|
+
return new Response(response.body, {
|
|
246
|
+
status: response.status,
|
|
247
|
+
statusText: response.statusText,
|
|
248
|
+
headers,
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error("[langfuse-proxy] auto-login failed", error);
|
|
252
|
+
return new Response("Langfuse auto-login failed", { status: 502 });
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
console.log(`[langfuse-proxy] listening on :${server.port}`);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const upstreamOrigin = requiredEnv("LANGFUSE_UPSTREAM_ORIGIN");
|
|
2
|
+
const publicOrigin = requiredEnv("LANGFUSE_PUBLIC_ORIGIN");
|
|
3
|
+
const adminEmail = requiredEnv("LANGFUSE_INIT_USER_EMAIL");
|
|
4
|
+
const adminPassword = requiredEnv("LANGFUSE_INIT_USER_PASSWORD");
|
|
5
|
+
const autoLoginEnabled = envFlag("LANGFUSE_PROXY_AUTOLOGIN", true);
|
|
6
|
+
const port = Number(Bun.env.PORT ?? "3000");
|
|
7
|
+
const retryDelayMs = Number(Bun.env.LANGFUSE_PROXY_RETRY_DELAY_MS ?? "500");
|
|
8
|
+
const maxRetries = Number(Bun.env.LANGFUSE_PROXY_MAX_RETRIES ?? "20");
|
|
9
|
+
|
|
10
|
+
const sessionCookieNames = [
|
|
11
|
+
"__Secure-authjs.session-token",
|
|
12
|
+
"authjs.session-token",
|
|
13
|
+
"__Secure-next-auth.session-token",
|
|
14
|
+
"next-auth.session-token",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function requiredEnv(name: string): string {
|
|
18
|
+
const value = Bun.env[name];
|
|
19
|
+
if (!value) {
|
|
20
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envFlag(name: string, fallback: boolean): boolean {
|
|
26
|
+
const value = Bun.env[name];
|
|
27
|
+
if (value == null || value === "") {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
return !["0", "false", "no", "off"].includes(value.toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getSetCookies(headers: Headers): string[] {
|
|
34
|
+
const bunHeaders = headers as Headers & { getSetCookie?: () => string[] };
|
|
35
|
+
if (typeof bunHeaders.getSetCookie === "function") {
|
|
36
|
+
return bunHeaders.getSetCookie();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const singleValue = headers.get("set-cookie");
|
|
40
|
+
return singleValue ? [singleValue] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function serializeCookieHeader(setCookies: string[]): string {
|
|
44
|
+
return setCookies
|
|
45
|
+
.map((cookie) => cookie.split(";", 1)[0]?.trim())
|
|
46
|
+
.filter((cookie): cookie is string => Boolean(cookie))
|
|
47
|
+
.join("; ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasSessionCookie(request: Request): boolean {
|
|
51
|
+
const cookieHeader = request.headers.get("cookie");
|
|
52
|
+
if (!cookieHeader) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return sessionCookieNames.some((name) => cookieHeader.includes(`${name}=`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendSetCookies(headers: Headers, cookies: string[]): void {
|
|
60
|
+
for (const cookie of cookies) {
|
|
61
|
+
headers.append("set-cookie", cookie);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sleep(ms: number): Promise<void> {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchWithRetry(url: URL, init: RequestInit): Promise<Response> {
|
|
70
|
+
let lastError: unknown;
|
|
71
|
+
|
|
72
|
+
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
73
|
+
try {
|
|
74
|
+
return await fetch(url, init);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
lastError = error;
|
|
77
|
+
if (attempt === maxRetries) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
await sleep(retryDelayMs);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw lastError;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function copyRequestHeaders(request: Request): Headers {
|
|
88
|
+
const headers = new Headers(request.headers);
|
|
89
|
+
headers.delete("host");
|
|
90
|
+
headers.delete("connection");
|
|
91
|
+
headers.delete("content-length");
|
|
92
|
+
return headers;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loginToLangfuse(): Promise<string[]> {
|
|
96
|
+
const csrfResponse = await fetchWithRetry(
|
|
97
|
+
new URL("/api/auth/csrf", upstreamOrigin),
|
|
98
|
+
{
|
|
99
|
+
redirect: "manual",
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (!csrfResponse.ok) {
|
|
104
|
+
throw new Error(`Langfuse CSRF request failed with ${csrfResponse.status}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const csrfPayload = (await csrfResponse.json()) as { csrfToken?: string };
|
|
108
|
+
if (!csrfPayload.csrfToken) {
|
|
109
|
+
throw new Error("Langfuse CSRF response did not include csrfToken");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const csrfCookies = getSetCookies(csrfResponse.headers);
|
|
113
|
+
const callbackBody = new URLSearchParams({
|
|
114
|
+
email: adminEmail,
|
|
115
|
+
password: adminPassword,
|
|
116
|
+
csrfToken: csrfPayload.csrfToken,
|
|
117
|
+
callbackUrl: publicOrigin,
|
|
118
|
+
json: "true",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const callbackResponse = await fetchWithRetry(
|
|
122
|
+
new URL("/api/auth/callback/credentials?json=true", upstreamOrigin),
|
|
123
|
+
{
|
|
124
|
+
method: "POST",
|
|
125
|
+
redirect: "manual",
|
|
126
|
+
headers: {
|
|
127
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
128
|
+
cookie: serializeCookieHeader(csrfCookies),
|
|
129
|
+
},
|
|
130
|
+
body: callbackBody.toString(),
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (callbackResponse.status >= 400) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Langfuse credentials callback failed with ${callbackResponse.status}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mergedCookies = [
|
|
141
|
+
...csrfCookies,
|
|
142
|
+
...getSetCookies(callbackResponse.headers),
|
|
143
|
+
];
|
|
144
|
+
if (!serializeCookieHeader(mergedCookies)) {
|
|
145
|
+
throw new Error("Langfuse login callback did not return any cookies");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return mergedCookies;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function checkLangfuseReadiness(): Promise<void> {
|
|
152
|
+
const loginCookies = autoLoginEnabled ? await loginToLangfuse() : [];
|
|
153
|
+
const headers = new Headers();
|
|
154
|
+
const cookieHeader = serializeCookieHeader(loginCookies);
|
|
155
|
+
if (cookieHeader) {
|
|
156
|
+
headers.set("cookie", cookieHeader);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const response = await fetchWithRetry(new URL("/", upstreamOrigin), {
|
|
160
|
+
headers,
|
|
161
|
+
redirect: "manual",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
165
|
+
if (response.status >= 400 || !contentType.includes("text/html")) {
|
|
166
|
+
throw new Error(`Langfuse readiness failed with ${response.status}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function proxyRequest(
|
|
171
|
+
request: Request,
|
|
172
|
+
extraCookies: string[] = [],
|
|
173
|
+
): Promise<Response> {
|
|
174
|
+
const upstreamUrl = new URL(request.url);
|
|
175
|
+
upstreamUrl.protocol = new URL(upstreamOrigin).protocol;
|
|
176
|
+
upstreamUrl.host = new URL(upstreamOrigin).host;
|
|
177
|
+
|
|
178
|
+
const headers = copyRequestHeaders(request);
|
|
179
|
+
if (extraCookies.length > 0) {
|
|
180
|
+
const existingCookies = request.headers.get("cookie");
|
|
181
|
+
const loginCookies = serializeCookieHeader(extraCookies);
|
|
182
|
+
const combinedCookies = [existingCookies, loginCookies]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join("; ");
|
|
185
|
+
if (combinedCookies) {
|
|
186
|
+
headers.set("cookie", combinedCookies);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const response = await fetchWithRetry(upstreamUrl, {
|
|
191
|
+
method: request.method,
|
|
192
|
+
headers,
|
|
193
|
+
body:
|
|
194
|
+
request.method === "GET" || request.method === "HEAD"
|
|
195
|
+
? undefined
|
|
196
|
+
: request.body,
|
|
197
|
+
redirect: "manual",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const responseHeaders = new Headers(response.headers);
|
|
201
|
+
responseHeaders.delete("content-encoding");
|
|
202
|
+
responseHeaders.delete("content-length");
|
|
203
|
+
|
|
204
|
+
return new Response(response.body, {
|
|
205
|
+
status: response.status,
|
|
206
|
+
statusText: response.statusText,
|
|
207
|
+
headers: responseHeaders,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const server = Bun.serve({
|
|
212
|
+
port,
|
|
213
|
+
async fetch(request: Request): Promise<Response> {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
|
|
216
|
+
if (url.pathname === "/healthz") {
|
|
217
|
+
return new Response("ok");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (url.pathname === "/readyz") {
|
|
221
|
+
try {
|
|
222
|
+
await checkLangfuseReadiness();
|
|
223
|
+
return new Response("ok");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error("[langfuse-proxy] readiness check failed", error);
|
|
226
|
+
return new Response("not ready", { status: 503 });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!autoLoginEnabled || url.pathname.startsWith("/api/auth/")) {
|
|
231
|
+
return proxyRequest(request);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (hasSessionCookie(request)) {
|
|
235
|
+
return proxyRequest(request);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const loginCookies = await loginToLangfuse();
|
|
240
|
+
const response = await proxyRequest(request, loginCookies);
|
|
241
|
+
const headers = new Headers(response.headers);
|
|
242
|
+
appendSetCookies(headers, loginCookies);
|
|
243
|
+
headers.set("cache-control", "no-store");
|
|
244
|
+
|
|
245
|
+
return new Response(response.body, {
|
|
246
|
+
status: response.status,
|
|
247
|
+
statusText: response.statusText,
|
|
248
|
+
headers,
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error("[langfuse-proxy] auto-login failed", error);
|
|
252
|
+
return new Response("Langfuse auto-login failed", { status: 502 });
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
console.log(`[langfuse-proxy] listening on :${server.port}`);
|