mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
14
open-sse/executors/ollama-local.js
Normal file
14
open-sse/executors/ollama-local.js
Normal 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;
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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]);
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user