feat: implement proxy registry, management APIs, docs, and test hardening

This commit is contained in:
rexname
2026-03-17 13:05:27 +07:00
parent 9b255e643a
commit 8091b6b508
32 changed files with 3279 additions and 266 deletions
@@ -0,0 +1,46 @@
# ADR-0001: Proxy Registry + Usage Control Generalization
Date: 2026-03-17
Status: Accepted
## Context
OmniRoute sudah punya:
- Proxy assignment berbasis config-map (`global`, `providers`, `combos`, `keys`).
- Quota-aware selection khusus provider tertentu (notably `codex`).
Gap utama:
- Proxy belum menjadi aset reusable yang bisa di-manage sebagai entitas (metadata, where-used, safe delete).
- Usage policy belum konsisten lintas provider.
- Error contract API belum seragam untuk endpoint manajemen.
## Decision
1. Tambah **Proxy Registry** sebagai domain baru di DB (`proxy_registry`, `proxy_assignments`).
2. Pertahankan kompatibilitas assignment lama (fallback ke `proxyConfig` lama).
3. Resolver runtime pakai prioritas:
- account -> provider -> global (registry)
- fallback ke legacy resolver jika registry belum ada assignment
4. Wajib redaction kredensial di output list registry default.
5. Standarkan error JSON untuk endpoint manajemen proxy agar konsisten dan punya `requestId`.
## Consequences
Positif:
- Proxy reusable dan bisa dilacak pemakaiannya.
- Safe delete bisa ditegakkan (409 saat masih dipakai).
- Migrasi bertahap tanpa breaking change runtime.
Negatif:
- Ada dual-source sementara (registry + legacy config) sampai migrasi selesai.
- Butuh endpoint assignment tambahan dan pemetaan scope yang konsisten.
## Follow-up
- Migrasi UI provider/account dari input raw proxy ke selector registry.
- Tambah health telemetry per proxy dan alerting.
- Generalisasi usage control ke provider lain melalui interface policy yang sama.
@@ -0,0 +1,32 @@
# ADR-0002: Error Contract for Management Endpoints
Date: 2026-03-17
Status: Accepted
## Decision
Management endpoints (proxy config, proxy registry, and proxy assignments) return a uniform error body:
```json
{
"error": {
"message": "Human-readable summary",
"type": "invalid_request | not_found | conflict | server_error",
"details": {}
},
"requestId": "uuid"
}
```
## Status Mapping
- 400: invalid request / validation failure
- 404: resource not found
- 409: resource conflict (for example, proxy still assigned)
- 500: unexpected server error
## Notes
- `requestId` is mandatory for log correlation.
- `details` is optional and only used for safe validation details.
- Sensitive secrets (proxy credentials, tokens) must never appear in `message` or `details`.
@@ -0,0 +1,16 @@
# ADR-0003: Security Checklist for Proxy Registry and Usage Controls
Date: 2026-03-17
Status: Accepted
## Checklist
- Validate all management payloads with Zod.
- Reject malformed scope assignment updates with status 400.
- Reject deleting an in-use proxy with status 409 unless forced.
- Never expose proxy username/password in list responses by default.
- Never log raw credentials or token values.
- Keep error responses free from internal stack traces.
- Protect management endpoints with existing auth middleware policy.
- Audit mutating operations: create/update/delete/assign/migrate.
- Ensure resolver fallback to legacy config while migration is in transition.
@@ -0,0 +1,605 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Modal } from "@/shared/components";
type ProxyItem = {
id: string;
name: string;
type: string;
host: string;
port: number;
region?: string | null;
notes?: string | null;
status?: string;
};
type UsageInfo = {
count: number;
assignments: Array<{ scope: string; scopeId: string | null }>;
};
type HealthInfo = {
proxyId: string;
totalRequests: number;
successRate: number | null;
avgLatencyMs: number | null;
lastSeenAt: string | null;
};
const EMPTY_FORM = {
id: "",
name: "",
type: "http",
host: "",
port: "8080",
username: "",
password: "",
region: "",
notes: "",
status: "active",
};
export default function ProxyRegistryManager() {
const [items, setItems] = useState<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [usageById, setUsageById] = useState<Record<string, UsageInfo>>({});
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
const [migrating, setMigrating] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkScope, setBulkScope] = useState("provider");
const [bulkScopeIds, setBulkScopeIds] = useState("");
const [bulkProxyId, setBulkProxyId] = useState("");
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
try {
const res = await fetch("/api/settings/proxies/health?hours=24");
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const entries = Array.isArray(data?.items) ? data.items : [];
const mapped = Object.fromEntries(
entries.map((entry: HealthInfo) => [entry.proxyId, entry])
) as Record<string, HealthInfo>;
setHealthById(mapped);
} catch {
// ignore health loading errors in UI
}
}, []);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/settings/proxies");
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to load proxy registry");
setItems([]);
return;
}
setItems(Array.isArray(data?.items) ? data.items : []);
void loadHealth();
} catch (e: any) {
setError(e?.message || "Failed to load proxy registry");
setItems([]);
} finally {
setLoading(false);
}
}, [loadHealth]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (items.length > 0 && !bulkProxyId) {
setBulkProxyId(items[0].id);
}
}, [items, bulkProxyId]);
const openCreate = () => {
setForm(EMPTY_FORM);
setModalOpen(true);
};
const openEdit = (item: ProxyItem) => {
setForm({
id: item.id,
name: item.name || "",
type: item.type || "http",
host: item.host || "",
port: String(item.port || 8080),
username: "",
password: "",
region: item.region || "",
notes: item.notes || "",
status: item.status || "active",
});
setModalOpen(true);
};
const loadUsage = async (proxyId: string) => {
try {
const res = await fetch(
`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
setUsageById((prev) => ({
...prev,
[proxyId]: {
count: Number(data?.count || 0),
assignments: Array.isArray(data?.assignments) ? data.assignments : [],
},
}));
} catch {
// ignore usage loading errors in UI
}
};
const handleSave = async () => {
if (!form.name.trim() || !form.host.trim()) {
setError("Name and host are required");
return;
}
setSaving(true);
setError(null);
const payload = {
...(editingId ? { id: editingId } : {}),
name: form.name.trim(),
type: form.type,
host: form.host.trim(),
port: Number(form.port || 8080),
username: form.username.trim(),
password: form.password.trim(),
region: form.region.trim() || null,
notes: form.notes.trim() || null,
status: form.status,
};
try {
const res = await fetch("/api/settings/proxies", {
method: editingId ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to save proxy");
return;
}
setModalOpen(false);
setForm(EMPTY_FORM);
await load();
} catch (e: any) {
setError(e?.message || "Failed to save proxy");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (res.ok) {
await load();
return;
}
const payload = await res.json().catch(() => ({}));
const inUse = res.status === 409;
if (inUse) {
const ok = window.confirm(
"This proxy is still assigned. Force delete and remove all assignments?"
);
if (!ok) return;
const forceRes = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}&force=1`, {
method: "DELETE",
});
if (!forceRes.ok) {
const forcePayload = await forceRes.json().catch(() => ({}));
setError(forcePayload?.error?.message || "Failed to force delete proxy");
return;
}
await load();
return;
}
setError(payload?.error?.message || "Failed to delete proxy");
} catch (e: any) {
setError(e?.message || "Failed to delete proxy");
}
};
const handleMigrate = async () => {
setMigrating(true);
setError(null);
try {
const res = await fetch("/api/settings/proxies/migrate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: false }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to migrate legacy proxy config");
return;
}
await load();
} catch (e: any) {
setError(e?.message || "Failed to migrate legacy proxy config");
} finally {
setMigrating(false);
}
};
const handleBulkAssign = async () => {
setBulkSaving(true);
setError(null);
try {
const scopeIds =
bulkScope === "global"
? []
: bulkScopeIds
.split(/[\n,]/g)
.map((part) => part.trim())
.filter(Boolean);
const res = await fetch("/api/settings/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: bulkScope,
scopeIds,
proxyId: bulkProxyId || null,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error?.message || "Failed to run bulk assignment");
return;
}
setBulkOpen(false);
setBulkScopeIds("");
await load();
} catch (e: any) {
setError(e?.message || "Failed to run bulk assignment");
} finally {
setBulkSaving(false);
}
};
return (
<>
<Card className="p-6">
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-lg font-semibold">Proxy Registry</h3>
<p className="text-sm text-text-muted">Store reusable proxies and track assignments.</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
icon="upgrade"
onClick={handleMigrate}
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
Import Legacy
</Button>
<Button
size="sm"
variant="secondary"
icon="account_tree"
onClick={() => setBulkOpen(true)}
data-testid="proxy-registry-open-bulk"
>
Bulk Assign
</Button>
<Button
size="sm"
icon="add"
onClick={openCreate}
data-testid="proxy-registry-open-create"
>
Add Proxy
</Button>
</div>
</div>
{error && (
<div className="mb-3 px-3 py-2 rounded border border-red-500/30 bg-red-500/10 text-sm text-red-400">
{error}
</div>
)}
{loading ? (
<div className="text-sm text-text-muted">Loading proxies...</div>
) : items.length === 0 ? (
<div className="text-sm text-text-muted">No saved proxies yet.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-text-muted border-b border-border">
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">Endpoint</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Health (24h)</th>
<th className="py-2 pr-3">Usage</th>
<th className="py-2">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const usage = usageById[item.id];
const health = healthById[item.id];
return (
<tr key={item.id} className="border-b border-border/60">
<td className="py-2 pr-3">
<div className="font-medium text-text-main">{item.name}</div>
{item.region && (
<div className="text-xs text-text-muted">{item.region}</div>
)}
</td>
<td className="py-2 pr-3 font-mono text-xs text-text-muted">
{item.type}://{item.host}:{item.port}
</td>
<td className="py-2 pr-3">
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
{item.status || "active"}
</span>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{health ? (
<div className="flex flex-col gap-0.5">
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</div>
) : (
"-"
)}
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{usage ? `${usage.count} assignment(s)` : "-"}
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
icon="visibility"
onClick={() => void loadUsage(item.id)}
>
Usage
</Button>
<Button
size="sm"
variant="ghost"
icon="edit"
onClick={() => openEdit(item)}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
icon="delete"
onClick={() => void handleDelete(item.id)}
className="!text-red-400"
>
Delete
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
<Modal
isOpen={modalOpen}
onClose={() => {
if (!saving) setModalOpen(false);
}}
title={editingId ? "Edit Proxy" : "Create Proxy"}
maxWidth="lg"
>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted mb-1 block">Name</label>
<input
data-testid="proxy-registry-name-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Type</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Host</label>
<input
data-testid="proxy-registry-host-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.host}
onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Port</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.port}
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Username</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Password</label>
<input
type="password"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Region</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.region}
onChange={(e) => setForm((prev) => ({ ...prev, region: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Status</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Notes</label>
<textarea
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
rows={3}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button size="sm" icon="save" onClick={handleSave} loading={saving}>
Save
</Button>
</div>
</div>
</Modal>
<Modal
isOpen={bulkOpen}
onClose={() => {
if (!bulkSaving) setBulkOpen(false);
}}
title="Bulk Proxy Assignment"
maxWidth="lg"
>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted mb-1 block">Scope</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={bulkScope}
onChange={(e) => setBulkScope(e.target.value)}
>
<option value="global">global</option>
<option value="provider">provider</option>
<option value="account">account</option>
<option value="combo">combo</option>
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Proxy</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={bulkProxyId}
onChange={(e) => setBulkProxyId(e.target.value)}
>
<option value="">(clear assignment)</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
</option>
))}
</select>
</div>
</div>
{bulkScope !== "global" && (
<div>
<label className="text-xs text-text-muted mb-1 block">
Scope IDs (comma or newline)
</label>
<textarea
data-testid="proxy-registry-bulk-scopeids-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
rows={5}
value={bulkScopeIds}
onChange={(e) => setBulkScopeIds(e.target.value)}
placeholder="provider-openai,provider-anthropic"
/>
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setBulkOpen(false)}>
Cancel
</Button>
<Button
size="sm"
icon="done_all"
onClick={handleBulkAssign}
loading={bulkSaving}
data-testid="proxy-registry-bulk-apply"
>
Apply
</Button>
</div>
</div>
</Modal>
</>
);
}
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ProxyConfigModal } from "@/shared/components";
import { useTranslations } from "next-intl";
import ProxyRegistryManager from "./ProxyRegistryManager";
export default function ProxyTab() {
const [proxyModalOpen, setProxyModalOpen] = useState(false);
@@ -41,39 +42,43 @@ export default function ProxyTab() {
return (
<>
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
vpn_lock
</span>
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
<div className="flex flex-col gap-6">
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
vpn_lock
</span>
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
</div>
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
<div className="flex items-center gap-3">
{globalProxy ? (
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded text-xs font-bold uppercase bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
{globalProxy.type}://{globalProxy.host}:{globalProxy.port}
</span>
</div>
) : (
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
)}
<Button
size="sm"
variant={globalProxy ? "secondary" : "primary"}
icon="settings"
onClick={() => {
loadGlobalProxy();
setProxyModalOpen(true);
}}
>
{globalProxy ? tc("edit") : t("configure")}
</Button>
</div>
</div>
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
<div className="flex items-center gap-3">
{globalProxy ? (
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded text-xs font-bold uppercase bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
{globalProxy.type}://{globalProxy.host}:{globalProxy.port}
</span>
</div>
) : (
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
)}
<Button
size="sm"
variant={globalProxy ? "secondary" : "primary"}
icon="settings"
onClick={() => {
loadGlobalProxy();
setProxyModalOpen(true);
}}
>
{globalProxy ? tc("edit") : t("configure")}
</Button>
</div>
</div>
</Card>
</Card>
<ProxyRegistryManager />
</div>
<ProxyConfigModal
isOpen={proxyModalOpen}
@@ -0,0 +1,63 @@
import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } from "@/lib/localDb";
import { proxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const proxyId = searchParams.get("proxyId");
const scope = searchParams.get("scope");
const scopeId = searchParams.get("scopeId");
const resolveConnectionId = searchParams.get("resolveConnectionId");
if (resolveConnectionId) {
const resolved = await resolveProxyForConnection(resolveConnectionId);
return Response.json(resolved);
}
const assignments = await getProxyAssignments({
proxyId: proxyId || undefined,
scope: scope || undefined,
});
const filtered = scopeId
? assignments.filter((entry) => entry.scopeId === scopeId)
: assignments;
return Response.json({ items: filtered, total: filtered.length });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy assignments");
}
}
export async function PUT(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(proxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeId, proxyId } = validation.data;
const assigned = await assignProxyToScope(scope, scopeId || null, proxyId || null);
clearDispatcherCache();
return Response.json({ success: true, assignment: assigned });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update assignment");
}
}
@@ -0,0 +1,45 @@
import { bulkAssignProxyToScope } from "@/lib/localDb";
import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function PUT(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(bulkProxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeIds, proxyId } = validation.data;
const normalizedScope = scope === "key" ? "account" : scope;
const result = await bulkAssignProxyToScope(normalizedScope, scopeIds || [], proxyId || null);
clearDispatcherCache();
return Response.json({
success: true,
scope: normalizedScope,
requested: normalizedScope === "global" ? 1 : (scopeIds || []).length,
updated: result.updated,
failed: result.failed,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to run bulk assignment");
}
}
@@ -0,0 +1,13 @@
import { getProxyHealthStats } from "@/lib/localDb";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") || 24);
const items = await getProxyHealthStats({ hours });
return Response.json({ items, total: items.length, windowHours: hours });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy health stats");
}
}
@@ -0,0 +1,24 @@
import { migrateLegacyProxyConfigToRegistry } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
export async function POST(request: Request) {
let force = false;
try {
const body = await request.json().catch(() => ({}));
force = body?.force === true;
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const result = await migrateLegacyProxyConfigToRegistry({ force });
return Response.json(result);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to migrate legacy proxy config");
}
}
+127
View File
@@ -0,0 +1,127 @@
import {
createProxy,
deleteProxyById,
getProxyById,
getProxyWhereUsed,
listProxies,
updateProxy,
} from "@/lib/localDb";
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const whereUsed = searchParams.get("whereUsed") === "1";
if (id && whereUsed) {
const usage = await getProxyWhereUsed(id);
return Response.json(usage);
}
if (id) {
const proxy = await getProxyById(id, { includeSecrets: false });
if (!proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(proxy);
}
const proxies = await listProxies({ includeSecrets: false });
return Response.json({ items: proxies, total: proxies.length });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");
}
}
export async function POST(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(createProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const created = await createProxy(validation.data);
return Response.json(created, { status: 201 });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to create proxy");
}
}
export async function PATCH(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(updateProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { id, ...changes } = validation.data;
const updated = await updateProxy(id, changes);
if (!updated) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(updated);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy");
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const force = searchParams.get("force") === "1";
if (!id) {
return createErrorResponse({
status: 400,
message: "id is required",
type: "invalid_request",
});
}
const deleted = await deleteProxyById(id, { force });
if (!deleted) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json({ success: true });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+20 -29
View File
@@ -8,6 +8,7 @@ import {
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { updateProxyConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import type { z } from "zod";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
@@ -135,11 +136,7 @@ export async function GET(request: Request) {
const config = await getProxyConfig();
return Response.json(config);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
return createErrorResponseFromUnknown(error, "Failed to load proxy config");
}
}
@@ -152,25 +149,22 @@ export async function PUT(request: Request) {
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(updateProxyConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json(
{
error: {
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const body = validation.data;
const normalizedBody = normalizeProxyPayload(body);
@@ -181,7 +175,7 @@ export async function PUT(request: Request) {
const routeError = toApiRouteError(error);
const status = Number(routeError.status) || 500;
const type = routeError.type || (status === 400 ? "invalid_request" : "server_error");
return Response.json({ error: { message: routeError.message, type } }, { status });
return createErrorResponse({ status, message: routeError.message, type });
}
}
@@ -196,20 +190,17 @@ export async function DELETE(request: Request) {
const id = searchParams.get("id");
if (!level) {
return Response.json(
{ error: { message: "level is required", type: "invalid_request" } },
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "level is required",
type: "invalid_request",
});
}
const updated = await deleteProxyForLevel(level, id);
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+38 -61
View File
@@ -7,6 +7,7 @@ import {
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
import { testProxySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
@@ -38,61 +39,46 @@ export async function POST(request: Request) {
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(testProxySchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json(
{
error: {
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { proxy } = validation.data;
const proxyType = String(proxy.type || "http").toLowerCase();
if (proxyType === "socks5" && !isSocks5ProxyEnabled()) {
return Response.json(
{
error: {
message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)",
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)",
type: "invalid_request",
});
}
if (proxyType.startsWith("socks") && proxyType !== "socks5") {
return Response.json(
{
error: {
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
});
}
if (!getSupportedProxyTypes().has(proxyType)) {
return Response.json(
{
error: {
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
});
}
let proxyUrl: string;
@@ -108,27 +94,19 @@ export async function POST(request: Request) {
{ allowSocks5: isSocks5ProxyEnabled() }
);
if (!normalizedProxyUrl) {
return Response.json(
{
error: {
message: "Invalid proxy configuration",
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "Invalid proxy configuration",
type: "invalid_request",
});
}
proxyUrl = normalizedProxyUrl;
} catch (proxyError) {
return Response.json(
{
error: {
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
type: "invalid_request",
});
}
const publicProxyUrl = proxyUrlForLogs(proxyUrl);
@@ -180,7 +158,6 @@ export async function POST(request: Request) {
clearTimeout(timeout);
}
} catch (error) {
const message = getErrorMessage(error, "Unexpected server error");
return Response.json({ error: { message, type: "server_error" } }, { status: 500 });
return createErrorResponseFromUnknown(error, "Unexpected server error");
}
}
@@ -0,0 +1,75 @@
import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } from "@/lib/localDb";
import { proxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
function toPagination(searchParams: URLSearchParams) {
const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 100)));
const offset = Math.max(0, Number(searchParams.get("offset") || 0));
return { limit, offset };
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const proxyId = searchParams.get("proxy_id");
const scope = searchParams.get("scope");
const scopeId = searchParams.get("scope_id");
const resolveConnectionId = searchParams.get("resolve_connection_id");
if (resolveConnectionId) {
const resolved = await resolveProxyForConnection(resolveConnectionId);
return Response.json(resolved);
}
const all = await getProxyAssignments({
proxyId: proxyId || undefined,
scope: scope || undefined,
});
const filtered = scopeId ? all.filter((entry) => entry.scopeId === scopeId) : all;
const { limit, offset } = toPagination(searchParams);
const items = filtered.slice(offset, offset + limit);
return Response.json({
items,
page: { limit, offset, total: filtered.length },
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy assignments");
}
}
export async function PUT(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(proxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeId, proxyId } = validation.data;
const assignment = await assignProxyToScope(scope, scopeId || null, proxyId || null);
clearDispatcherCache();
return Response.json({ success: true, assignment });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy assignment");
}
}
@@ -0,0 +1,45 @@
import { bulkAssignProxyToScope } from "@/lib/localDb";
import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function PUT(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(bulkProxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeIds, proxyId } = validation.data;
const normalizedScope = scope === "key" ? "account" : scope;
const result = await bulkAssignProxyToScope(normalizedScope, scopeIds || [], proxyId || null);
clearDispatcherCache();
return Response.json({
success: true,
scope: normalizedScope,
requested: normalizedScope === "global" ? 1 : (scopeIds || []).length,
updated: result.updated,
failed: result.failed,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to run bulk assignment");
}
}
@@ -0,0 +1,13 @@
import { getProxyHealthStats } from "@/lib/localDb";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") || 24);
const items = await getProxyHealthStats({ hours });
return Response.json({ items, total: items.length, windowHours: hours });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy health stats");
}
}
+138
View File
@@ -0,0 +1,138 @@
import {
createProxy,
deleteProxyById,
getProxyById,
getProxyWhereUsed,
listProxies,
updateProxy,
} from "@/lib/localDb";
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
function toPagination(searchParams: URLSearchParams) {
const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 50)));
const offset = Math.max(0, Number(searchParams.get("offset") || 0));
return { limit, offset };
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const whereUsed = searchParams.get("where_used") === "1";
if (id && whereUsed) {
const usage = await getProxyWhereUsed(id);
return Response.json(usage);
}
if (id) {
const proxy = await getProxyById(id, { includeSecrets: false });
if (!proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(proxy);
}
const { limit, offset } = toPagination(searchParams);
const items = await listProxies({ includeSecrets: false });
const paged = items.slice(offset, offset + limit);
return Response.json({
items: paged,
page: { limit, offset, total: items.length },
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");
}
}
export async function POST(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(createProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const created = await createProxy(validation.data);
return Response.json(created, { status: 201 });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to create proxy");
}
}
export async function PATCH(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(updateProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { id, ...changes } = validation.data;
const updated = await updateProxy(id, changes);
if (!updated) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(updated);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy");
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const force = searchParams.get("force") === "1";
if (!id) {
return createErrorResponse({
status: 400,
message: "id is required",
type: "invalid_request",
});
}
const deleted = await deleteProxyById(id, { force });
if (!deleted) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json({ success: true });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+64
View File
@@ -14,6 +14,36 @@ const ENDPOINT_ROWS = [
{ path: "/models", method: "GET", noteKey: "endpointRewriteModelsNote" },
] as const;
const MANAGEMENT_ENDPOINT_ROWS = [
{ path: "/api/v1/management/proxies", method: "GET", noteKey: "mgmtProxiesListNote" },
{ path: "/api/v1/management/proxies", method: "POST", noteKey: "mgmtProxiesCreateNote" },
{
path: "/api/v1/management/proxies/health",
method: "GET",
noteKey: "mgmtProxiesHealthNote",
},
{
path: "/api/v1/management/proxies/bulk-assign",
method: "PUT",
noteKey: "mgmtProxiesBulkAssignNote",
},
{
path: "/api/v1/management/proxies/assignments",
method: "GET",
noteKey: "mgmtAssignmentsListNote",
},
{
path: "/api/v1/management/proxies/assignments",
method: "PUT",
noteKey: "mgmtAssignmentsUpdateNote",
},
{
path: "/api/settings/proxies/migrate",
method: "POST",
noteKey: "mgmtLegacyMigrationNote",
},
] as const;
const FEATURE_ITEMS = [
{ icon: "hub", titleKey: "featureRoutingTitle", textKey: "featureRoutingText" },
{ icon: "layers", titleKey: "featureCombosTitle", textKey: "featureCombosText" },
@@ -48,6 +78,7 @@ const TOC_ITEMS = [
{ href: "#client-compatibility", labelKey: "clientCompatibility" },
{ href: "#protocols", labelKey: "protocolsToc" },
{ href: "#api-reference", labelKey: "apiReference" },
{ href: "#management-api", labelKey: "managementApiReference" },
{ href: "#model-prefixes", labelKey: "modelPrefixes" },
{ href: "#troubleshooting", labelKey: "troubleshooting" },
] as const;
@@ -102,6 +133,10 @@ export default function DocsPage() {
...row,
note: t(row.noteKey),
}));
const managementEndpointRows = MANAGEMENT_ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const featureItems = FEATURE_ITEMS.map((item) => ({
...item,
@@ -490,6 +525,35 @@ POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
</div>
</section>
<section id="management-api" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("managementApiReference")}</h2>
<p className="text-sm text-text-muted mt-2">{t("managementApiDescription")}</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
{managementEndpointRows.map((row) => (
<tr key={`${row.method}:${row.path}`} className="border-b border-border/60">
<td className="py-2 pr-4">
<code className="px-1.5 py-0.5 rounded bg-bg text-xs font-semibold">
{row.method}
</code>
</td>
<td className="py-2 pr-4 font-mono">{row.path}</td>
<td className="py-2 text-text-muted">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="troubleshooting" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("troubleshooting")}</h2>
<ul className="mt-4 list-disc list-inside text-sm text-text-muted space-y-2">
+9
View File
@@ -2345,6 +2345,8 @@
"clientCompatibility": "Client Compatibility",
"protocolsToc": "Protocols",
"apiReference": "API Reference",
"managementApiReference": "Management API Reference",
"managementApiDescription": "Automation endpoints for proxy registry, scope assignments, and legacy proxy migration.",
"method": "Method",
"path": "Path",
"notes": "Notes",
@@ -2440,6 +2442,13 @@
"endpointRewriteChatNote": "Rewrite helper for clients without /v1.",
"endpointRewriteResponsesNote": "Rewrite helper for Responses without /v1.",
"endpointRewriteModelsNote": "Rewrite helper for model discovery without /v1.",
"mgmtProxiesListNote": "List saved proxy registry items (supports pagination).",
"mgmtProxiesCreateNote": "Create a reusable proxy item in the registry.",
"mgmtProxiesHealthNote": "Get 24h/rolling health metrics per saved proxy from proxy logs.",
"mgmtProxiesBulkAssignNote": "Assign or clear one proxy across many scope IDs in one request.",
"mgmtAssignmentsListNote": "List proxy assignments by scope, scope_id, or proxy_id.",
"mgmtAssignmentsUpdateNote": "Assign or clear proxy for global/provider/account/combo scope.",
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"modelPrefixesDescriptionStart": "Use the provider prefix before the model name to route to a specific provider. Example:",
"modelPrefixesDescriptionEnd": "routes to GitHub Copilot.",
"provider": "Provider",
+9
View File
@@ -2310,6 +2310,8 @@
"clientCompatibility": "Kompatibilitas Klien",
"protocolsToc": "Protocols",
"apiReference": "Referensi API",
"managementApiReference": "Referensi API Manajemen",
"managementApiDescription": "Endpoint automasi untuk registry proxy, assignment scope, dan migrasi proxy lama.",
"method": "Metode",
"path": "Jalan",
"notes": "Catatan",
@@ -2405,6 +2407,13 @@
"endpointRewriteChatNote": "Tulis ulang pembantu untuk klien tanpa /v1.",
"endpointRewriteResponsesNote": "Tulis ulang pembantu untuk Respons tanpa /v1.",
"endpointRewriteModelsNote": "Tulis ulang pembantu untuk penemuan model tanpa /v1.",
"mgmtProxiesListNote": "Daftar item proxy registry tersimpan (mendukung pagination).",
"mgmtProxiesCreateNote": "Buat item proxy reusable di registry.",
"mgmtProxiesHealthNote": "Ambil metrik kesehatan proxy tersimpan (24 jam/window) dari proxy logs.",
"mgmtProxiesBulkAssignNote": "Set atau hapus satu proxy ke banyak scope ID dalam satu request.",
"mgmtAssignmentsListNote": "Daftar assignment proxy berdasarkan scope, scope_id, atau proxy_id.",
"mgmtAssignmentsUpdateNote": "Set atau hapus proxy untuk scope global/provider/account/combo.",
"mgmtLegacyMigrationNote": "Impor map proxyConfig lama ke assignment registry.",
"modelPrefixesDescriptionStart": "Gunakan awalan penyedia sebelum nama model untuk merutekan ke penyedia tertentu. Contoh:",
"modelPrefixesDescriptionEnd": "rute ke GitHub Copilot.",
"provider": "Penyedia",
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto";
export type ApiErrorType = "invalid_request" | "not_found" | "conflict" | "server_error";
interface ApiErrorPayload {
status: number;
message: string;
type?: ApiErrorType;
details?: unknown;
}
export function createErrorResponse(payload: ApiErrorPayload): Response {
const requestId = randomUUID();
const resolvedType =
payload.type ||
(payload.status >= 500
? "server_error"
: payload.status === 404
? "not_found"
: payload.status === 409
? "conflict"
: "invalid_request");
return Response.json(
{
error: {
message: payload.message,
type: resolvedType,
details: payload.details,
},
requestId,
},
{ status: payload.status }
);
}
export function createErrorResponseFromUnknown(
error: unknown,
fallbackMessage = "Unexpected server error"
): Response {
const anyError = error as {
message?: string;
status?: number;
type?: ApiErrorType;
details?: unknown;
};
const status = Number(anyError?.status) || 500;
return createErrorResponse({
status,
message: typeof anyError?.message === "string" ? anyError.message : fallbackMessage,
type: anyError?.type,
details: anyError?.details,
});
}
@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS proxy_registry (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
region TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_proxy_registry_status ON proxy_registry(status);
CREATE INDEX IF NOT EXISTS idx_proxy_registry_host ON proxy_registry(host);
CREATE TABLE IF NOT EXISTS proxy_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy_id TEXT NOT NULL,
scope TEXT NOT NULL,
scope_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(scope, scope_id),
FOREIGN KEY (proxy_id) REFERENCES proxy_registry(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_proxy_assignments_proxy_id ON proxy_assignments(proxy_id);
CREATE INDEX IF NOT EXISTS idx_proxy_assignments_scope ON proxy_assignments(scope, scope_id);
+573
View File
@@ -0,0 +1,573 @@
import { randomUUID } from "node:crypto";
import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
type JsonRecord = Record<string, unknown>;
type ProxyScope = "global" | "provider" | "account" | "combo";
interface ProxyRegistryRecord {
id: string;
name: string;
type: string;
host: string;
port: number;
username: string;
password: string;
region: string | null;
notes: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
interface ProxyAssignmentRecord {
id: number;
proxyId: string;
scope: ProxyScope;
scopeId: string | null;
createdAt: string;
updatedAt: string;
}
interface ProxyPayload {
name: string;
type: string;
host: string;
port: number;
username?: string;
password?: string;
region?: string | null;
notes?: string | null;
status?: string;
}
interface LegacyProxyConfig {
global?: unknown;
providers?: Record<string, unknown>;
combos?: Record<string, unknown>;
keys?: Record<string, unknown>;
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" ? (value as JsonRecord) : {};
}
function mapProxyRow(row: unknown): ProxyRegistryRecord {
const r = toRecord(row);
return {
id: typeof r.id === "string" ? r.id : "",
name: typeof r.name === "string" ? r.name : "",
type: typeof r.type === "string" ? r.type : "http",
host: typeof r.host === "string" ? r.host : "",
port: Number(r.port) || 0,
username: typeof r.username === "string" ? r.username : "",
password: typeof r.password === "string" ? r.password : "",
region: typeof r.region === "string" ? r.region : null,
notes: typeof r.notes === "string" ? r.notes : null,
status: typeof r.status === "string" ? r.status : "active",
createdAt: typeof r.created_at === "string" ? r.created_at : "",
updatedAt: typeof r.updated_at === "string" ? r.updated_at : "",
};
}
function mapAssignmentRow(row: unknown): ProxyAssignmentRecord {
const r = toRecord(row);
const scope = (typeof r.scope === "string" ? r.scope : "global") as ProxyScope;
const rawScopeId = typeof r.scope_id === "string" ? r.scope_id : null;
return {
id: Number(r.id) || 0,
proxyId: typeof r.proxy_id === "string" ? r.proxy_id : "",
scope,
scopeId: scope === "global" && rawScopeId === "__global__" ? null : rawScopeId,
createdAt: typeof r.created_at === "string" ? r.created_at : "",
updatedAt: typeof r.updated_at === "string" ? r.updated_at : "",
};
}
function normalizeScope(scope: string): ProxyScope {
const value = String(scope || "").toLowerCase();
if (value === "key") return "account";
if (value === "provider") return "provider";
if (value === "account") return "account";
if (value === "combo") return "combo";
return "global";
}
function coerceProxyPayload(value: unknown, fallbackName: string): ProxyPayload | null {
if (!value) return null;
if (typeof value === "string") {
try {
const parsed = new URL(value);
return {
name: fallbackName,
type: parsed.protocol.replace(":", "") || "http",
host: parsed.hostname,
port: Number(parsed.port || (parsed.protocol === "https:" ? "443" : "8080")),
username: parsed.username ? decodeURIComponent(parsed.username) : "",
password: parsed.password ? decodeURIComponent(parsed.password) : "",
status: "active",
};
} catch {
return null;
}
}
if (typeof value !== "object" || Array.isArray(value)) return null;
const record = toRecord(value);
const host = typeof record.host === "string" ? record.host.trim() : "";
if (!host) return null;
const port = Number(record.port) || 8080;
return {
name: fallbackName,
type: typeof record.type === "string" ? record.type : "http",
host,
port,
username: typeof record.username === "string" ? record.username : "",
password: typeof record.password === "string" ? record.password : "",
status: "active",
};
}
export function redactProxySecrets(proxy: ProxyRegistryRecord): ProxyRegistryRecord {
return {
...proxy,
username: proxy.username ? "***" : "",
password: proxy.password ? "***" : "",
};
}
export async function listProxies(options?: { includeSecrets?: boolean }) {
const includeSecrets = options?.includeSecrets === true;
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, name, type, host, port, username, password, region, notes, status, created_at, updated_at FROM proxy_registry ORDER BY datetime(updated_at) DESC, name ASC"
)
.all();
const proxies = rows.map(mapProxyRow);
return includeSecrets ? proxies : proxies.map(redactProxySecrets);
}
export async function getProxyById(id: string, options?: { includeSecrets?: boolean }) {
const includeSecrets = options?.includeSecrets === true;
const db = getDbInstance();
const row = db
.prepare(
"SELECT id, name, type, host, port, username, password, region, notes, status, created_at, updated_at FROM proxy_registry WHERE id = ?"
)
.get(id);
if (!row) return null;
const proxy = mapProxyRow(row);
return includeSecrets ? proxy : redactProxySecrets(proxy);
}
export async function createProxy(payload: ProxyPayload) {
const db = getDbInstance();
const id = randomUUID();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO proxy_registry
(id, name, type, host, port, username, password, region, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
payload.name,
payload.type,
payload.host,
Number(payload.port),
payload.username || "",
payload.password || "",
payload.region || null,
payload.notes || null,
payload.status || "active",
now,
now
);
backupDbFile("pre-write");
return getProxyById(id, { includeSecrets: false });
}
export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
const db = getDbInstance();
const existing = await getProxyById(id, { includeSecrets: true });
if (!existing) return null;
const merged = {
...existing,
...payload,
updatedAt: new Date().toISOString(),
};
db.prepare(
`UPDATE proxy_registry
SET name = ?, type = ?, host = ?, port = ?, username = ?, password = ?, region = ?, notes = ?, status = ?, updated_at = ?
WHERE id = ?`
).run(
merged.name,
merged.type,
merged.host,
Number(merged.port),
merged.username || "",
merged.password || "",
merged.region || null,
merged.notes || null,
merged.status || "active",
merged.updatedAt,
id
);
backupDbFile("pre-write");
return getProxyById(id, { includeSecrets: false });
}
export async function getProxyAssignments(filters?: { proxyId?: string; scope?: string }) {
const db = getDbInstance();
if (filters?.proxyId) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE proxy_id = ? ORDER BY scope, scope_id"
)
.all(filters.proxyId)
.map(mapAssignmentRow);
}
if (filters?.scope) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE scope = ? ORDER BY scope_id"
)
.all(normalizeScope(filters.scope))
.map(mapAssignmentRow);
}
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments ORDER BY scope, scope_id"
)
.all()
.map(mapAssignmentRow);
}
export async function getProxyWhereUsed(proxyId: string) {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE proxy_id = ? ORDER BY scope, scope_id"
)
.all(proxyId)
.map(mapAssignmentRow);
return {
count: rows.length,
assignments: rows,
};
}
export async function assignProxyToScope(
scope: string,
scopeId: string | null,
proxyId: string | null
): Promise<ProxyAssignmentRecord | null> {
const normalizedScope = normalizeScope(scope);
const normalizedScopeId = normalizedScope === "global" ? "__global__" : scopeId;
const db = getDbInstance();
if (!proxyId) {
db.prepare("DELETE FROM proxy_assignments WHERE scope = ? AND scope_id IS ?").run(
normalizedScope,
normalizedScopeId
);
backupDbFile("pre-write");
return null;
}
const proxy = await getProxyById(proxyId, { includeSecrets: true });
if (!proxy) {
const err = new Error(`Proxy not found: ${proxyId}`) as Error & { status?: number };
err.status = 404;
throw err;
}
const now = new Date().toISOString();
db.prepare(
`INSERT INTO proxy_assignments (proxy_id, scope, scope_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scope, scope_id)
DO UPDATE SET proxy_id = excluded.proxy_id, updated_at = excluded.updated_at`
).run(proxyId, normalizedScope, normalizedScopeId, now, now);
backupDbFile("pre-write");
const row = db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE scope = ? AND scope_id IS ?"
)
.get(normalizedScope, normalizedScopeId);
return row ? mapAssignmentRow(row) : null;
}
export async function deleteProxyById(id: string, options?: { force?: boolean }) {
const force = options?.force === true;
const db = getDbInstance();
const usage = await getProxyWhereUsed(id);
if (!force && usage.count > 0) {
const err = new Error(
"Proxy is still assigned. Remove assignments first or use force=true"
) as Error & {
status?: number;
code?: string;
};
err.status = 409;
err.code = "proxy_in_use";
throw err;
}
if (force && usage.count > 0) {
db.prepare("DELETE FROM proxy_assignments WHERE proxy_id = ?").run(id);
}
const result = db.prepare("DELETE FROM proxy_registry WHERE id = ?").run(id);
backupDbFile("pre-write");
return result.changes > 0;
}
export async function resolveProxyForConnectionFromRegistry(connectionId: string) {
const db = getDbInstance();
const accountAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1"
)
.get(connectionId);
if (accountAssignment) {
const record = toRecord(accountAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "account",
levelId: connectionId,
source: "registry",
};
}
const connection = db
.prepare("SELECT provider FROM provider_connections WHERE id = ?")
.get(connectionId) as { provider?: string } | undefined;
if (connection?.provider) {
const providerAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
)
.get(connection.provider);
if (providerAssignment) {
const record = toRecord(providerAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "provider",
levelId: connection.provider,
source: "registry",
};
}
}
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
)
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "global",
levelId: null,
source: "registry",
};
}
return null;
}
export async function migrateLegacyProxyConfigToRegistry(options?: { force?: boolean }) {
const force = options?.force === true;
const db = getDbInstance();
const existingCountRow = db.prepare("SELECT COUNT(*) AS cnt FROM proxy_registry").get() as
| { cnt?: number }
| undefined;
const existingCount = Number(existingCountRow?.cnt || 0);
if (!force && existingCount > 0) {
return { migrated: 0, skipped: true, reason: "registry_not_empty" as const };
}
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'proxyConfig'")
.all() as Array<{ key?: string; value?: string }>;
const raw: LegacyProxyConfig = {};
for (const row of rows) {
if (!row?.key || typeof row.value !== "string") continue;
try {
raw[row.key as keyof LegacyProxyConfig] = JSON.parse(row.value);
} catch {
// ignore malformed legacy entry
}
}
let migrated = 0;
if (raw.global) {
const payload = coerceProxyPayload(raw.global, "Legacy Global Proxy");
if (payload) {
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("global", null, created.id);
migrated++;
}
}
}
for (const [providerId, proxyValue] of Object.entries(raw.providers || {})) {
const payload = coerceProxyPayload(proxyValue, `Legacy Provider Proxy (${providerId})`);
if (!payload) continue;
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("provider", providerId, created.id);
migrated++;
}
}
for (const [comboId, proxyValue] of Object.entries(raw.combos || {})) {
const payload = coerceProxyPayload(proxyValue, `Legacy Combo Proxy (${comboId})`);
if (!payload) continue;
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("combo", comboId, created.id);
migrated++;
}
}
for (const [connectionId, proxyValue] of Object.entries(raw.keys || {})) {
const payload = coerceProxyPayload(proxyValue, `Legacy Account Proxy (${connectionId})`);
if (!payload) continue;
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("account", connectionId, created.id);
migrated++;
}
}
return { migrated, skipped: false as const };
}
export async function getProxyHealthStats(options?: { hours?: number }) {
const db = getDbInstance();
const hours = Math.max(1, Math.min(24 * 30, Number(options?.hours || 24)));
const sinceIso = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
const rows = db
.prepare(
`SELECT
p.id as proxy_id,
p.name as proxy_name,
p.type as proxy_type,
p.host as proxy_host,
p.port as proxy_port,
COUNT(l.id) as total_requests,
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) as error_count,
SUM(CASE WHEN l.status = 'timeout' THEN 1 ELSE 0 END) as timeout_count,
AVG(CASE WHEN l.latency_ms IS NOT NULL THEN l.latency_ms END) as avg_latency_ms,
MAX(l.timestamp) as last_seen_at
FROM proxy_registry p
LEFT JOIN proxy_logs l
ON l.proxy_host = p.host
AND l.proxy_type = p.type
AND l.timestamp >= ?
GROUP BY p.id, p.name, p.type, p.host, p.port
ORDER BY p.name ASC`
)
.all(sinceIso) as Array<Record<string, unknown>>;
return rows.map((row) => {
const total = Number(row.total_requests || 0);
const success = Number(row.success_count || 0);
const error = Number(row.error_count || 0);
const timeout = Number(row.timeout_count || 0);
const successRate = total > 0 ? Math.round((success / total) * 10000) / 100 : null;
return {
proxyId: String(row.proxy_id || ""),
name: String(row.proxy_name || ""),
type: String(row.proxy_type || "http"),
host: String(row.proxy_host || ""),
port: Number(row.proxy_port || 0),
totalRequests: total,
successCount: success,
errorCount: error,
timeoutCount: timeout,
successRate,
avgLatencyMs:
row.avg_latency_ms === null || row.avg_latency_ms === undefined
? null
: Math.round(Number(row.avg_latency_ms)),
lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null,
};
});
}
export async function bulkAssignProxyToScope(
scope: string,
scopeIds: string[],
proxyId: string | null
): Promise<{ updated: number; failed: Array<{ scopeId: string; reason: string }> }> {
const uniqueScopeIds = [
...new Set((scopeIds || []).map((id) => String(id).trim()).filter(Boolean)),
];
const failed: Array<{ scopeId: string; reason: string }> = [];
let updated = 0;
if (scope === "global") {
await assignProxyToScope("global", null, proxyId);
return { updated: 1, failed: [] };
}
for (const scopeId of uniqueScopeIds) {
try {
await assignProxyToScope(scope, scopeId, proxyId);
updated++;
} catch (error) {
failed.push({
scopeId,
reason: error instanceof Error ? error.message : "Unknown error",
});
}
}
return { updated, failed };
}
+6
View File
@@ -6,6 +6,7 @@ import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts";
import { invalidateDbCache } from "./readCache";
import { resolveProxyForConnectionFromRegistry } from "./proxies";
type JsonRecord = Record<string, unknown>;
type PricingModels = Record<string, JsonRecord>;
@@ -389,6 +390,11 @@ export async function deleteProxyForLevel(level: string, id: string | null) {
}
export async function resolveProxyForConnection(connectionId: string) {
const registryResolved = await resolveProxyForConnectionFromRegistry(connectionId);
if (registryResolved?.proxy) {
return registryResolved;
}
const config = await getProxyConfig();
if (connectionId && config.keys?.[connectionId]) {
+16
View File
@@ -89,6 +89,22 @@ export {
setProxyConfig,
} from "./db/settings";
export {
// Proxy Registry
listProxies,
getProxyById,
createProxy,
updateProxy,
deleteProxyById,
getProxyAssignments,
getProxyWhereUsed,
assignProxyToScope,
resolveProxyForConnectionFromRegistry,
migrateLegacyProxyConfigToRegistry,
getProxyHealthStats,
bulkAssignProxyToScope,
} from "./db/proxies";
export {
// Pricing Sync
getSyncedPricing,
+222 -80
View File
@@ -33,7 +33,24 @@ const LEVEL_LABELS = {
* @param {string} [props.levelLabel] — display name for the level
* @param {Function} [props.onSaved] — callback after save
*/
export default function ProxyConfigModal({ isOpen, onClose, level, levelId, levelLabel, onSaved }: { isOpen: any; onClose: any; level: any; levelId?: any; levelLabel?: any; onSaved?: any }) {
export default function ProxyConfigModal({
isOpen,
onClose,
level,
levelId,
levelLabel,
onSaved,
}: {
isOpen: any;
onClose: any;
level: any;
levelId?: any;
levelLabel?: any;
onSaved?: any;
}) {
const [mode, setMode] = useState("saved");
const [savedProxies, setSavedProxies] = useState([]);
const [selectedProxyId, setSelectedProxyId] = useState("");
const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http");
const [host, setHost] = useState("");
const [port, setPort] = useState("");
@@ -63,6 +80,36 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
const loadProxy = async () => {
try {
let hasSavedAssignment = false;
const registryRes = await fetch("/api/settings/proxies");
if (registryRes.ok) {
const registryPayload = await registryRes.json();
setSavedProxies(Array.isArray(registryPayload?.items) ? registryPayload.items : []);
} else {
setSavedProxies([]);
}
const scope = level === "key" ? "account" : level;
const assignmentParams = new URLSearchParams({ scope });
if (level !== "global" && levelId) {
assignmentParams.set("scopeId", levelId);
}
const assignmentRes = await fetch(`/api/settings/proxies/assignments?${assignmentParams}`);
if (assignmentRes.ok) {
const assignmentPayload = await assignmentRes.json();
const items = Array.isArray(assignmentPayload?.items) ? assignmentPayload.items : [];
const target = items[0];
if (target?.proxyId) {
setMode("saved");
setSelectedProxyId(target.proxyId);
setHasOwnProxy(true);
hasSavedAssignment = true;
} else {
setMode("custom");
setSelectedProxyId("");
}
}
// Load own proxy
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
@@ -85,9 +132,12 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
"SOCKS5 is configured but hidden because NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false."
);
}
if (!hasSavedAssignment) setMode("custom");
} else {
resetFields();
setHasOwnProxy(false);
if (!hasSavedAssignment) {
setHasOwnProxy(false);
}
}
}
@@ -130,22 +180,46 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
};
const handleSave = async () => {
if (!host.trim()) return;
if (mode === "saved" && !selectedProxyId) {
setFormError("Select a saved proxy before saving.");
return;
}
if (mode === "custom" && !host.trim()) return;
setFormError(null);
setSaving(true);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
const res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
const scope = level === "key" ? "account" : level;
let res;
if (mode === "saved") {
res = await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: selectedProxyId,
}),
});
if (res.ok) {
const clearParams = new URLSearchParams({ level });
if (levelId) clearParams.set("id", levelId);
await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" });
}
} else {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
}
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to save proxy configuration");
@@ -165,6 +239,17 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
setFormError(null);
setSaving(true);
try {
const scope = level === "key" ? "account" : level;
await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: null,
}),
});
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`, { method: "DELETE" });
@@ -175,6 +260,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
}
resetFields();
setHasOwnProxy(false);
setSelectedProxyId("");
setTestResult(null);
onSaved?.();
} catch (error) {
@@ -186,6 +272,10 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
};
const handleTest = async () => {
if (mode === "saved") {
setFormError("Use custom mode to run manual connection test.");
return;
}
if (!host.trim()) return;
setFormError(null);
setTesting(true);
@@ -248,93 +338,145 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
{/* Proxy Type Selector */}
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
Source
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
proxyType === t.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t.label}
</button>
))}
<div className="flex gap-2">
<button
onClick={() => setMode("saved")}
className={`px-3 py-2 rounded text-sm border transition-colors ${
mode === "saved"
? "bg-primary text-white border-primary"
: "bg-bg-subtle text-text-muted border-border"
}`}
>
Saved Proxy
</button>
<button
onClick={() => setMode("custom")}
className={`px-3 py-2 rounded text-sm border transition-colors ${
mode === "custom"
? "bg-primary text-white border-primary"
: "bg-bg-subtle text-text-muted border-border"
}`}
>
Custom
</button>
</div>
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Host
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
{mode === "saved" && (
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Port
Saved Proxy
</label>
<input
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={getDefaultPort(proxyType)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
<select
value={selectedProxyId}
onChange={(e) => setSelectedProxyId(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary"
>
<option value="">Select saved proxy...</option>
{savedProxies.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
</option>
))}
</select>
</div>
</div>
)}
{/* Auth Toggle */}
<div>
<button
onClick={() => setShowAuth(!showAuth)}
className="flex items-center gap-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
Authentication (optional)
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
{mode === "custom" && (
<>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
proxyType === t.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Username
Host
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Password
Port
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={getDefaultPort(proxyType)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
)}
</div>
{/* Auth Toggle */}
<div>
<button
onClick={() => setShowAuth(!showAuth)}
className="flex items-center gap-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
Authentication (optional)
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
)}
</div>
</>
)}
{/* Test Result */}
{formError && (
@@ -390,7 +532,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
icon="speed"
onClick={handleTest}
loading={testing}
disabled={!host.trim()}
disabled={mode !== "custom" || !host.trim()}
>
Test Connection
</Button>
@@ -416,7 +558,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
icon="save"
onClick={handleSave}
loading={saving}
disabled={!host.trim()}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Save
</Button>
+62 -1
View File
@@ -551,7 +551,7 @@ export const removeModelAliasSchema = z.object({
from: z.string().trim().min(1),
});
const proxyConfigSchema = z
export const proxyConfigSchema = z
.object({
type: z
.preprocess(
@@ -621,6 +621,67 @@ export const testProxySchema = z.object({
}),
});
export const createProxyRegistrySchema = z
.object({
name: z.string().trim().min(1, "name is required").max(120),
type: z
.preprocess(
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
z.enum(["http", "https", "socks5"])
)
.optional()
.default("http"),
host: z.string().trim().min(1, "host is required").max(255),
port: z.coerce.number().int().min(1).max(65535),
username: z.string().optional(),
password: z.string().optional(),
region: z.string().trim().max(64).nullable().optional(),
notes: z.string().trim().max(1000).nullable().optional(),
status: z.enum(["active", "inactive"]).optional().default("active"),
})
.strict();
export const updateProxyRegistrySchema = createProxyRegistrySchema.partial().extend({
id: z.string().trim().min(1, "id is required"),
});
export const proxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),
scopeId: z.string().trim().nullable().optional(),
proxyId: z.string().trim().nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.scope !== "global" && !value.scopeId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "scopeId is required for provider/account/combo/key scope",
path: ["scopeId"],
});
}
});
export const bulkProxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),
scopeIds: z.array(z.string().trim().min(1)).optional().default([]),
proxyId: z.string().trim().nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (
value.scope !== "global" &&
(!Array.isArray(value.scopeIds) || value.scopeIds.length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "scopeIds is required for provider/account/combo/key scope",
path: ["scopeIds"],
});
}
});
const jsonRecordSchema = z.record(z.string(), z.unknown());
const nonEmptyJsonRecordSchema = jsonRecordSchema.refine(
(value) => Object.keys(value).length > 0,
+115 -63
View File
@@ -52,6 +52,8 @@ interface RecoverableConnectionState {
}
const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
const MIN_QUOTA_THRESHOLD_PERCENT = 1;
const MAX_QUOTA_THRESHOLD_PERCENT = 100;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
@@ -111,6 +113,84 @@ function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
};
}
interface QuotaLimitPolicy {
enabled: boolean;
thresholdPercent: number;
windows: string[];
}
function normalizeQuotaThreshold(value: unknown, fallback = CODEX_QUOTA_THRESHOLD_PERCENT): number {
const parsed = toNumber(value, fallback);
return Math.min(MAX_QUOTA_THRESHOLD_PERCENT, Math.max(MIN_QUOTA_THRESHOLD_PERCENT, parsed));
}
function normalizeWindowName(windowName: unknown): string | null {
if (typeof windowName !== "string") return null;
const normalized = windowName.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function getLegacyCodexWindows(providerSpecificData: JsonRecord): string[] {
const codexPolicy = getCodexLimitPolicy(providerSpecificData);
const windows: string[] = [];
if (codexPolicy.use5h) windows.push("session");
if (codexPolicy.useWeekly) windows.push("weekly");
return windows;
}
export function resolveQuotaLimitPolicy(
provider: string,
providerSpecificData: JsonRecord
): QuotaLimitPolicy {
const rawPolicy = asRecord(providerSpecificData.limitPolicy);
const rawWindows = Array.isArray(rawPolicy.windows) ? rawPolicy.windows : [];
const windows = rawWindows.map(normalizeWindowName).filter(Boolean) as string[];
if (provider === "codex") {
const fallbackWindows = getLegacyCodexWindows(providerSpecificData);
const defaultWindows = windows.length > 0 ? windows : fallbackWindows;
const enabled = toBooleanOrDefault(rawPolicy.enabled, defaultWindows.length > 0);
return {
enabled,
thresholdPercent: normalizeQuotaThreshold(rawPolicy.thresholdPercent),
windows: defaultWindows,
};
}
return {
enabled: toBooleanOrDefault(rawPolicy.enabled, false),
thresholdPercent: normalizeQuotaThreshold(rawPolicy.thresholdPercent),
windows,
};
}
export function evaluateQuotaLimitPolicy(
provider: string,
connection: ProviderConnectionView
): { blocked: boolean; reasons: string[]; resetAt: string | null } {
const policy = resolveQuotaLimitPolicy(provider, connection.providerSpecificData);
if (!policy.enabled || policy.windows.length === 0) {
return { blocked: false, reasons: [], resetAt: null };
}
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
for (const windowName of policy.windows) {
const status = getQuotaWindowStatus(connection.id, windowName, policy.thresholdPercent);
if (!status?.reachedThreshold) continue;
reasons.push(`${windowName} usage ${Math.round(status.usedPercentage)}%`);
resetCandidates.push(status.resetAt);
}
return {
blocked: reasons.length > 0,
reasons,
resetAt: getEarliestFutureDate(resetCandidates),
};
}
function parseFutureDateMs(value: string | null): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
@@ -260,76 +340,48 @@ export async function getProviderCredentials(
}
let policyEligibleConnections = availableConnections;
if (provider === "codex") {
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
policyEligibleConnections = availableConnections.filter((connection) => {
const policy = getCodexLimitPolicy(connection.providerSpecificData);
const sessionStatus = policy.use5h
? getQuotaWindowStatus(connection.id, "session", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
const weeklyStatus = policy.useWeekly
? getQuotaWindowStatus(connection.id, "weekly", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
policyEligibleConnections = availableConnections.filter((connection) => {
const evaluation = evaluateQuotaLimitPolicy(provider, connection);
if (!evaluation.blocked) return true;
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
if (policy.use5h && sessionStatus?.reachedThreshold) {
reasons.push(`5h usage ${Math.round(sessionStatus.usedPercentage)}%`);
resetCandidates.push(sessionStatus.resetAt);
}
if (policy.useWeekly && weeklyStatus?.reachedThreshold) {
reasons.push(`weekly usage ${Math.round(weeklyStatus.usedPercentage)}%`);
resetCandidates.push(weeklyStatus.resetAt);
}
if (reasons.length > 0) {
const nextResetAt = getEarliestFutureDate(resetCandidates);
blockedByPolicy.push({
id: connection.id,
reasons,
resetAt: nextResetAt,
});
return false;
}
return true;
blockedByPolicy.push({
id: connection.id,
reasons: evaluation.reasons,
resetAt: evaluation.resetAt,
});
return false;
});
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(
blockedByPolicy.map((entry) => entry.resetAt)
);
const earliestResetMs = parseFutureDateMs(earliestResetAt);
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(blockedByPolicy.map((entry) => entry.resetAt));
const earliestResetMs = parseFutureDateMs(earliestResetAt);
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: "All Codex accounts reached configured quota threshold",
lastErrorCode: 429,
};
}
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: `All ${provider} accounts reached configured quota threshold`,
lastErrorCode: 429,
};
}
// Quota-aware: prioritize accounts with available quota
+216
View File
@@ -0,0 +1,216 @@
import { test, expect } from "@playwright/test";
type ProxyStub = {
id: string;
name: string;
type: string;
host: string;
port: number;
status: string;
region?: string | null;
notes?: string | null;
};
test.describe("Proxy Registry smoke flow", () => {
test("create, edit, bulk-assign modal, and delete proxy from settings advanced", async ({
page,
}) => {
const state: {
proxies: ProxyStub[];
nextId: number;
bulkAssignCalls: number;
} = {
proxies: [],
nextId: 1,
bulkAssignCalls: 0,
};
await page.route("**/api/settings/proxy?level=global", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ proxy: null }),
});
});
await page.route("**/api/settings/proxies/health?hours=24", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: state.proxies.map((p) => ({
proxyId: p.id,
totalRequests: 0,
successRate: null,
avgLatencyMs: null,
lastSeenAt: null,
})),
total: state.proxies.length,
windowHours: 24,
}),
});
});
await page.route("**/api/settings/proxies/bulk-assign", async (route) => {
if (route.request().method() !== "PUT") {
await route.fulfill({
status: 405,
contentType: "application/json",
body: JSON.stringify({ error: "method not allowed in test stub" }),
});
return;
}
state.bulkAssignCalls += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
scope: "provider",
requested: 2,
updated: 2,
failed: [],
}),
});
});
await page.route("**/api/settings/proxies*", async (route) => {
const req = route.request();
const method = req.method();
const url = new URL(req.url());
const id = url.searchParams.get("id");
const whereUsed = url.searchParams.get("whereUsed");
if (method === "GET" && id && whereUsed === "1") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ count: 0, assignments: [] }),
});
return;
}
if (method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: state.proxies, total: state.proxies.length }),
});
return;
}
if (method === "POST") {
const payload = req.postDataJSON() as Partial<ProxyStub>;
const proxy: ProxyStub = {
id: `proxy-${state.nextId++}`,
name: payload.name || "Proxy",
type: payload.type || "http",
host: payload.host || "localhost",
port: Number(payload.port || 8080),
status: payload.status || "active",
region: payload.region || null,
notes: payload.notes || null,
};
state.proxies.unshift(proxy);
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(proxy),
});
return;
}
if (method === "PATCH") {
const payload = req.postDataJSON() as Partial<ProxyStub> & { id?: string };
const index = state.proxies.findIndex((p) => p.id === payload.id);
if (index === -1) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: { message: "Proxy not found", type: "not_found" } }),
});
return;
}
const updated = {
...state.proxies[index],
...payload,
} as ProxyStub;
state.proxies[index] = updated;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(updated),
});
return;
}
if (method === "DELETE") {
if (!id) {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: { message: "id is required", type: "invalid_request" } }),
});
return;
}
state.proxies = state.proxies.filter((p) => p.id !== id);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
return;
}
await route.fulfill({
status: 405,
contentType: "application/json",
body: JSON.stringify({ error: "method not allowed in test stub" }),
});
});
await page.goto("/dashboard/settings?tab=advanced");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible();
await page.getByTestId("proxy-registry-open-create").click();
const createDialog = page.getByRole("dialog");
await expect(createDialog.getByText("Create Proxy")).toBeVisible();
await createDialog.getByTestId("proxy-registry-name-input").fill("Registry Smoke Proxy");
await createDialog.getByTestId("proxy-registry-host-input").fill("smoke.local");
await createDialog.getByRole("button", { name: "Save" }).click();
await expect(page.locator("table")).toContainText("Registry Smoke Proxy");
await expect(page.locator("table")).toContainText("http://smoke.local:8080");
const row = page.locator("tr", { hasText: "Registry Smoke Proxy" });
await row.getByRole("button", { name: "Edit" }).click();
const editDialog = page.getByRole("dialog");
await expect(editDialog.getByText("Edit Proxy")).toBeVisible();
await editDialog.getByTestId("proxy-registry-host-input").fill("smoke-updated.local");
await editDialog.getByRole("button", { name: "Save" }).click();
await expect(page.locator("table")).toContainText("http://smoke-updated.local:8080");
await page.getByTestId("proxy-registry-open-bulk").click();
const bulkDialog = page.getByRole("dialog");
await expect(bulkDialog.getByText("Bulk Proxy Assignment")).toBeVisible();
await bulkDialog.getByTestId("proxy-registry-bulk-scopeids-input").fill("openai,anthropic");
await bulkDialog.getByTestId("proxy-registry-bulk-apply").click();
await expect
.poll(() => state.bulkAssignCalls, {
message: "Expected bulk assign API to be called exactly once",
})
.toBe(1);
await row.getByRole("button", { name: "Delete" }).click();
await expect(page.locator("table")).not.toContainText("Registry Smoke Proxy");
});
});
@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-registry-flow-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const proxySettingsRoute = await import("../../src/app/api/settings/proxies/route.ts");
const proxyAssignmentsRoute =
await import("../../src/app/api/settings/proxies/assignments/route.ts");
const proxyBulkRoute = await import("../../src/app/api/settings/proxies/bulk-assign/route.ts");
const proxyHealthRoute = await import("../../src/app/api/settings/proxies/health/route.ts");
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("integration: proxy registry full flow works and enforces safe delete", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "proxy-flow-account",
apiKey: "sk-flow-test",
});
const createRes = await proxySettingsRoute.POST(
new Request("http://localhost/api/settings/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Flow Proxy",
type: "http",
host: "flow.local",
port: 8080,
}),
})
);
assert.equal(createRes.status, 201);
const createdProxy = await createRes.json();
assert.ok(createdProxy.id);
const assignRes = await proxyAssignmentsRoute.PUT(
new Request("http://localhost/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "account",
scopeId: connection.id,
proxyId: createdProxy.id,
}),
})
);
assert.equal(assignRes.status, 200);
const resolveRes = await proxyAssignmentsRoute.GET(
new Request(
`http://localhost/api/settings/proxies/assignments?resolveConnectionId=${connection.id}`
)
);
assert.equal(resolveRes.status, 200);
const resolved = await resolveRes.json();
assert.equal(resolved.level, "account");
assert.equal(resolved.source, "registry");
assert.equal(resolved.proxy.host, "flow.local");
const bulkRes = await proxyBulkRoute.PUT(
new Request("http://localhost/api/settings/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "provider",
scopeIds: ["openai", "anthropic"],
proxyId: createdProxy.id,
}),
})
);
assert.equal(bulkRes.status, 200);
const bulkPayload = await bulkRes.json();
assert.equal(bulkPayload.updated, 2);
assert.equal(bulkPayload.failed.length, 0);
proxyLogger.logProxyEvent({
status: "success",
proxy: { type: "http", host: "flow.local", port: 8080 },
latencyMs: 90,
level: "provider",
levelId: "openai",
provider: "openai",
});
proxyLogger.logProxyEvent({
status: "error",
proxy: { type: "http", host: "flow.local", port: 8080 },
latencyMs: 240,
level: "provider",
levelId: "openai",
provider: "openai",
});
const healthRes = await proxyHealthRoute.GET(
new Request("http://localhost/api/settings/proxies/health?hours=24")
);
assert.equal(healthRes.status, 200);
const healthPayload = await healthRes.json();
const row = healthPayload.items.find((item) => item.proxyId === createdProxy.id);
assert.ok(row);
assert.equal(row.totalRequests >= 2, true);
assert.equal(row.errorCount >= 1, true);
const deleteConflictRes = await proxySettingsRoute.DELETE(
new Request(`http://localhost/api/settings/proxies?id=${createdProxy.id}`, {
method: "DELETE",
})
);
assert.equal(deleteConflictRes.status, 409);
const deleteConflict = await deleteConflictRes.json();
assert.equal(deleteConflict.error.type, "conflict");
assert.equal(typeof deleteConflict.requestId, "string");
assert.equal(deleteConflict.requestId.length > 0, true);
const clearAccountAssignment = await proxyAssignmentsRoute.PUT(
new Request("http://localhost/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "account",
scopeId: connection.id,
proxyId: null,
}),
})
);
assert.equal(clearAccountAssignment.status, 200);
const clearProviderBulk = await proxyBulkRoute.PUT(
new Request("http://localhost/api/settings/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "provider",
scopeIds: ["openai", "anthropic"],
proxyId: null,
}),
})
);
assert.equal(clearProviderBulk.status, 200);
const deleteOkRes = await proxySettingsRoute.DELETE(
new Request(`http://localhost/api/settings/proxies?id=${createdProxy.id}`, {
method: "DELETE",
})
);
assert.equal(deleteOkRes.status, 200);
const deleteOkPayload = await deleteOkRes.json();
assert.equal(deleteOkPayload.success, true);
});
@@ -0,0 +1,202 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-v1-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const proxyV1Route = await import("../../src/app/api/v1/management/proxies/route.ts");
const proxyAssignmentsV1Route =
await import("../../src/app/api/v1/management/proxies/assignments/route.ts");
const proxyHealthV1Route = await import("../../src/app/api/v1/management/proxies/health/route.ts");
const proxyBulkAssignV1Route =
await import("../../src/app/api/v1/management/proxies/bulk-assign/route.ts");
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("v1 management proxies supports create/list/pagination", async () => {
await resetStorage();
const createA = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy A",
type: "http",
host: "proxy-a.local",
port: 8080,
}),
})
);
assert.equal(createA.status, 201);
const createB = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy B",
type: "https",
host: "proxy-b.local",
port: 443,
}),
})
);
assert.equal(createB.status, 201);
const listRes = await proxyV1Route.GET(
new Request("http://localhost/api/v1/management/proxies?limit=1&offset=0")
);
assert.equal(listRes.status, 200);
const listPayload = await listRes.json();
assert.equal(Array.isArray(listPayload.items), true);
assert.equal(listPayload.items.length, 1);
assert.equal(listPayload.page.total >= 2, true);
});
test("v1 management assignments supports put and filtered get", async () => {
await resetStorage();
const providerConn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "v1-assignment",
apiKey: "sk-test-v1",
});
const createdRes = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy Assign",
type: "http",
host: "assign.local",
port: 8000,
}),
})
);
const created = await createdRes.json();
const assignRes = await proxyAssignmentsV1Route.PUT(
new Request("http://localhost/api/v1/management/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "account",
scopeId: providerConn.id,
proxyId: created.id,
}),
})
);
assert.equal(assignRes.status, 200);
const filteredRes = await proxyAssignmentsV1Route.GET(
new Request(
`http://localhost/api/v1/management/proxies/assignments?scope=account&scope_id=${providerConn.id}`
)
);
assert.equal(filteredRes.status, 200);
const payload = await filteredRes.json();
assert.equal(payload.items.length, 1);
assert.equal(payload.items[0].proxyId, created.id);
});
test("v1 management health endpoint aggregates proxy log metrics", async () => {
await resetStorage();
const createdRes = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy Health",
type: "http",
host: "health.local",
port: 8080,
}),
})
);
const created = await createdRes.json();
proxyLogger.logProxyEvent({
status: "success",
proxy: { type: "http", host: "health.local", port: 8080 },
latencyMs: 120,
level: "provider",
levelId: "openai",
provider: "openai",
});
proxyLogger.logProxyEvent({
status: "error",
proxy: { type: "http", host: "health.local", port: 8080 },
latencyMs: 200,
level: "provider",
levelId: "openai",
provider: "openai",
});
const healthRes = await proxyHealthV1Route.GET(
new Request("http://localhost/api/v1/management/proxies/health?hours=24")
);
assert.equal(healthRes.status, 200);
const healthPayload = await healthRes.json();
const row = healthPayload.items.find((item) => item.proxyId === created.id);
assert.ok(row);
assert.equal(row.totalRequests >= 2, true);
assert.equal(row.errorCount >= 1, true);
});
test("v1 bulk assignment updates multiple scope IDs in one request", async () => {
await resetStorage();
const proxyRes = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Bulk Proxy",
type: "http",
host: "bulk.local",
port: 8080,
}),
})
);
const proxy = await proxyRes.json();
const bulkRes = await proxyBulkAssignV1Route.PUT(
new Request("http://localhost/api/v1/management/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "provider",
scopeIds: ["openai", "anthropic"],
proxyId: proxy.id,
}),
})
);
assert.equal(bulkRes.status, 200);
const bulkPayload = await bulkRes.json();
assert.equal(bulkPayload.updated, 2);
const checkRes = await proxyAssignmentsV1Route.GET(
new Request("http://localhost/api/v1/management/proxies/assignments?scope=provider")
);
const checkPayload = await checkRes.json();
assert.equal(checkPayload.items.length >= 2, true);
});
+121
View File
@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-registry-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const proxiesDb = await import("../../src/lib/db/proxies.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("proxy registry blocks delete when proxy is still assigned", async () => {
await resetStorage();
const created = await proxiesDb.createProxy({
name: "Delete Safety Proxy",
type: "http",
host: "127.0.0.1",
port: 8080,
});
assert.ok(created?.id);
await proxiesDb.assignProxyToScope("provider", "openai", created.id);
await assert.rejects(
async () => proxiesDb.deleteProxyById(created.id),
(error) => {
assert.equal(error.status, 409);
assert.equal(error.code, "proxy_in_use");
return true;
}
);
});
test("registry assignment takes precedence over legacy proxy config", async () => {
await resetStorage();
const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "registry-precedence",
apiKey: "sk-test",
});
await settingsDb.setProxyForLevel("key", conn.id, {
type: "http",
host: "legacy-key.local",
port: 8080,
});
const providerProxy = await proxiesDb.createProxy({
name: "Provider Proxy",
type: "https",
host: "provider.local",
port: 443,
});
const accountProxy = await proxiesDb.createProxy({
name: "Account Proxy",
type: "http",
host: "account.local",
port: 8081,
});
await proxiesDb.assignProxyToScope("provider", "openai", providerProxy.id);
await proxiesDb.assignProxyToScope("account", conn.id, accountProxy.id);
const resolved = await settingsDb.resolveProxyForConnection(conn.id);
assert.equal(resolved.level, "account");
assert.equal(resolved.source, "registry");
assert.equal(resolved.proxy.host, "account.local");
});
test("legacy proxy config migration imports global/provider/key assignments", async () => {
await resetStorage();
const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "legacy-import",
apiKey: "sk-test-legacy",
});
await settingsDb.setProxyForLevel("global", null, {
type: "http",
host: "global.local",
port: 8080,
});
await settingsDb.setProxyForLevel("provider", "openai", {
type: "https",
host: "provider-legacy.local",
port: 443,
});
await settingsDb.setProxyForLevel("key", conn.id, {
type: "http",
host: "account-legacy.local",
port: 8082,
});
const result = await proxiesDb.migrateLegacyProxyConfigToRegistry();
assert.equal(result.skipped, false);
assert.equal(result.migrated >= 3, true);
const resolved = await settingsDb.resolveProxyForConnection(conn.id);
assert.equal(resolved.level, "account");
assert.equal(resolved.source, "registry");
assert.equal(resolved.proxy.host, "account-legacy.local");
});
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
const auth = await import("../../src/sse/services/auth.ts");
const quotaCache = await import("../../src/domain/quotaCache.ts");
function buildConnection(id, providerSpecificData = {}) {
return {
id,
providerSpecificData,
};
}
test("resolveQuotaLimitPolicy keeps codex legacy defaults when generic policy is missing", () => {
const policy = auth.resolveQuotaLimitPolicy("codex", {
codexLimitPolicy: { use5h: true, useWeekly: false },
});
assert.equal(policy.enabled, true);
assert.deepEqual(policy.windows, ["session"]);
assert.equal(policy.thresholdPercent, 90);
});
test("resolveQuotaLimitPolicy disables non-codex policy by default", () => {
const policy = auth.resolveQuotaLimitPolicy("openai", {});
assert.equal(policy.enabled, false);
assert.deepEqual(policy.windows, []);
});
test("resolveQuotaLimitPolicy accepts generic provider policy and clamps threshold", () => {
const policy = auth.resolveQuotaLimitPolicy("openai", {
limitPolicy: {
enabled: true,
thresholdPercent: 999,
windows: ["daily", " monthly ", ""],
},
});
assert.equal(policy.enabled, true);
assert.equal(policy.thresholdPercent, 100);
assert.deepEqual(policy.windows, ["daily", "monthly"]);
});
test("evaluateQuotaLimitPolicy blocks when configured window reaches threshold", () => {
const resetAt = new Date(Date.now() + 60_000).toISOString();
quotaCache.setQuotaCache("conn-policy-1", "openai", {
daily: { remainingPercentage: 5, resetAt },
});
const result = auth.evaluateQuotaLimitPolicy(
"openai",
buildConnection("conn-policy-1", {
limitPolicy: { enabled: true, thresholdPercent: 90, windows: ["daily"] },
})
);
assert.equal(result.blocked, true);
assert.equal(result.reasons.length, 1);
assert.match(result.reasons[0], /daily usage/i);
assert.equal(result.resetAt, resetAt);
});
test("evaluateQuotaLimitPolicy does not block when no quota data exists", () => {
const result = auth.evaluateQuotaLimitPolicy(
"openai",
buildConnection("conn-policy-missing", {
limitPolicy: { enabled: true, thresholdPercent: 90, windows: ["daily"] },
})
);
assert.equal(result.blocked, false);
assert.deepEqual(result.reasons, []);
assert.equal(result.resetAt, null);
});