myapikey 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.
@@ -0,0 +1,226 @@
1
+ import { Hono, type Context, type MiddlewareHandler } from "hono";
2
+ import { trimBase } from "../shared/config";
3
+ import type { Format, Provider, RouteKey } from "../shared/types";
4
+ import type { Store } from "./store";
5
+
6
+ /** HTTP statuses that should trigger failover to the next provider. */
7
+ const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
8
+
9
+ /** Headers copied from upstream back to the client. */
10
+ const COPY_DOWN = ["content-type", "cache-control", "x-request-id", "openai-organization", "anthropic-ratelimit-requests-reset"];
11
+
12
+ /** Resolve the ordered, compatible provider list for a model on a routing slot. */
13
+ function candidates(store: Store, model: string, key: RouteKey): Provider[] {
14
+ const d = store.get();
15
+ const entry = d.models[model];
16
+ const fe = entry?.[key];
17
+ if (!fe?.enabled) return [];
18
+ const byId = new Map(d.providers.map((p) => [p.id, p]));
19
+ // Defense-in-depth: openai/anthropic require that wire format; responses
20
+ // requires supportsResponses. (Admin keeps chains pure, but a provider's
21
+ // formats/flag can be edited afterwards.)
22
+ return fe.providers
23
+ .map((id) => byId.get(id))
24
+ .filter((p): p is Provider => {
25
+ if (!p) return false;
26
+ return key === "responses" ? !!p.supportsResponses : p.formats.includes(key);
27
+ });
28
+ }
29
+
30
+ function notFound(c: Context, model: string) {
31
+ return c.json(
32
+ {
33
+ error: {
34
+ message: `model '${model}' is not available (not enabled or no provider speaks this format)`,
35
+ type: "invalid_request_error",
36
+ code: "model_not_found",
37
+ },
38
+ },
39
+ 404,
40
+ );
41
+ }
42
+
43
+ /** Auth headers for the Anthropic wire format. Sends BOTH x-api-key and
44
+ * Authorization: Bearer (same key). Native Anthropic (api.anthropic.com)
45
+ * accepts either; anthropic- COMPATIBLE surfaces (sensenova, Volcengine Ark,
46
+ * …) typically honor ONLY Authorization: Bearer and 401 on bare x-api-key.
47
+ * Each server uses the header it recognizes and ignores the other, so one
48
+ * request satisfies either flavor. (Anthropic's own C# SDK sends both.) */
49
+ export function anthropicAuthHeaders(apiKey: string, version: string): Record<string, string> {
50
+ return { "x-api-key": apiKey, authorization: `Bearer ${apiKey}`, "anthropic-version": version };
51
+ }
52
+
53
+ function upstreamHeaders(provider: Provider, format: Format, clientVersion?: string): Record<string, string> {
54
+ const h: Record<string, string> = { "content-type": "application/json" };
55
+ if (format === "openai") h.authorization = `Bearer ${provider.apiKey}`;
56
+ else Object.assign(h, anthropicAuthHeaders(provider.apiKey, clientVersion || "2023-06-01"));
57
+ return h;
58
+ }
59
+
60
+ /** Resolve the upstream URL + wire format for a routing slot. The OpenAI base
61
+ * includes the version segment (we append the bare resource); the Anthropic
62
+ * base excludes /v1 (we append v1/messages). /responses reuses the OpenAI base. */
63
+ function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format } {
64
+ if (key === "anthropic") {
65
+ return { url: `${trimBase(p.baseUrlAnthropic)}/v1/messages`, wire: "anthropic" };
66
+ }
67
+ const path = key === "responses" ? "responses" : "chat/completions";
68
+ return { url: `${trimBase(p.baseUrlOpenai)}/${path}`, wire: "openai" };
69
+ }
70
+
71
+ function passThrough(upstream: Response, servedBy?: string): Response {
72
+ const headers = new Headers();
73
+ for (const h of COPY_DOWN) {
74
+ const v = upstream.headers.get(h);
75
+ if (v) headers.set(h, v);
76
+ }
77
+ // Internal hook for the model-page "test": report which provider answered.
78
+ // Only set on in-process probe calls (see isProbe in dispatch), so it never
79
+ // appears on responses to real agent clients.
80
+ if (servedBy) headers.set("x-myapikey-provider", servedBy);
81
+ // Stream the upstream body straight through (handles SSE + normal JSON).
82
+ return new Response(upstream.body, { status: upstream.status, headers });
83
+ }
84
+
85
+ /** Pull a short human-readable message out of an upstream error body. */
86
+ export function shortError(text: string): string {
87
+ try {
88
+ const j = JSON.parse(text) as { error?: { message?: string }; message?: string };
89
+ return (j.error?.message || j.message || text).slice(0, 200);
90
+ } catch {
91
+ return text.slice(0, 200);
92
+ }
93
+ }
94
+
95
+ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
96
+ const app = new Hono();
97
+ app.use("*", auth);
98
+
99
+ /** Shared dispatch with failover. `key` selects the routing slot (and thus the
100
+ * candidate chain); `wire`/`path` derive from it for the upstream call. */
101
+ const dispatch = async (c: Context, key: RouteKey) => {
102
+ const body = await c.req.json().catch(() => null);
103
+ if (!body || typeof body.model !== "string") {
104
+ return c.json({ error: { message: "request body must be JSON with a 'model' field", type: "invalid_request_error" } }, 400);
105
+ }
106
+ const model: string = body.model;
107
+ const wire: Format = key === "anthropic" ? "anthropic" : "openai";
108
+ const stream = body.stream === true;
109
+ // The model-page "test" button drives dispatch via an in-process loopback
110
+ // (adminApi calls v1.request). The probe is a real call in every respect —
111
+ // including being logged — so we only tag it to report WHICH provider
112
+ // answered back to the test handler (x-myapikey-provider), without leaking
113
+ // that header to real agent clients.
114
+ const isProbe = c.req.header("x-myapikey-probe") === "1";
115
+ // candidates() already restricts the responses chain to supportsResponses sources.
116
+ const list = candidates(store, model, key);
117
+ if (!list.length) {
118
+ if (key === "responses") {
119
+ return c.json(
120
+ {
121
+ error: {
122
+ message: `model '${model}' is not enabled for /responses — enable it on a source marked "supports responses"`,
123
+ type: "invalid_request_error",
124
+ code: "model_not_found",
125
+ },
126
+ },
127
+ 404,
128
+ );
129
+ }
130
+ return notFound(c, model);
131
+ }
132
+ const clientVersion = c.req.header("anthropic-version") ?? undefined;
133
+ const start = Date.now();
134
+ let lastStatus = 502;
135
+ let lastErr = "";
136
+
137
+ // Skip providers currently in circuit-breaker cooldown. If every candidate
138
+ // is cooling, fall back to the full list anyway — cooldown is a heuristic,
139
+ // and one real attempt beats a guaranteed 502 (a cooled provider that now
140
+ // succeeds also resets its circuit).
141
+ const live = list.filter((p) => !store.isCooling(p.id));
142
+ const order = live.length ? live : list;
143
+
144
+ for (const provider of order) {
145
+ // Model mapping (per model×source): rewrite the passthrough body's model
146
+ // to this provider's configured upstream name. Recomputed from the ORIGINAL
147
+ // `model` each iteration, so failover to the next provider never carries the
148
+ // previous provider's upstream name. Absent map/key → send the public name.
149
+ const mapped = store.get().models[model]?.[key]?.modelMap?.[provider.id];
150
+ body.model = mapped ?? model;
151
+ let upstream: Response;
152
+ try {
153
+ upstream = await fetch(upstreamTarget(provider, key).url, {
154
+ method: "POST",
155
+ headers: upstreamHeaders(provider, wire, clientVersion),
156
+ body: JSON.stringify(body),
157
+ });
158
+ } catch {
159
+ // Network error / DNS / timeout → try next provider.
160
+ lastStatus = 502;
161
+ lastErr = "network error";
162
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
163
+ if (r.entered) {
164
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
165
+ }
166
+ continue;
167
+ }
168
+
169
+ if (upstream.ok) {
170
+ // A success closes the circuit (provider is healthy again).
171
+ store.recordCircuitSuccess(provider.id);
172
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream });
173
+ return passThrough(upstream, isProbe ? provider.name : undefined);
174
+ }
175
+ if (RETRYABLE.has(upstream.status)) {
176
+ lastStatus = upstream.status;
177
+ // Drain so the connection can be reused, then move on; capture the
178
+ // reason for the log (this branch never streams back to the client).
179
+ const txt = await upstream.text().catch(() => "");
180
+ lastErr = shortError(txt) || `HTTP ${upstream.status}`;
181
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
182
+ if (r.entered) {
183
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
184
+ }
185
+ continue;
186
+ }
187
+ // Non-retryable client error: return it to the caller as-is. Read the
188
+ // error text off a CLONE so the original body still streams back.
189
+ const errText = await upstream.clone().text().catch(() => "");
190
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream, error: shortError(errText) || `HTTP ${upstream.status}` });
191
+ return passThrough(upstream, isProbe ? provider.name : undefined);
192
+ }
193
+
194
+ const last = order[order.length - 1];
195
+ store.pushLog({ ts: Date.now(), model, provider: last.name, providerId: last.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, error: lastErr || `all providers failed (last status ${lastStatus})` });
196
+ return c.json(
197
+ { error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
198
+ 502,
199
+ );
200
+ };
201
+
202
+ app.post("/chat/completions", (c) => dispatch(c, "openai"));
203
+ app.post("/messages", (c) => dispatch(c, "anthropic"));
204
+ // OpenAI Responses API — its own routing slot (sources must be supportsResponses).
205
+ app.post("/responses", (c) => dispatch(c, "responses"));
206
+
207
+ // OpenAI-style model list of everything routable on the OpenAI path. This is
208
+ // the OpenAI list endpoint — advertise only models whose openai slot is
209
+ // enabled, so an agent that picks an id here can actually call it on
210
+ // /chat/completions. Anthropic-only models are intentionally omitted.
211
+ app.get("/models", (c) => {
212
+ const d = store.get();
213
+ const byId = new Map(d.providers.map((p) => [p.id, p]));
214
+ const data = Object.entries(d.models)
215
+ .filter(([, e]) => e.openai.enabled)
216
+ .map(([id, e]) => ({
217
+ id,
218
+ object: "model",
219
+ created: 0,
220
+ owned_by: byId.get(e.openai.providers[0] ?? "")?.name || "MyAPIKey",
221
+ }));
222
+ return c.json({ object: "list", data });
223
+ });
224
+
225
+ return app;
226
+ }