feat: add OllamaLocalExecutor and update provider handling

- Introduced OllamaLocalExecutor to handle requests for the "ollama-local" provider.
- Removed the direct URL construction for "ollama-local" from BaseExecutor.
- Updated index.js to include the new OllamaLocalExecutor in the executors mapping.
- Enhanced the ProvidersPage component to support dynamic addition of OpenAI/Anthropic compatible providers.
This commit is contained in:
decolua
2026-05-07 16:42:36 +07:00
parent 050e56f20b
commit 0d61a1d546
8 changed files with 127 additions and 94 deletions

View File

@@ -1,5 +1,4 @@
import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js";
import { resolveOllamaLocalHost } from "../config/providers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
/** /**
@@ -36,9 +35,6 @@ export class BaseExecutor {
const normalized = baseUrl.replace(/\/$/, ""); const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`; return `${normalized}/messages`;
} }
if (this.provider === "ollama-local") {
return `${resolveOllamaLocalHost(credentials)}/api/chat`;
}
const baseUrls = this.getBaseUrls(); const baseUrls = this.getBaseUrls();
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
} }

View File

@@ -13,6 +13,7 @@ import { OpenCodeExecutor } from "./opencode.js";
import { OpenCodeGoExecutor } from "./opencode-go.js"; import { OpenCodeGoExecutor } from "./opencode-go.js";
import { GrokWebExecutor } from "./grok-web.js"; import { GrokWebExecutor } from "./grok-web.js";
import { PerplexityWebExecutor } from "./perplexity-web.js"; import { PerplexityWebExecutor } from "./perplexity-web.js";
import { OllamaLocalExecutor } from "./ollama-local.js";
import { DefaultExecutor } from "./default.js"; import { DefaultExecutor } from "./default.js";
const executors = { const executors = {
@@ -33,6 +34,7 @@ const executors = {
"opencode-go": new OpenCodeGoExecutor(), "opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(), "grok-web": new GrokWebExecutor(),
"perplexity-web": new PerplexityWebExecutor(), "perplexity-web": new PerplexityWebExecutor(),
"ollama-local": new OllamaLocalExecutor(),
}; };
const defaultCache = new Map(); const defaultCache = new Map();
@@ -64,3 +66,4 @@ export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js"; export { OpenCodeGoExecutor } from "./opencode-go.js";
export { GrokWebExecutor } from "./grok-web.js"; export { GrokWebExecutor } from "./grok-web.js";
export { PerplexityWebExecutor } from "./perplexity-web.js"; export { PerplexityWebExecutor } from "./perplexity-web.js";
export { OllamaLocalExecutor } from "./ollama-local.js";

View File

@@ -0,0 +1,14 @@
import { DefaultExecutor } from "./default.js";
import { resolveOllamaLocalHost } from "../config/providers.js";
export class OllamaLocalExecutor extends DefaultExecutor {
constructor() {
super("ollama-local");
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
return `${resolveOllamaLocalHost(credentials)}/api/chat`;
}
}
export default OllamaLocalExecutor;

View File

@@ -292,6 +292,58 @@ export default function ProvidersPage() {
</div> </div>
)} )}
{/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
Custom Providers (OpenAI/Anthropic Compatible){" "}
</h2>
<div className="grid grid-cols-1 gap-2 sm:flex sm:w-auto">
<Button
size="sm"
icon="add"
onClick={() => setShowAddAnthropicCompatibleModal(true)}
className="w-full sm:w-auto"
>
Add Anthropic Compatible
</Button>
<Button
size="sm"
variant="secondary"
icon="add"
onClick={() => setShowAddCompatibleModal(true)}
className="w-full !bg-white !text-black hover:!bg-gray-100 sm:w-auto"
>
Add OpenAI Compatible
</Button>
</div>
</div>
{compatibleProviders.length === 0 &&
anthropicCompatibleProviders.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-2 border border-dashed border-border rounded-xl text-text-muted text-sm">
<span className="material-symbols-outlined text-[18px]">extension</span>
<span>No custom providers use buttons above to add OpenAI/Anthropic compatible endpoints</span>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{[...compatibleProviders, ...anthropicCompatibleProviders].map(
(info) => (
<ApiKeyProviderCard
key={info.id}
providerId={info.id}
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
onToggle={(active) =>
handleToggleProvider(info.id, "apikey", active)
}
/>
),
)}
</div>
)}
</div>
{/* OAuth Providers */} {/* OAuth Providers */}
{oauthEntries.length > 0 && ( {oauthEntries.length > 0 && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -449,82 +501,6 @@ export default function ProvidersPage() {
</div> </div>
</div> */} </div> */}
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
API Key Compatible Providers{" "}
</h2>
<div className="grid grid-cols-1 gap-2 sm:flex sm:w-auto">
{/* {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
<button
onClick={() => handleBatchTest("compatible")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "compatible"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
title="Test all Compatible connections"
>
<span className={`material-symbols-outlined text-[14px]${testingMode === "compatible" ? " animate-spin" : ""}`}>
play_arrow
</span>
{testingMode === "compatible" ? "Testing..." : "Test All"}
</button>
)} */}
<Button
size="sm"
icon="add"
onClick={() => setShowAddAnthropicCompatibleModal(true)}
className="w-full sm:w-auto"
>
Add Anthropic Compatible
</Button>
<Button
size="sm"
variant="secondary"
icon="add"
onClick={() => setShowAddCompatibleModal(true)}
className="w-full !bg-white !text-black hover:!bg-gray-100 sm:w-auto"
>
Add OpenAI Compatible
</Button>
</div>
</div>
{compatibleProviders.length === 0 &&
anthropicCompatibleProviders.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-xl">
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
extension
</span>
<p className="text-text-muted text-sm">
No compatible providers added yet
</p>
<p className="text-text-muted text-xs mt-1">
Use the buttons above to add OpenAI or Anthropic compatible
endpoints
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{[...compatibleProviders, ...anthropicCompatibleProviders].map(
(info) => (
<ApiKeyProviderCard
key={info.id}
providerId={info.id}
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
onToggle={(active) =>
handleToggleProvider(info.id, "apikey", active)
}
/>
),
)}
</div>
)}
</div>
<AddOpenAICompatibleModal <AddOpenAICompatibleModal
isOpen={showAddCompatibleModal} isOpen={showAddCompatibleModal}
onClose={() => setShowAddCompatibleModal(false)} onClose={() => setShowAddCompatibleModal(false)}

View File

@@ -6,6 +6,7 @@ import {
ReactFlow, ReactFlow,
Handle, Handle,
Position, Position,
Controls,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { AI_PROVIDERS } from "@/shared/constants/providers"; import { AI_PROVIDERS } from "@/shared/constants/providers";
@@ -250,13 +251,34 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
); );
const rfInstance = useRef(null); const rfInstance = useRef(null);
const containerRef = useRef(null);
const fitOpts = { padding: 0.2, duration: 200 };
const onInit = useCallback((instance) => { const onInit = useCallback((instance) => {
rfInstance.current = instance; rfInstance.current = instance;
setTimeout(() => instance.fitView({ padding: 0.3 }), 50); setTimeout(() => instance.fitView(fitOpts), 50);
}, []); }, []);
// Re-fit on container resize
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
if (rfInstance.current) rfInstance.current.fitView(fitOpts);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Re-fit when node count/layout changes
useEffect(() => {
if (rfInstance.current) {
const id = setTimeout(() => rfInstance.current.fitView(fitOpts), 50);
return () => clearTimeout(id);
}
}, [nodes.length]);
return ( return (
<div className="h-[320px] w-full min-w-0 rounded-lg border border-border bg-bg-subtle/30 sm:h-[480px]"> <div ref={containerRef} className="h-[320px] w-full min-w-0 rounded-lg border border-border bg-bg-subtle/30 sm:h-[480px]">
{providers.length === 0 ? ( {providers.length === 0 ? (
<div className="h-full flex items-center justify-center text-text-muted text-sm"> <div className="h-full flex items-center justify-center text-text-muted text-sm">
No providers connected No providers connected
@@ -268,18 +290,22 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
fitViewOptions={{ padding: 0.3 }} fitViewOptions={fitOpts}
minZoom={0.1}
maxZoom={2}
onInit={onInit} onInit={onInit}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
panOnDrag={false} panOnDrag
zoomOnScroll={false} zoomOnScroll
zoomOnPinch={false} zoomOnPinch
zoomOnDoubleClick={false} zoomOnDoubleClick
preventScrolling={false} preventScrolling={false}
nodesDraggable={false} nodesDraggable={false}
nodesConnectable={false} nodesConnectable={false}
elementsSelectable={false} elementsSelectable={false}
/> >
<Controls showInteractive={false} />
</ReactFlow>
)} )}
</div> </div>
); );

View File

@@ -3,7 +3,7 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const os = require("os"); const os = require("os");
const { log, err } = require("../logger"); const { log, err } = require("../logger");
const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts"); const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts.js");
const { runElevatedPowerShell, isAdmin } = require("../winElevated.js"); const { runElevatedPowerShell, isAdmin } = require("../winElevated.js");
/** /**

View File

@@ -549,6 +549,15 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
} }
// Step 2: Spawn server (Root CA already installed in Step 1.5) // Step 2: Spawn server (Root CA already installed in Step 1.5)
// Verify server.js exists — recopy if runtime file was deleted (antivirus/cleanup)
let effectiveServerPath = SERVER_PATH;
if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) {
log(`[MITM] server.js missing at ${effectiveServerPath} → recopying`);
effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath());
if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) {
throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`);
}
}
const mitmRouterBase = await resolveMitmRouterBaseUrl(); const mitmRouterBase = await resolveMitmRouterBaseUrl();
log(`🚀 Starting server... (router: ${mitmRouterBase})`); log(`🚀 Starting server... (router: ${mitmRouterBase})`);
if (IS_WIN) { if (IS_WIN) {
@@ -569,7 +578,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
// Spawn directly — process already has admin rights // Spawn directly — process already has admin rights
serverProcess = spawn( serverProcess = spawn(
process.execPath, process.execPath,
[SERVER_PATH], [effectiveServerPath],
{ {
detached: false, detached: false,
windowsHide: true, windowsHide: true,
@@ -593,7 +602,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
`MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`, `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`,
"NODE_ENV=production", "NODE_ENV=production",
shellQuoteSingle(process.execPath), shellQuoteSingle(process.execPath),
shellQuoteSingle(SERVER_PATH), shellQuoteSingle(effectiveServerPath),
].join(" "); ].join(" ");
serverProcess = spawn( serverProcess = spawn(
"sudo", ["-S", "-E", "sh", "-c", inlineCmd], "sudo", ["-S", "-E", "sh", "-c", inlineCmd],
@@ -603,7 +612,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
serverProcess.stdin.end(); serverProcess.stdin.end();
} else { } else {
// Docker/minimal images: no sudo — same as Windows-style direct spawn // Docker/minimal images: no sudo — same as Windows-style direct spawn
serverProcess = spawn(process.execPath, [SERVER_PATH], { serverProcess = spawn(process.execPath, [effectiveServerPath], {
detached: false, detached: false,
windowsHide: true, windowsHide: true,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],

View File

@@ -2,7 +2,14 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { FREE_PROVIDERS } from "@/shared/constants/providers"; import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers";
// Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds
function isLLMProvider(id) {
const p = AI_PROVIDERS[id];
if (!p?.serviceKinds) return true;
return p.serviceKinds.includes("llm");
}
import Badge from "./Badge"; import Badge from "./Badge";
import Card from "./Card"; import Card from "./Card";
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
@@ -206,12 +213,14 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
.then((d) => { .then((d) => {
const seen = new Set(); const seen = new Set();
const unique = (d?.connections || []).filter((c) => { const unique = (d?.connections || []).filter((c) => {
if (c.isActive === false) return false;
if (!isLLMProvider(c.provider)) return false;
if (seen.has(c.provider)) return false; if (seen.has(c.provider)) return false;
seen.add(c.provider); seen.add(c.provider);
return true; return true;
}); });
const noAuthProviders = Object.values(FREE_PROVIDERS) const noAuthProviders = Object.values(FREE_PROVIDERS)
.filter((p) => p.noAuth && !seen.has(p.id)) .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id))
.map((p) => ({ provider: p.id, name: p.name })); .map((p) => ({ provider: p.id, name: p.name }));
setProviders([...unique, ...noAuthProviders]); setProviders([...unique, ...noAuthProviders]);
}) })