mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
- Refactor chatCore.js to streamline imports and remove unused functions.
- Fix streaming /v1/responses
This commit is contained in:
@@ -24,6 +24,8 @@ export default function ProviderDetailPage() {
|
||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModels, setTestingModels] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const providerInfo = providerNode
|
||||
@@ -256,6 +258,27 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestModels = async () => {
|
||||
if (testingModels) return;
|
||||
const conn = connections.find((c) => c.isActive !== false) || connections[0];
|
||||
if (!conn) return;
|
||||
setTestingModels(true);
|
||||
setModelTestResults({});
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${conn.id}/test-models`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const map = {};
|
||||
for (const r of data.results || []) map[r.modelId] = r.ok ? "ok" : "error";
|
||||
setModelTestResults(map);
|
||||
}
|
||||
} catch {
|
||||
// silent fail
|
||||
} finally {
|
||||
setTestingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderModelsSection = () => {
|
||||
if (isCompatible) {
|
||||
return (
|
||||
@@ -305,6 +328,7 @@ export default function ProviderDetailPage() {
|
||||
onCopy={copy}
|
||||
onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)}
|
||||
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -494,11 +518,23 @@ export default function ProviderDetailPage() {
|
||||
|
||||
{/* Models */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
||||
</h2>
|
||||
{connections.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={testingModels ? "progress_activity" : "science"}
|
||||
onClick={handleTestModels}
|
||||
disabled={testingModels}
|
||||
>
|
||||
{testingModels ? "Testing…" : "Test Models"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{renderModelsSection()}
|
||||
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
@@ -552,10 +588,27 @@ export default function ProviderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({ model, fullModel, alias, copied, onCopy }) {
|
||||
function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) {
|
||||
const borderColor = testStatus === "ok"
|
||||
? "border-green-500/40"
|
||||
: testStatus === "error"
|
||||
? "border-red-500/40"
|
||||
: "border-border";
|
||||
|
||||
const iconColor = testStatus === "ok"
|
||||
? "#22c55e"
|
||||
: testStatus === "error"
|
||||
? "#ef4444"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<span
|
||||
className="material-symbols-outlined text-base"
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
>
|
||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||
</span>
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
||||
@@ -578,6 +631,7 @@ ModelRow.propTypes = {
|
||||
alias: PropTypes.string,
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
testStatus: PropTypes.oneOf(["ok", "error"]),
|
||||
};
|
||||
|
||||
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
|
||||
@@ -1498,3 +1552,4 @@ EditCompatibleNodeModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
isAnthropic: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, getApiKeys } from "@/lib/localDb";
|
||||
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
/**
|
||||
* Get an active API key to pass through auth when requireApiKey is enabled.
|
||||
*/
|
||||
async function getInternalApiKey() {
|
||||
const keys = await getApiKeys();
|
||||
return keys.find((k) => k.isActive !== false)?.key || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping a single model via internal completions endpoint (OpenAI format).
|
||||
* open-sse handles all provider translation automatically.
|
||||
*/
|
||||
async function pingModel(modelId, baseUrl, apiKey) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
// 200 = working; 400 = bad request but auth passed (model reachable)
|
||||
const ok = res.status === 200 || res.status === 400;
|
||||
let error = null;
|
||||
if (!ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`;
|
||||
}
|
||||
return { ok, latencyMs, error };
|
||||
} catch (err) {
|
||||
return { ok: false, latencyMs: Date.now() - start, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/providers/[id]/test-models
|
||||
* id = connectionId — used only to resolve provider + model list.
|
||||
* Actual requests go through /api/v1/chat/completions (open-sse handles everything).
|
||||
*/
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const providerId = connection.provider;
|
||||
const isCompatible = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
|
||||
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
||||
|
||||
let models = getProviderModels(alias);
|
||||
|
||||
// Compatible providers: fetch live model list
|
||||
if (isCompatible && models.length === 0) {
|
||||
try {
|
||||
const modelsRes = await fetch(`${getBaseUrl(request)}/api/providers/${id}/models`);
|
||||
if (modelsRes.ok) {
|
||||
const data = await modelsRes.json();
|
||||
models = (data.models || []).map((m) => ({ id: m.id || m.name, name: m.name || m.id }));
|
||||
}
|
||||
} catch { /* fallback to empty */ }
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl(request);
|
||||
const apiKey = await getInternalApiKey();
|
||||
|
||||
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
|
||||
// This prevents race condition where multiple requests concurrently refresh the same token.
|
||||
const [first, ...rest] = models;
|
||||
const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey);
|
||||
const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }];
|
||||
|
||||
if (rest.length > 0) {
|
||||
const restResults = await Promise.all(
|
||||
rest.map(async (model) => {
|
||||
const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey);
|
||||
return { modelId: model.id, name: model.name || model.id, ...result };
|
||||
})
|
||||
);
|
||||
results.push(...restResults);
|
||||
}
|
||||
|
||||
return NextResponse.json({ provider: providerId, connectionId: id, results });
|
||||
} catch (error) {
|
||||
console.log("Error testing models:", error);
|
||||
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(request) {
|
||||
const url = new URL(request.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
}
|
||||
Reference in New Issue
Block a user