-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.ts
More file actions
156 lines (135 loc) · 4.99 KB
/
Copy pathproxy.ts
File metadata and controls
156 lines (135 loc) · 4.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
// proxy.ts
//
// Image-routing proxy for Anthropic-style LLM APIs.
//
// Routes image-bearing requests to a vision model, everything else
// to the default model. Transparent forward for SSE streams.
//
// Run:
// bun run proxy.ts
//
// Config: config.json (next to this file) — see config.example.json.
// Or set env vars:
// ROUTER_PORT (default 3456)
// ROUTER_UPSTREAM (default https://openrouter.ai/api)
// ROUTER_VISION (default qwen/qwen3.6-flash)
import { readFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { isAddressInUse, reclaimPort } from "./port.ts";
import {
forwardHeaders,
getErrorMessage,
hasImage,
isCountTokens,
isMessagesRequest,
jsonError,
needsVision,
responseHeaders,
stripImages,
} from "./routing.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load config.json if present, fall back to env vars.
interface Config {
port: number;
upstream: string;
visionModel: string;
}
const configPath = join(__dirname, "config.json");
const config: Config = existsSync(configPath)
? JSON.parse(readFileSync(configPath, "utf-8"))
: {
port: Number(process.env.ROUTER_PORT ?? 3456),
upstream: process.env.ROUTER_UPSTREAM ?? "https://openrouter.ai/api",
visionModel: process.env.ROUTER_VISION ?? "qwen/qwen3.6-flash",
};
// Loopback only: this proxy relays any request bearing an Authorization header
// straight to the upstream, so it must not be reachable from the local network.
const HOSTNAME = "127.0.0.1";
// LLM responses stream with long silent gaps while the model thinks. Bun's 10s
// default idle timeout severs those connections mid-response.
const IDLE_TIMEOUT_DISABLED = 0;
interface MessagesRequest {
messages?: unknown;
model?: string;
}
// Two cases, and the second one is not optional: the client resends the whole
// conversation each turn, so an image stays in the payload long after the turn it
// arrived in. Routing every one of those turns to the vision model would strand the
// session there, but forwarding the stale image to the text-only model 404s it — so
// the image has to come out of the history instead.
function route(request: MessagesRequest): MessagesRequest {
if (needsVision(request.messages)) {
console.log(`[router] image in newest turn -> ${config.visionModel}`);
return { ...request, model: config.visionModel };
}
if (hasImage(request.messages)) {
console.log(`[router] stale image in history, stripped -> ${request.model}`);
return { ...request, messages: stripImages(request.messages) };
}
return request;
}
async function handle(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "GET" && (url.pathname === "/__health" || url.pathname === "/")) {
return new Response("ok\n", { status: 200, headers: { "content-type": "text/plain" } });
}
// The upstream resets the connection on this endpoint rather than answering it.
// Token counting is optional: on 404 the client falls back to local estimation.
if (isCountTokens(url.pathname)) {
return jsonError(404, "count_tokens is not supported by the upstream");
}
const headers = forwardHeaders(req.headers);
let body: BodyInit | null | undefined;
if (isMessagesRequest(req.method, url.pathname)) {
const raw = await req.text();
try {
const parsed = JSON.parse(raw) as { messages?: unknown; model?: string };
body = JSON.stringify(route(parsed));
headers.set("content-type", "application/json");
} catch {
console.log(`[router] non-JSON body, passing through`);
body = raw;
}
} else {
body = req.body;
}
const upstream = config.upstream + url.pathname + url.search;
try {
const upstreamResp = await fetch(upstream, {
method: req.method,
headers,
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
});
// Body and status pass through untouched: the client's retry logic matches on
// the upstream's own error wording, so wrapping it would break recovery.
// Headers are re-derived because fetch already inflated the body.
return new Response(upstreamResp.body, {
status: upstreamResp.status,
headers: responseHeaders(upstreamResp.headers),
});
} catch (error: unknown) {
const message = getErrorMessage(error);
console.error(`[router] upstream ${req.method} ${url.pathname} failed: ${message}`);
return jsonError(502, message);
}
}
function listen() {
return Bun.serve({
port: config.port,
hostname: HOSTNAME,
idleTimeout: IDLE_TIMEOUT_DISABLED,
fetch: handle,
});
}
// Take over the port from a previous instance rather than failing to start.
// Only attempted once, and only when the port is genuinely occupied — a normal
// start pays no cost for this.
try {
listen();
} catch (error: unknown) {
if (!isAddressInUse(error)) throw error;
await reclaimPort(config.port);
listen();
}
console.log(`[router] listening on ${HOSTNAME}:${config.port}`);