feat: implement proxy registry, management APIs, docs, and test hardening
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user