aigetwey 1.0.1
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/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/assets/logo.svg +8 -0
- package/assets/screenshot.png +0 -0
- package/assets/wordmark.svg +9 -0
- package/config.example.yaml +56 -0
- package/dashboard/.env.example +12 -0
- package/dashboard/next-env.d.ts +6 -0
- package/dashboard/next.config.ts +12 -0
- package/dashboard/package-lock.json +1771 -0
- package/dashboard/package.json +29 -0
- package/dashboard/postcss.config.mjs +5 -0
- package/dashboard/src/app/(console)/combos/page.tsx +10 -0
- package/dashboard/src/app/(console)/config/page.tsx +5 -0
- package/dashboard/src/app/(console)/console/page.tsx +92 -0
- package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
- package/dashboard/src/app/(console)/layout.tsx +17 -0
- package/dashboard/src/app/(console)/page.tsx +8 -0
- package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/providers/page.tsx +5 -0
- package/dashboard/src/app/(console)/quota/page.tsx +5 -0
- package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/tools/page.tsx +5 -0
- package/dashboard/src/app/(console)/usage/page.tsx +24 -0
- package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
- package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
- package/dashboard/src/app/api/login/route.ts +30 -0
- package/dashboard/src/app/api/logout/route.ts +9 -0
- package/dashboard/src/app/api/password/route.ts +34 -0
- package/dashboard/src/app/globals.css +340 -0
- package/dashboard/src/app/icon.svg +8 -0
- package/dashboard/src/app/layout.tsx +28 -0
- package/dashboard/src/app/login/page.tsx +60 -0
- package/dashboard/src/components/AreaChart.tsx +115 -0
- package/dashboard/src/components/Badge.tsx +32 -0
- package/dashboard/src/components/Button.tsx +60 -0
- package/dashboard/src/components/CapacityBadges.tsx +40 -0
- package/dashboard/src/components/Checkbox.tsx +40 -0
- package/dashboard/src/components/CliToolConfig.tsx +63 -0
- package/dashboard/src/components/ConfigEditor.tsx +199 -0
- package/dashboard/src/components/ConfirmModal.tsx +36 -0
- package/dashboard/src/components/CooldownTimer.tsx +42 -0
- package/dashboard/src/components/EndpointView.tsx +439 -0
- package/dashboard/src/components/Icon.tsx +25 -0
- package/dashboard/src/components/KeyReveal.tsx +78 -0
- package/dashboard/src/components/Lamp.tsx +8 -0
- package/dashboard/src/components/LogTable.tsx +223 -0
- package/dashboard/src/components/LogoutButton.tsx +20 -0
- package/dashboard/src/components/ModelPicker.tsx +121 -0
- package/dashboard/src/components/ModelSelectModal.tsx +126 -0
- package/dashboard/src/components/PasswordEditor.tsx +86 -0
- package/dashboard/src/components/PricingEditor.tsx +171 -0
- package/dashboard/src/components/ProviderDetail.tsx +566 -0
- package/dashboard/src/components/ProviderManager.tsx +311 -0
- package/dashboard/src/components/QuotaView.tsx +78 -0
- package/dashboard/src/components/Rail.tsx +82 -0
- package/dashboard/src/components/RichCard.tsx +46 -0
- package/dashboard/src/components/RoutingView.tsx +329 -0
- package/dashboard/src/components/ThemeProvider.tsx +36 -0
- package/dashboard/src/components/ToastProvider.tsx +58 -0
- package/dashboard/src/components/ToolDetail.tsx +475 -0
- package/dashboard/src/components/TopBar.tsx +128 -0
- package/dashboard/src/components/UsageView.tsx +151 -0
- package/dashboard/src/components/ui.tsx +54 -0
- package/dashboard/src/lib/capabilities.ts +318 -0
- package/dashboard/src/lib/cliTools.ts +120 -0
- package/dashboard/src/lib/client.ts +190 -0
- package/dashboard/src/lib/gateway.ts +269 -0
- package/dashboard/src/lib/session.ts +71 -0
- package/dashboard/src/middleware.ts +37 -0
- package/dashboard/tsconfig.json +21 -0
- package/dist/adapters/anthropic.js +289 -0
- package/dist/adapters/anthropic.js.map +1 -0
- package/dist/adapters/gemini.js +268 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openai.js +13 -0
- package/dist/adapters/openai.js.map +1 -0
- package/dist/cli/tray/autostart.js +152 -0
- package/dist/cli/tray/autostart.js.map +1 -0
- package/dist/cli/tray/icon.js +4 -0
- package/dist/cli/tray/icon.js.map +1 -0
- package/dist/cli/tray/tray.js +141 -0
- package/dist/cli/tray/tray.js.map +1 -0
- package/dist/cli/tray/trayRuntime.js +91 -0
- package/dist/cli/tray/trayRuntime.js.map +1 -0
- package/dist/cli.js +361 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +728 -0
- package/dist/config.js.map +1 -0
- package/dist/core/authStore.js +78 -0
- package/dist/core/authStore.js.map +1 -0
- package/dist/core/canonical.js +9 -0
- package/dist/core/canonical.js.map +1 -0
- package/dist/core/console-buffer.js +25 -0
- package/dist/core/console-buffer.js.map +1 -0
- package/dist/core/fallback.js +62 -0
- package/dist/core/fallback.js.map +1 -0
- package/dist/core/handler.js +174 -0
- package/dist/core/handler.js.map +1 -0
- package/dist/core/keypool.js +105 -0
- package/dist/core/keypool.js.map +1 -0
- package/dist/core/quota.js +165 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/state.js +52 -0
- package/dist/core/state.js.map +1 -0
- package/dist/db.js +193 -0
- package/dist/db.js.map +1 -0
- package/dist/headroom/compress.js +44 -0
- package/dist/headroom/compress.js.map +1 -0
- package/dist/headroom/detect.js +108 -0
- package/dist/headroom/detect.js.map +1 -0
- package/dist/headroom/process.js +158 -0
- package/dist/headroom/process.js.map +1 -0
- package/dist/inject/caveman.js +30 -0
- package/dist/inject/caveman.js.map +1 -0
- package/dist/inject/index.js +24 -0
- package/dist/inject/index.js.map +1 -0
- package/dist/inject/ponytail.js +19 -0
- package/dist/inject/ponytail.js.map +1 -0
- package/dist/middleware/auth.js +66 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/providers/capabilities.js +246 -0
- package/dist/providers/capabilities.js.map +1 -0
- package/dist/providers/free.js +43 -0
- package/dist/providers/free.js.map +1 -0
- package/dist/providers/pricing.js +224 -0
- package/dist/providers/pricing.js.map +1 -0
- package/dist/providers/vertex.js +97 -0
- package/dist/providers/vertex.js.map +1 -0
- package/dist/routes/admin.js +622 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/health.js +4 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/v1.js +75 -0
- package/dist/routes/v1.js.map +1 -0
- package/dist/rtk/detect.js +50 -0
- package/dist/rtk/detect.js.map +1 -0
- package/dist/rtk/filters.js +85 -0
- package/dist/rtk/filters.js.map +1 -0
- package/dist/rtk/index.js +39 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/server.js +100 -0
- package/dist/server.js.map +1 -0
- package/dist/stream/anthropic-stream.js +239 -0
- package/dist/stream/anthropic-stream.js.map +1 -0
- package/dist/stream/chunk.js +7 -0
- package/dist/stream/chunk.js.map +1 -0
- package/dist/stream/gemini-stream.js +135 -0
- package/dist/stream/gemini-stream.js.map +1 -0
- package/dist/stream/index.js +12 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/openai-stream.js +34 -0
- package/dist/stream/openai-stream.js.map +1 -0
- package/dist/stream/sse.js +64 -0
- package/dist/stream/sse.js.map +1 -0
- package/dist/translator/thinking.js +70 -0
- package/dist/translator/thinking.js.map +1 -0
- package/dist/translator/thinkingUnified.js +322 -0
- package/dist/translator/thinkingUnified.js.map +1 -0
- package/dist/upstream/client.js +120 -0
- package/dist/upstream/client.js.map +1 -0
- package/package.json +76 -0
- package/run.sh +27 -0
- package/src/adapters/anthropic.ts +377 -0
- package/src/adapters/gemini.ts +341 -0
- package/src/adapters/index.ts +17 -0
- package/src/adapters/openai.ts +22 -0
- package/src/cli/tray/autostart.ts +133 -0
- package/src/cli/tray/icon.ts +4 -0
- package/src/cli/tray/tray.ts +156 -0
- package/src/cli/tray/trayRuntime.ts +90 -0
- package/src/cli.ts +379 -0
- package/src/config.ts +777 -0
- package/src/core/authStore.ts +86 -0
- package/src/core/canonical.ts +93 -0
- package/src/core/console-buffer.ts +39 -0
- package/src/core/fallback.ts +116 -0
- package/src/core/handler.ts +236 -0
- package/src/core/keypool.ts +152 -0
- package/src/core/quota.ts +214 -0
- package/src/core/state.ts +65 -0
- package/src/db.ts +280 -0
- package/src/headroom/compress.ts +78 -0
- package/src/headroom/detect.ts +119 -0
- package/src/headroom/process.ts +166 -0
- package/src/inject/caveman.ts +35 -0
- package/src/inject/index.ts +46 -0
- package/src/inject/ponytail.ts +31 -0
- package/src/middleware/auth.ts +76 -0
- package/src/providers/capabilities.ts +297 -0
- package/src/providers/free.ts +53 -0
- package/src/providers/pricing.ts +261 -0
- package/src/providers/vertex.ts +117 -0
- package/src/routes/admin.ts +716 -0
- package/src/routes/health.ts +5 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/v1.ts +87 -0
- package/src/rtk/detect.ts +55 -0
- package/src/rtk/filters.ts +94 -0
- package/src/rtk/index.ts +58 -0
- package/src/server.ts +108 -0
- package/src/stream/anthropic-stream.ts +310 -0
- package/src/stream/chunk.ts +46 -0
- package/src/stream/gemini-stream.ts +158 -0
- package/src/stream/index.ts +23 -0
- package/src/stream/openai-stream.ts +41 -0
- package/src/stream/sse.ts +72 -0
- package/src/translator/thinking.ts +64 -0
- package/src/translator/thinkingUnified.ts +319 -0
- package/src/upstream/client.ts +155 -0
- package/tsconfig.json +20 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
renameSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// ---- schema (PLAN §8) -------------------------------------------------------
|
|
14
|
+
//
|
|
15
|
+
// Shape differs from a flat OpenAI gateway: routing lives in a top-level
|
|
16
|
+
// `models[]` layer (alias -> provider chain), the endpoint block carries the
|
|
17
|
+
// token-saver toggles, and providers may be free passthroughs or service-account
|
|
18
|
+
// backed. The handler/keypool/quota phases read these fields; defining the full
|
|
19
|
+
// shape up front avoids reshaping config across later phases.
|
|
20
|
+
|
|
21
|
+
/** Token quota window for a provider — drives the dashboard reset countdown. */
|
|
22
|
+
const QuotaSchema = z.object({
|
|
23
|
+
window: z.enum(["5h", "daily", "weekly", "monthly"]),
|
|
24
|
+
// daily: "HH:MM" local reset; weekly: weekday name ("monday"); others: ignored.
|
|
25
|
+
reset_at: z.string().optional(),
|
|
26
|
+
timezone: z.string().default("UTC"),
|
|
27
|
+
// optional ceiling for a progress bar; quota tracking works without it.
|
|
28
|
+
limit_tokens: z.number().int().positive().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ProviderModelSchema = z.object({
|
|
32
|
+
id: z.string().min(1),
|
|
33
|
+
price_in: z.number().nonnegative().optional(),
|
|
34
|
+
price_out: z.number().nonnegative().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ProviderSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
id: z.string().min(1),
|
|
40
|
+
name: z.string().optional(),
|
|
41
|
+
format: z.enum(["openai", "anthropic", "gemini"]),
|
|
42
|
+
base_url: z.string().url(),
|
|
43
|
+
api_key: z.string().min(1).optional(),
|
|
44
|
+
api_keys: z.array(z.string().min(1)).optional(),
|
|
45
|
+
// optional friendly label per key, keyed by the raw key string (like the
|
|
46
|
+
// server's key_names). api_keys stays a plain string[] so auth/masking paths
|
|
47
|
+
// are untouched.
|
|
48
|
+
key_names: z.record(z.string()).optional(),
|
|
49
|
+
// free passthrough (OpenCode Free): no upstream auth required.
|
|
50
|
+
free: z.boolean().default(false),
|
|
51
|
+
// fetch the provider's model catalog at runtime instead of from config.
|
|
52
|
+
auto_models: z.boolean().default(false),
|
|
53
|
+
// path to a GCP service-account JSON (Vertex AI): JWT-exchanged for tokens.
|
|
54
|
+
service_account: z.string().optional(),
|
|
55
|
+
models: z.array(ProviderModelSchema).default([]),
|
|
56
|
+
headers: z.record(z.string()).optional(),
|
|
57
|
+
quota: QuotaSchema.optional(),
|
|
58
|
+
// when true the provider is skipped in routing (kept in config, like a key's
|
|
59
|
+
// disabled state but for the whole provider).
|
|
60
|
+
disabled: z.boolean().optional(),
|
|
61
|
+
disabled_keys: z.array(z.number().int().nonnegative()).optional(),
|
|
62
|
+
strategy: z.enum(["fallback", "round-robin"]).optional(),
|
|
63
|
+
sticky: z.number().int().positive().optional(),
|
|
64
|
+
// base cooldown after a retryable key failure, doubled per consecutive fail.
|
|
65
|
+
cooldown_base_ms: z.number().int().positive().default(1000),
|
|
66
|
+
// keys to try within this provider before falling through to the next.
|
|
67
|
+
max_retries: z.number().int().nonnegative().default(2),
|
|
68
|
+
})
|
|
69
|
+
.refine((p) => p.free || p.service_account || p.api_key || (p.api_keys?.length ?? 0) > 0, {
|
|
70
|
+
message: "provider needs api_key/api_keys, or free: true, or service_account",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A combo — a client-facing `alias` resolved to an ordered chain
|
|
75
|
+
* of providers, tried by `strategy`. `model[i]` pairs with `target[i]`; a single
|
|
76
|
+
* string applies to all targets; omitted falls back to the alias name as the
|
|
77
|
+
* upstream model id. Call the alias directly as the model name from a CLI tool.
|
|
78
|
+
*/
|
|
79
|
+
const ModelRouteSchema = z.object({
|
|
80
|
+
alias: z.string().min(1),
|
|
81
|
+
target: z.array(z.string().min(1)).min(1),
|
|
82
|
+
model: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
|
|
83
|
+
// fallback: try targets in order. round-robin: rotate the first target tried
|
|
84
|
+
// per request to spread load across the chain.
|
|
85
|
+
strategy: z.enum(["fallback", "round-robin"]).default("fallback"),
|
|
86
|
+
price_in: z.number().nonnegative().optional(),
|
|
87
|
+
price_out: z.number().nonnegative().optional(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Headroom = external context-compression proxy. Off by default; url points at a
|
|
91
|
+
// locally-run `headroom proxy`. compress_user_messages also squeezes user turns.
|
|
92
|
+
const HeadroomSchema = z
|
|
93
|
+
.object({
|
|
94
|
+
enabled: z.boolean().default(false),
|
|
95
|
+
url: z.string().default("http://localhost:8787"),
|
|
96
|
+
compress_user_messages: z.boolean().default(false),
|
|
97
|
+
})
|
|
98
|
+
.default({ enabled: false, url: "http://localhost:8787", compress_user_messages: false });
|
|
99
|
+
|
|
100
|
+
const EndpointSchema = z
|
|
101
|
+
.object({
|
|
102
|
+
rtk: z.boolean().default(false),
|
|
103
|
+
caveman: z.enum(["off", "lite", "full", "ultra"]).default("off"),
|
|
104
|
+
ponytail: z.enum(["off", "lite", "full", "ultra"]).default("off"),
|
|
105
|
+
headroom: HeadroomSchema,
|
|
106
|
+
})
|
|
107
|
+
.default({});
|
|
108
|
+
|
|
109
|
+
const ServerSchema = z
|
|
110
|
+
.object({
|
|
111
|
+
host: z.string().default("127.0.0.1"),
|
|
112
|
+
port: z.number().int().positive().default(18080),
|
|
113
|
+
// gateway-level keys clients must present. Empty => auth disabled (localhost).
|
|
114
|
+
api_keys: z.array(z.string().min(1)).default([]),
|
|
115
|
+
// optional friendly label per key, keyed by the key itself. Kept separate so
|
|
116
|
+
// api_keys stays a plain string[] (auth/masking paths untouched).
|
|
117
|
+
key_names: z.record(z.string()).optional(),
|
|
118
|
+
})
|
|
119
|
+
.default({ host: "127.0.0.1", port: 18080, api_keys: [] });
|
|
120
|
+
|
|
121
|
+
const ConfigSchema = z.object({
|
|
122
|
+
server: ServerSchema,
|
|
123
|
+
endpoint: EndpointSchema,
|
|
124
|
+
providers: z.array(ProviderSchema).default([]),
|
|
125
|
+
// the routing layer. Each entry is a "combo": an alias + a provider chain.
|
|
126
|
+
models: z.array(ModelRouteSchema).default([]),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export type Quota = z.infer<typeof QuotaSchema>;
|
|
130
|
+
export type ProviderModel = z.infer<typeof ProviderModelSchema>;
|
|
131
|
+
export type Provider = z.infer<typeof ProviderSchema>;
|
|
132
|
+
export type ModelRoute = z.infer<typeof ModelRouteSchema>;
|
|
133
|
+
export type EndpointSettings = z.infer<typeof EndpointSchema>;
|
|
134
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
135
|
+
|
|
136
|
+
export interface ResolvedRoute {
|
|
137
|
+
/** client-facing alias that resolved to this route */
|
|
138
|
+
alias: string;
|
|
139
|
+
provider: Provider;
|
|
140
|
+
/** upstream model id to send */
|
|
141
|
+
model: string;
|
|
142
|
+
price_in?: number;
|
|
143
|
+
price_out?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export class GatewayConfig {
|
|
147
|
+
readonly server: Config["server"];
|
|
148
|
+
readonly endpoint: Config["endpoint"];
|
|
149
|
+
readonly raw: Config;
|
|
150
|
+
private readonly providers: Map<string, Provider>;
|
|
151
|
+
private readonly routes: Map<string, ModelRoute>;
|
|
152
|
+
/** per-alias rotation cursor for the round-robin strategy */
|
|
153
|
+
private readonly rrCursor: Map<string, number> = new Map();
|
|
154
|
+
|
|
155
|
+
constructor(raw: Config) {
|
|
156
|
+
this.raw = raw;
|
|
157
|
+
this.server = raw.server;
|
|
158
|
+
this.endpoint = raw.endpoint;
|
|
159
|
+
this.providers = new Map(raw.providers.map((p) => [p.id, p]));
|
|
160
|
+
this.routes = new Map(raw.models.map((m) => [m.alias, m]));
|
|
161
|
+
|
|
162
|
+
// fail fast: a routing alias must only target known providers, else the
|
|
163
|
+
// first request to that alias 404s at runtime with a confusing message.
|
|
164
|
+
for (const m of raw.models) {
|
|
165
|
+
for (const t of m.target) {
|
|
166
|
+
if (!this.providers.has(t)) {
|
|
167
|
+
throw new Error(`model alias "${m.alias}" targets unknown provider "${t}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Upstream model id for the i-th target of a route (see ModelRouteSchema). */
|
|
174
|
+
private modelFor(route: ModelRoute, index: number): string {
|
|
175
|
+
if (Array.isArray(route.model)) return route.model[index] ?? route.model[0] ?? route.alias;
|
|
176
|
+
if (typeof route.model === "string") return route.model;
|
|
177
|
+
return route.alias;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve a client model string to a prioritized chain of routes.
|
|
182
|
+
* - a combo alias => its target chain, ordered by the combo's strategy
|
|
183
|
+
* (fallback = config order; round-robin = rotate the first tried per call).
|
|
184
|
+
* - "provider/model" => single direct route to that provider.
|
|
185
|
+
* - a bare model id => auto-detect: every provider whose catalog lists that
|
|
186
|
+
* exact id, as a fallback chain (config order). Lets a CLI tool call a raw
|
|
187
|
+
* model name with no combo and no prefix.
|
|
188
|
+
* Returns [] when nothing matches (handler turns that into a 404).
|
|
189
|
+
*/
|
|
190
|
+
resolve(name: string): ResolvedRoute[] {
|
|
191
|
+
const route = this.routes.get(name);
|
|
192
|
+
if (route) {
|
|
193
|
+
const built = route.target.flatMap((providerId, i) => {
|
|
194
|
+
const provider = this.providers.get(providerId);
|
|
195
|
+
if (!provider || provider.disabled) return [];
|
|
196
|
+
return [
|
|
197
|
+
{
|
|
198
|
+
alias: name,
|
|
199
|
+
provider,
|
|
200
|
+
model: this.modelFor(route, i),
|
|
201
|
+
price_in: route.price_in,
|
|
202
|
+
price_out: route.price_out,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
});
|
|
206
|
+
if (route.strategy === "round-robin" && built.length > 1) {
|
|
207
|
+
const start = (this.rrCursor.get(name) ?? 0) % built.length;
|
|
208
|
+
this.rrCursor.set(name, start + 1);
|
|
209
|
+
return [...built.slice(start), ...built.slice(0, start)];
|
|
210
|
+
}
|
|
211
|
+
return built;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const slash = name.indexOf("/");
|
|
215
|
+
if (slash > 0) {
|
|
216
|
+
const providerId = name.slice(0, slash);
|
|
217
|
+
const model = name.slice(slash + 1);
|
|
218
|
+
const provider = this.providers.get(providerId);
|
|
219
|
+
if (provider && !provider.disabled && model) {
|
|
220
|
+
const entry = provider.models.find((m) => m.id === model);
|
|
221
|
+
return [
|
|
222
|
+
{
|
|
223
|
+
alias: name,
|
|
224
|
+
provider,
|
|
225
|
+
model,
|
|
226
|
+
price_in: entry?.price_in,
|
|
227
|
+
price_out: entry?.price_out,
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Auto-detect: no alias, no usable provider/ prefix. Route by catalog —
|
|
234
|
+
// any provider that lists this exact model id, as a fallback chain. The
|
|
235
|
+
// upstream model name stays the requested id (it's what the catalog holds).
|
|
236
|
+
const byCatalog = [...this.providers.values()].flatMap((provider) => {
|
|
237
|
+
if (provider.disabled) return [];
|
|
238
|
+
const entry = provider.models.find((m) => m.id === name);
|
|
239
|
+
if (!entry) return [];
|
|
240
|
+
return [{ alias: name, provider, model: name, price_in: entry.price_in, price_out: entry.price_out }];
|
|
241
|
+
});
|
|
242
|
+
if (byCatalog.length > 0) return byCatalog;
|
|
243
|
+
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
getProvider(id: string): Provider | undefined {
|
|
248
|
+
return this.providers.get(id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
listProviders(): Provider[] {
|
|
252
|
+
return [...this.providers.values()];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
listRoutes(): ModelRoute[] {
|
|
256
|
+
return [...this.routes.values()];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Validate an already-parsed config object. Throws with readable issues. */
|
|
261
|
+
export function validateConfig(parsed: unknown): GatewayConfig {
|
|
262
|
+
const result = ConfigSchema.safeParse(parsed ?? {});
|
|
263
|
+
if (!result.success) {
|
|
264
|
+
const issues = result.error.issues
|
|
265
|
+
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
266
|
+
.join("\n");
|
|
267
|
+
throw new Error(`invalid config:\n${issues}`);
|
|
268
|
+
}
|
|
269
|
+
return new GatewayConfig(result.data);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function loadConfig(path: string): GatewayConfig {
|
|
273
|
+
const text = readFileSync(path, "utf8");
|
|
274
|
+
try {
|
|
275
|
+
return validateConfig(parseYaml(text));
|
|
276
|
+
} catch (e) {
|
|
277
|
+
throw new Error(`config at ${path}: ${(e as Error).message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Parse YAML or JSON text into a validated config (yaml parser accepts JSON). */
|
|
282
|
+
export function parseConfigText(text: string): GatewayConfig {
|
|
283
|
+
return validateConfig(parseYaml(text));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function serializeConfig(config: Config): string {
|
|
287
|
+
return stringifyYaml(config);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Mask a secret for display: keep a short suffix, hide the rest.
|
|
292
|
+
* "" -> "(none)". "sk-abcdEFGHijklMNOP" -> "sk-…MNOP".
|
|
293
|
+
*/
|
|
294
|
+
export function maskKey(key: string): string {
|
|
295
|
+
if (!key) return "(none)";
|
|
296
|
+
if (key.length <= 8) return "…" + key.slice(-2);
|
|
297
|
+
return key.slice(0, 3) + "…" + key.slice(-4);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function looksMasked(v: string): boolean {
|
|
301
|
+
return v.includes("…");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve masked secrets in an edited config back to real values.
|
|
306
|
+
*
|
|
307
|
+
* The dashboard shows keys masked. On save, unchanged keys return still masked;
|
|
308
|
+
* writing those verbatim would corrupt config. Map each masked value back to the
|
|
309
|
+
* real key from the CURRENT config. A freshly-typed (unmasked) value is kept. An
|
|
310
|
+
* unresolvable or ambiguous mask throws — better to refuse than write a wrong
|
|
311
|
+
* secret. Mutates and returns `next`.
|
|
312
|
+
*/
|
|
313
|
+
export function unmaskSecrets(next: Config, current: Config): Config {
|
|
314
|
+
const byMask = new Map<string, string | null>(); // mask -> real, or null if ambiguous
|
|
315
|
+
const note = (real: string) => {
|
|
316
|
+
if (!real) return;
|
|
317
|
+
const m = maskKey(real);
|
|
318
|
+
if (byMask.has(m) && byMask.get(m) !== real) byMask.set(m, null);
|
|
319
|
+
else byMask.set(m, real);
|
|
320
|
+
};
|
|
321
|
+
for (const p of current.providers) {
|
|
322
|
+
if (p.api_key) note(p.api_key);
|
|
323
|
+
p.api_keys?.forEach(note);
|
|
324
|
+
}
|
|
325
|
+
current.server.api_keys.forEach(note);
|
|
326
|
+
|
|
327
|
+
const resolve = (v: string): string => {
|
|
328
|
+
if (!looksMasked(v)) return v;
|
|
329
|
+
const real = byMask.get(v);
|
|
330
|
+
if (real === undefined) throw new Error(`cannot resolve masked key "${v}" — type the real key`);
|
|
331
|
+
if (real === null) throw new Error(`masked key "${v}" is ambiguous — type the real key`);
|
|
332
|
+
return real;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
for (const p of next.providers) {
|
|
336
|
+
if (p.api_key) p.api_key = resolve(p.api_key);
|
|
337
|
+
if (p.api_keys) p.api_keys = p.api_keys.map(resolve);
|
|
338
|
+
}
|
|
339
|
+
next.server.api_keys = next.server.api_keys.map(resolve);
|
|
340
|
+
return next;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Write config to disk atomically with a one-level backup. Backs up the existing
|
|
345
|
+
* file to <path>.bak, writes a temp file, then renames over the target so a crash
|
|
346
|
+
* mid-write can't leave a corrupt config.
|
|
347
|
+
*/
|
|
348
|
+
export function writeConfigFile(path: string, config: Config): void {
|
|
349
|
+
const yaml = serializeConfig(config);
|
|
350
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
351
|
+
if (existsSync(path)) copyFileSync(path, path + ".bak");
|
|
352
|
+
const tmp = path + ".tmp";
|
|
353
|
+
writeFileSync(tmp, yaml, "utf8");
|
|
354
|
+
renameSync(tmp, path);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- granular config mutations (admin write surface) -----------------------
|
|
358
|
+
//
|
|
359
|
+
// Each returns a NEW Config (clone + mutate); the caller serializes it and feeds
|
|
360
|
+
// it through state.reload(), which re-validates (zod) and persists atomically.
|
|
361
|
+
// So url/length checks and schema defaults are enforced there — these helpers
|
|
362
|
+
// own only the structural change and the guards zod can't express.
|
|
363
|
+
|
|
364
|
+
function cloneConfig(config: Config): Config {
|
|
365
|
+
return JSON.parse(JSON.stringify(config)) as Config;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Real keys a provider routes through, in the order the keypool sees them. */
|
|
369
|
+
function realKeysOf(p: Provider): string[] {
|
|
370
|
+
if (p.api_keys && p.api_keys.length > 0) return p.api_keys;
|
|
371
|
+
if (p.api_key) return [p.api_key];
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function addProvider(
|
|
376
|
+
config: Config,
|
|
377
|
+
input: {
|
|
378
|
+
id: string;
|
|
379
|
+
format: Provider["format"];
|
|
380
|
+
base_url: string;
|
|
381
|
+
api_key?: string;
|
|
382
|
+
free?: boolean;
|
|
383
|
+
auto_models?: boolean;
|
|
384
|
+
service_account?: string;
|
|
385
|
+
},
|
|
386
|
+
): Config {
|
|
387
|
+
const next = cloneConfig(config);
|
|
388
|
+
if (next.providers.some((p) => p.id === input.id)) {
|
|
389
|
+
throw new Error(`provider "${input.id}" already exists`);
|
|
390
|
+
}
|
|
391
|
+
next.providers.push({
|
|
392
|
+
id: input.id,
|
|
393
|
+
format: input.format,
|
|
394
|
+
base_url: input.base_url,
|
|
395
|
+
free: input.free ?? false,
|
|
396
|
+
auto_models: input.auto_models ?? false,
|
|
397
|
+
models: [],
|
|
398
|
+
cooldown_base_ms: 1000,
|
|
399
|
+
max_retries: 2,
|
|
400
|
+
...(input.api_key ? { api_keys: [input.api_key] } : {}),
|
|
401
|
+
...(input.service_account ? { service_account: input.service_account } : {}),
|
|
402
|
+
});
|
|
403
|
+
return next;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Edit a provider's base_url and/or format (id is immutable). */
|
|
407
|
+
export function editProvider(
|
|
408
|
+
config: Config,
|
|
409
|
+
id: string,
|
|
410
|
+
patch: { base_url?: string; format?: Provider["format"]; name?: string },
|
|
411
|
+
): Config {
|
|
412
|
+
const next = cloneConfig(config);
|
|
413
|
+
const p = next.providers.find((x) => x.id === id);
|
|
414
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
415
|
+
if (patch.base_url !== undefined) {
|
|
416
|
+
if (!patch.base_url.trim()) throw new Error("base_url must not be empty");
|
|
417
|
+
p.base_url = patch.base_url.trim();
|
|
418
|
+
}
|
|
419
|
+
if (patch.format !== undefined) p.format = patch.format;
|
|
420
|
+
if (patch.name !== undefined) p.name = patch.name.trim() || undefined;
|
|
421
|
+
return next;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Rename a provider's id (the call prefix). Cascades to every combo that targets
|
|
426
|
+
* it so routing stays intact — the id is the routing key, not just a label.
|
|
427
|
+
*/
|
|
428
|
+
export function renameProvider(config: Config, oldId: string, newId: string): Config {
|
|
429
|
+
const next = cloneConfig(config);
|
|
430
|
+
const trimmed = newId.trim();
|
|
431
|
+
if (!trimmed) throw new Error("new provider id must not be empty");
|
|
432
|
+
if (/\s|\//.test(trimmed)) throw new Error("provider id can't contain spaces or '/'");
|
|
433
|
+
const p = next.providers.find((x) => x.id === oldId);
|
|
434
|
+
if (!p) throw new Error(`provider "${oldId}" not found`);
|
|
435
|
+
if (trimmed === oldId) return next;
|
|
436
|
+
if (next.providers.some((x) => x.id === trimmed)) throw new Error(`provider "${trimmed}" already exists`);
|
|
437
|
+
p.id = trimmed;
|
|
438
|
+
// repoint any combo chains that referenced the old id.
|
|
439
|
+
for (const m of next.models) {
|
|
440
|
+
m.target = m.target.map((t) => (t === oldId ? trimmed : t));
|
|
441
|
+
}
|
|
442
|
+
return next;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Remove a provider; refuses if any routing alias still targets it. */
|
|
446
|
+
export function removeProvider(config: Config, id: string): Config {
|
|
447
|
+
const next = cloneConfig(config);
|
|
448
|
+
const idx = next.providers.findIndex((p) => p.id === id);
|
|
449
|
+
if (idx === -1) throw new Error(`provider "${id}" not found`);
|
|
450
|
+
const usedBy = next.models.filter((m) => m.target.includes(id)).map((m) => m.alias);
|
|
451
|
+
if (usedBy.length > 0) {
|
|
452
|
+
throw new Error(`provider "${id}" is targeted by model alias(es): ${usedBy.join(", ")} — edit those first`);
|
|
453
|
+
}
|
|
454
|
+
next.providers.splice(idx, 1);
|
|
455
|
+
return next;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function addProviderKey(config: Config, id: string, key: string, name?: string): Config {
|
|
459
|
+
const next = cloneConfig(config);
|
|
460
|
+
const p = next.providers.find((x) => x.id === id);
|
|
461
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
462
|
+
if (!key.trim()) throw new Error("key must not be empty");
|
|
463
|
+
p.api_keys = [...realKeysOf(p), key];
|
|
464
|
+
delete p.api_key;
|
|
465
|
+
const label = name?.trim();
|
|
466
|
+
if (label) p.key_names = { ...(p.key_names ?? {}), [key]: label };
|
|
467
|
+
return next;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function removeProviderKey(config: Config, id: string, index: number): Config {
|
|
471
|
+
const next = cloneConfig(config);
|
|
472
|
+
const p = next.providers.find((x) => x.id === id);
|
|
473
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
474
|
+
const keys = realKeysOf(p);
|
|
475
|
+
if (index < 0 || index >= keys.length) throw new Error(`no key at index ${index} for provider "${id}"`);
|
|
476
|
+
// free/service-account providers may legitimately hold zero keys; a keyed
|
|
477
|
+
// provider keeps at least one (remove the provider instead to fully drop it).
|
|
478
|
+
if (keys.length <= 1 && !p.free && !p.service_account) {
|
|
479
|
+
throw new Error(`cannot remove the last key of "${id}" — delete the provider instead`);
|
|
480
|
+
}
|
|
481
|
+
const [removed] = keys.splice(index, 1);
|
|
482
|
+
p.api_keys = keys;
|
|
483
|
+
delete p.api_key;
|
|
484
|
+
if (removed && p.key_names && removed in p.key_names) delete p.key_names[removed];
|
|
485
|
+
return next;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Edit one provider key in place: swap its value and/or rename it. Keeps the
|
|
489
|
+
// key's position in the list (so cooldown/health ordering stays meaningful).
|
|
490
|
+
export function editProviderKey(
|
|
491
|
+
config: Config,
|
|
492
|
+
id: string,
|
|
493
|
+
index: number,
|
|
494
|
+
patch: { key?: string; name?: string },
|
|
495
|
+
): Config {
|
|
496
|
+
const next = cloneConfig(config);
|
|
497
|
+
const p = next.providers.find((x) => x.id === id);
|
|
498
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
499
|
+
const keys = realKeysOf(p);
|
|
500
|
+
if (index < 0 || index >= keys.length) throw new Error(`no key at index ${index} for provider "${id}"`);
|
|
501
|
+
const old = keys[index];
|
|
502
|
+
if (old === undefined) throw new Error(`no key at index ${index} for provider "${id}"`);
|
|
503
|
+
const newKey = patch.key?.trim() ? patch.key.trim() : old;
|
|
504
|
+
keys[index] = newKey;
|
|
505
|
+
p.api_keys = keys;
|
|
506
|
+
delete p.api_key;
|
|
507
|
+
const names = { ...(p.key_names ?? {}) };
|
|
508
|
+
const oldName = names[old];
|
|
509
|
+
if (old !== newKey && old in names) delete names[old];
|
|
510
|
+
// explicit name wins; otherwise carry the old label onto the new key value.
|
|
511
|
+
const label = patch.name !== undefined ? patch.name.trim() : oldName;
|
|
512
|
+
if (label) names[newKey] = label;
|
|
513
|
+
else delete names[newKey];
|
|
514
|
+
p.key_names = Object.keys(names).length > 0 ? names : undefined;
|
|
515
|
+
return next;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Swap a provider key from one index to another, preserving names. */
|
|
519
|
+
export function reorderProviderKey(config: Config, id: string, from: number, to: number): Config {
|
|
520
|
+
const next = cloneConfig(config);
|
|
521
|
+
const p = next.providers.find((x) => x.id === id);
|
|
522
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
523
|
+
const keys = realKeysOf(p);
|
|
524
|
+
if (from < 0 || from >= keys.length) throw new Error(`from index ${from} out of range`);
|
|
525
|
+
if (to < 0 || to >= keys.length) throw new Error(`to index ${to} out of range`);
|
|
526
|
+
if (from === to) return next;
|
|
527
|
+
const [moved] = keys.splice(from, 1);
|
|
528
|
+
keys.splice(to, 0, moved!);
|
|
529
|
+
p.api_keys = keys;
|
|
530
|
+
delete p.api_key;
|
|
531
|
+
return next;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Toggle a key's disabled state. disabled_keys stores indexes of disabled keys. */
|
|
535
|
+
export function toggleProviderKey(config: Config, id: string, index: number, enabled: boolean): Config {
|
|
536
|
+
const next = cloneConfig(config);
|
|
537
|
+
const p = next.providers.find((x) => x.id === id);
|
|
538
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
539
|
+
const keys = realKeysOf(p);
|
|
540
|
+
if (index < 0 || index >= keys.length) throw new Error(`key index ${index} out of range`);
|
|
541
|
+
const disabled = new Set(p.disabled_keys ?? []);
|
|
542
|
+
if (enabled) disabled.delete(index);
|
|
543
|
+
else disabled.add(index);
|
|
544
|
+
p.disabled_keys = disabled.size > 0 ? [...disabled].sort((a, b) => a - b) : undefined;
|
|
545
|
+
return next;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Enable/disable a whole provider — disabled providers are skipped in routing. */
|
|
549
|
+
export function setProviderDisabled(config: Config, id: string, disabled: boolean): Config {
|
|
550
|
+
const next = cloneConfig(config);
|
|
551
|
+
const p = next.providers.find((x) => x.id === id);
|
|
552
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
553
|
+
if (disabled) p.disabled = true;
|
|
554
|
+
else delete p.disabled;
|
|
555
|
+
return next;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Set per-provider strategy override (round-robin + sticky). */
|
|
559
|
+
export function setProviderStrategy(
|
|
560
|
+
config: Config,
|
|
561
|
+
id: string,
|
|
562
|
+
strategy: "fallback" | "round-robin" | null,
|
|
563
|
+
sticky?: number,
|
|
564
|
+
): Config {
|
|
565
|
+
const next = cloneConfig(config);
|
|
566
|
+
const p = next.providers.find((x) => x.id === id);
|
|
567
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
568
|
+
if (strategy === null || strategy === "fallback") {
|
|
569
|
+
delete p.strategy;
|
|
570
|
+
delete p.sticky;
|
|
571
|
+
} else {
|
|
572
|
+
p.strategy = strategy;
|
|
573
|
+
p.sticky = sticky && sticky > 0 ? sticky : 1;
|
|
574
|
+
}
|
|
575
|
+
return next;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function addProviderModel(
|
|
579
|
+
config: Config,
|
|
580
|
+
id: string,
|
|
581
|
+
model: string,
|
|
582
|
+
price?: { price_in?: number; price_out?: number },
|
|
583
|
+
): Config {
|
|
584
|
+
const trimmed = model.trim();
|
|
585
|
+
if (!trimmed) throw new Error("model id must not be empty");
|
|
586
|
+
const next = cloneConfig(config);
|
|
587
|
+
const p = next.providers.find((x) => x.id === id);
|
|
588
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
589
|
+
if (p.models.some((m) => m.id === trimmed)) {
|
|
590
|
+
throw new Error(`provider "${id}" already serves model "${trimmed}"`);
|
|
591
|
+
}
|
|
592
|
+
p.models.push({
|
|
593
|
+
id: trimmed,
|
|
594
|
+
...(price?.price_in !== undefined ? { price_in: price.price_in } : {}),
|
|
595
|
+
...(price?.price_out !== undefined ? { price_out: price.price_out } : {}),
|
|
596
|
+
});
|
|
597
|
+
return next;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function removeProviderModel(config: Config, id: string, model: string): Config {
|
|
601
|
+
const next = cloneConfig(config);
|
|
602
|
+
const p = next.providers.find((x) => x.id === id);
|
|
603
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
604
|
+
const idx = p.models.findIndex((m) => m.id === model);
|
|
605
|
+
if (idx === -1) throw new Error(`provider "${id}" does not serve model "${model}"`);
|
|
606
|
+
p.models.splice(idx, 1);
|
|
607
|
+
return next;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Override a model's price (per 1M tokens). null clears the override so cost falls
|
|
612
|
+
* back to the auto pricing table; undefined leaves that side untouched.
|
|
613
|
+
*/
|
|
614
|
+
export function setProviderModelPrice(
|
|
615
|
+
config: Config,
|
|
616
|
+
id: string,
|
|
617
|
+
model: string,
|
|
618
|
+
price: { price_in?: number | null; price_out?: number | null },
|
|
619
|
+
): Config {
|
|
620
|
+
const next = cloneConfig(config);
|
|
621
|
+
const p = next.providers.find((x) => x.id === id);
|
|
622
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
623
|
+
const m = p.models.find((x) => x.id === model);
|
|
624
|
+
if (!m) throw new Error(`provider "${id}" does not serve model "${model}"`);
|
|
625
|
+
if (price.price_in === null) delete m.price_in;
|
|
626
|
+
else if (price.price_in !== undefined) {
|
|
627
|
+
if (price.price_in < 0) throw new Error("price_in must be >= 0");
|
|
628
|
+
m.price_in = price.price_in;
|
|
629
|
+
}
|
|
630
|
+
if (price.price_out === null) delete m.price_out;
|
|
631
|
+
else if (price.price_out !== undefined) {
|
|
632
|
+
if (price.price_out < 0) throw new Error("price_out must be >= 0");
|
|
633
|
+
m.price_out = price.price_out;
|
|
634
|
+
}
|
|
635
|
+
return next;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Add several model ids at once, skipping any the provider already serves. */
|
|
639
|
+
export function addProviderModels(config: Config, id: string, ids: string[]): Config {
|
|
640
|
+
const next = cloneConfig(config);
|
|
641
|
+
const p = next.providers.find((x) => x.id === id);
|
|
642
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
643
|
+
const have = new Set(p.models.map((m) => m.id));
|
|
644
|
+
for (const raw of ids) {
|
|
645
|
+
const mid = raw.trim();
|
|
646
|
+
if (mid && !have.has(mid)) {
|
|
647
|
+
p.models.push({ id: mid });
|
|
648
|
+
have.add(mid);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return next;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Drop every model from a provider's catalog. */
|
|
655
|
+
export function clearProviderModels(config: Config, id: string): Config {
|
|
656
|
+
const next = cloneConfig(config);
|
|
657
|
+
const p = next.providers.find((x) => x.id === id);
|
|
658
|
+
if (!p) throw new Error(`provider "${id}" not found`);
|
|
659
|
+
p.models = [];
|
|
660
|
+
return next;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---- routing layer: client alias -> prioritized provider chain -------------
|
|
664
|
+
|
|
665
|
+
// ---- combos: a client alias -> ordered provider chain + strategy -----------
|
|
666
|
+
|
|
667
|
+
/** Create or replace a combo (alias + target chain + strategy). */
|
|
668
|
+
export function setRoute(
|
|
669
|
+
config: Config,
|
|
670
|
+
route: {
|
|
671
|
+
alias: string;
|
|
672
|
+
target: string[];
|
|
673
|
+
model?: string | string[];
|
|
674
|
+
strategy?: ModelRoute["strategy"];
|
|
675
|
+
price_in?: number;
|
|
676
|
+
price_out?: number;
|
|
677
|
+
},
|
|
678
|
+
): Config {
|
|
679
|
+
const alias = route.alias.trim();
|
|
680
|
+
if (!alias) throw new Error("alias must not be empty");
|
|
681
|
+
if (!route.target.length) throw new Error("a combo needs at least one target provider");
|
|
682
|
+
const next = cloneConfig(config);
|
|
683
|
+
for (const t of route.target) {
|
|
684
|
+
if (!next.providers.some((p) => p.id === t)) throw new Error(`unknown provider "${t}" in combo`);
|
|
685
|
+
}
|
|
686
|
+
const entry: ModelRoute = {
|
|
687
|
+
alias,
|
|
688
|
+
target: route.target,
|
|
689
|
+
strategy: route.strategy ?? "fallback",
|
|
690
|
+
...(route.model !== undefined ? { model: route.model } : {}),
|
|
691
|
+
...(route.price_in !== undefined ? { price_in: route.price_in } : {}),
|
|
692
|
+
...(route.price_out !== undefined ? { price_out: route.price_out } : {}),
|
|
693
|
+
};
|
|
694
|
+
const idx = next.models.findIndex((m) => m.alias === alias);
|
|
695
|
+
if (idx === -1) next.models.push(entry);
|
|
696
|
+
else next.models[idx] = entry;
|
|
697
|
+
return next;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function removeRoute(config: Config, alias: string): Config {
|
|
701
|
+
const next = cloneConfig(config);
|
|
702
|
+
const idx = next.models.findIndex((m) => m.alias === alias);
|
|
703
|
+
if (idx === -1) throw new Error(`combo "${alias}" not found`);
|
|
704
|
+
next.models.splice(idx, 1);
|
|
705
|
+
return next;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---- endpoint settings: token-saver toggles + gateway keys -----------------
|
|
709
|
+
|
|
710
|
+
export function setRtk(config: Config, enabled: boolean): Config {
|
|
711
|
+
const next = cloneConfig(config);
|
|
712
|
+
next.endpoint.rtk = enabled;
|
|
713
|
+
return next;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function setCaveman(config: Config, level: EndpointSettings["caveman"]): Config {
|
|
717
|
+
const next = cloneConfig(config);
|
|
718
|
+
next.endpoint.caveman = level;
|
|
719
|
+
return next;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function setPonytail(config: Config, level: EndpointSettings["ponytail"]): Config {
|
|
723
|
+
const next = cloneConfig(config);
|
|
724
|
+
next.endpoint.ponytail = level;
|
|
725
|
+
return next;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Update the headroom (external context-compression proxy) settings. */
|
|
729
|
+
export function setHeadroom(
|
|
730
|
+
config: Config,
|
|
731
|
+
patch: { enabled?: boolean; url?: string; compress_user_messages?: boolean },
|
|
732
|
+
): Config {
|
|
733
|
+
const next = cloneConfig(config);
|
|
734
|
+
if (patch.enabled !== undefined) next.endpoint.headroom.enabled = patch.enabled;
|
|
735
|
+
if (patch.url !== undefined) next.endpoint.headroom.url = patch.url.trim() || "http://localhost:8787";
|
|
736
|
+
if (patch.compress_user_messages !== undefined) {
|
|
737
|
+
next.endpoint.headroom.compress_user_messages = patch.compress_user_messages;
|
|
738
|
+
}
|
|
739
|
+
return next;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Append a gateway-level api key clients must present on /v1/*, with a label. */
|
|
743
|
+
export function addServerKey(config: Config, key: string, name?: string): Config {
|
|
744
|
+
const trimmed = key.trim();
|
|
745
|
+
if (!trimmed) throw new Error("key must not be empty");
|
|
746
|
+
const next = cloneConfig(config);
|
|
747
|
+
if (next.server.api_keys.includes(trimmed)) throw new Error("key already present");
|
|
748
|
+
next.server.api_keys = [...next.server.api_keys, trimmed];
|
|
749
|
+
const label = name?.trim();
|
|
750
|
+
if (label) next.server.key_names = { ...(next.server.key_names ?? {}), [trimmed]: label };
|
|
751
|
+
return next;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Rename a gateway key's label (by index, since keys are masked in the API). */
|
|
755
|
+
export function editServerKey(config: Config, index: number, patch: { name?: string }): Config {
|
|
756
|
+
const next = cloneConfig(config);
|
|
757
|
+
const keys = next.server.api_keys;
|
|
758
|
+
if (index < 0 || index >= keys.length) throw new Error(`no gateway key at index ${index}`);
|
|
759
|
+
const key = keys[index]!;
|
|
760
|
+
const names = { ...(next.server.key_names ?? {}) };
|
|
761
|
+
const label = patch.name?.trim();
|
|
762
|
+
if (label) names[key] = label;
|
|
763
|
+
else delete names[key];
|
|
764
|
+
next.server.key_names = Object.keys(names).length > 0 ? names : undefined;
|
|
765
|
+
return next;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Remove a gateway key by index (keys are masked in the API, so by-index). */
|
|
769
|
+
export function removeServerKey(config: Config, index: number): Config {
|
|
770
|
+
const next = cloneConfig(config);
|
|
771
|
+
if (index < 0 || index >= next.server.api_keys.length) throw new Error(`no gateway key at index ${index}`);
|
|
772
|
+
const [removed] = next.server.api_keys.splice(index, 1);
|
|
773
|
+
if (removed && next.server.key_names && removed in next.server.key_names) {
|
|
774
|
+
delete next.server.key_names[removed];
|
|
775
|
+
}
|
|
776
|
+
return next;
|
|
777
|
+
}
|