mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Fix bug
This commit is contained in:
@@ -328,6 +328,13 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
|
||||
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
|
||||
],
|
||||
oc: [ // OpenCode
|
||||
{ id: "nemotron-3-super-free", name: "Nemotron 3 Super" },
|
||||
{ id: "qwen3.6-plus-free", name: "Qwen 3.6 Plus" },
|
||||
// { id: "big-pickle", name: "Big Pickle", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5-free", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
],
|
||||
|
||||
cl: [ // Cline
|
||||
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
@@ -589,6 +596,7 @@ const OAUTH_ALIASES = {
|
||||
"kimi-coding": "kmc",
|
||||
kilocode: "kc",
|
||||
cline: "cl",
|
||||
opencode: "oc",
|
||||
vertex: "vertex",
|
||||
"vertex-partner": "vertex-partner",
|
||||
};
|
||||
|
||||
@@ -332,4 +332,9 @@ export const PROVIDERS = {
|
||||
baseUrl: "https://copilot.tencent.com/v1/chat/completions",
|
||||
format: "openai",
|
||||
},
|
||||
opencode: {
|
||||
baseUrl: "https://opencode.ai",
|
||||
format: "openai",
|
||||
headers: { "x-opencode-client": "desktop" }
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CodexExecutor } from "./codex.js";
|
||||
import { CursorExecutor } from "./cursor.js";
|
||||
import { VertexExecutor } from "./vertex.js";
|
||||
import { QwenExecutor } from "./qwen.js";
|
||||
import { OpenCodeExecutor } from "./opencode.js";
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
|
||||
const executors = {
|
||||
@@ -23,6 +24,7 @@ const executors = {
|
||||
vertex: new VertexExecutor("vertex"),
|
||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||
qwen: new QwenExecutor(),
|
||||
opencode: new OpenCodeExecutor(),
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -49,3 +51,4 @@ export { CursorExecutor } from "./cursor.js";
|
||||
export { VertexExecutor } from "./vertex.js";
|
||||
export { DefaultExecutor } from "./default.js";
|
||||
export { QwenExecutor } from "./qwen.js";
|
||||
export { OpenCodeExecutor } from "./opencode.js";
|
||||
|
||||
27
open-sse/executors/opencode.js
Normal file
27
open-sse/executors/opencode.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
|
||||
// Models that use /zen/v1/messages (claude format)
|
||||
const MESSAGES_MODELS = new Set(["big-pickle", "minimax-m2.5-free"]);
|
||||
|
||||
export class OpenCodeExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("opencode", PROVIDERS.opencode);
|
||||
}
|
||||
|
||||
buildUrl(model) {
|
||||
const base = "https://opencode.ai";
|
||||
return MESSAGES_MODELS.has(model)
|
||||
? `${base}/zen/v1/messages`
|
||||
: `${base}/zen/v1/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders() {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer public",
|
||||
"x-opencode-client": "desktop",
|
||||
"Accept": "text/event-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export default function OpenClawToolCard({
|
||||
const [message, setMessage] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [agentModels, setAgentModels] = useState({}); // { [agentId]: modelId }
|
||||
const [agentModalFor, setAgentModalFor] = useState(null); // agentId opening modal
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
@@ -74,14 +76,18 @@ export default function OpenClawToolCard({
|
||||
const provider = openclawStatus.settings?.models?.providers?.["9router"];
|
||||
if (provider) {
|
||||
const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary;
|
||||
if (primaryModel) {
|
||||
const modelId = primaryModel.replace("9router/", "");
|
||||
setSelectedModel(modelId);
|
||||
}
|
||||
if (primaryModel) setSelectedModel(primaryModel.replace("9router/", ""));
|
||||
if (provider.apiKey && apiKeys?.some(k => k.key === provider.apiKey)) {
|
||||
setSelectedApiKey(provider.apiKey);
|
||||
}
|
||||
}
|
||||
// Init per-agent models from enriched agents list
|
||||
const agentList = openclawStatus.agents || [];
|
||||
const initAgentModels = {};
|
||||
agentList.forEach((agent) => {
|
||||
if (agent.currentModel) initAgentModels[agent.id] = agent.currentModel;
|
||||
});
|
||||
setAgentModels(initAgentModels);
|
||||
}
|
||||
}, [openclawStatus, apiKeys]);
|
||||
|
||||
@@ -131,7 +137,8 @@ export default function OpenClawToolCard({
|
||||
body: JSON.stringify({
|
||||
baseUrl: getEffectiveBaseUrl(),
|
||||
apiKey: keyToUse,
|
||||
model: selectedModel
|
||||
model: selectedModel,
|
||||
agentModels,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -170,7 +177,12 @@ export default function OpenClawToolCard({
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
if (agentModalFor) {
|
||||
setAgentModels(prev => ({ ...prev, [agentModalFor]: model.value }));
|
||||
setAgentModalFor(null);
|
||||
} else {
|
||||
setSelectedModel(model.value);
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
@@ -298,14 +310,31 @@ export default function OpenClawToolCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
{/* Default Model */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Model</span>
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Default Model</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
|
||||
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
|
||||
<button onClick={() => { setAgentModalFor(null); setModalOpen(true); }} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
||||
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||
</div>
|
||||
|
||||
{/* Per-agent model overrides */}
|
||||
{(openclawStatus.agents || []).filter(a => a.agentDir).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 pl-4">
|
||||
<span className="w-32 shrink-0 text-xs text-primary text-right truncate" title={agent.name || agent.id}>Agent {agent.name || agent.id}</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<input
|
||||
type="text"
|
||||
value={agentModels[agent.id] || ""}
|
||||
onChange={(e) => setAgentModels(prev => ({ ...prev, [agent.id]: e.target.value }))}
|
||||
placeholder={`default (${selectedModel || "provider/model-id"})`}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
<button onClick={() => { setAgentModalFor(agent.id); setModalOpen(true); }} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
||||
{agentModels[agent.id] && <button onClick={() => setAgentModels(prev => ({ ...prev, [agent.id]: "" }))} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
|
||||
@@ -472,7 +472,7 @@ export default function APIPageClient({ machineId }) {
|
||||
<Input
|
||||
value={currentEndpoint}
|
||||
readOnly
|
||||
className={`flex-1 font-mono text-sm ${tunnelEnabled ? "animate-border-glow" : ""}`}
|
||||
className={`flex-1 font-mono text-sm`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -483,6 +483,20 @@ export default function APIPageClient({ machineId }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Direct local endpoint */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-text-muted shrink-0">Direct</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[12px]">arrow_forward</span>
|
||||
<code className="flex-1 text-xs text-text-muted font-mono truncate">{baseUrl}/chat/completions</code>
|
||||
<button
|
||||
onClick={() => copy(`${baseUrl}/chat/completions`, "direct_url")}
|
||||
className="p-1 text-text-muted hover:text-primary transition-colors shrink-0"
|
||||
title="Copy direct endpoint"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copied === "direct_url" ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tunnel Status */}
|
||||
{tunnelStatus && (
|
||||
<div className={`mt-3 p-2 rounded text-sm ${
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId]);
|
||||
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId];
|
||||
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
|
||||
const models = getModelsByProviderId(providerId);
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
|
||||
@@ -588,7 +589,7 @@ export default function ProviderDetailPage() {
|
||||
onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)}
|
||||
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isFree={model.isFree}
|
||||
/>
|
||||
@@ -607,7 +608,7 @@ export default function ProviderDetailPage() {
|
||||
onSetAlias={() => {}}
|
||||
onDeleteAlias={() => handleDeleteAlias(model.alias)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
|
||||
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isCustom
|
||||
isFree={false}
|
||||
@@ -808,80 +809,94 @@ export default function ProviderDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Connections */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
{/* Round Robin toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
<Toggle
|
||||
checked={providerStrategy === "round-robin"}
|
||||
onChange={handleRoundRobinToggle}
|
||||
/>
|
||||
{providerStrategy === "round-robin" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-text-muted">Sticky:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={providerStickyLimit}
|
||||
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
||||
placeholder="1"
|
||||
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">{isOAuth ? "lock" : "key"}</span>
|
||||
{isFreeNoAuth ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">This provider is ready to use.</p>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
||||
{!isCompatible && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
{providerId === "iflow" && (
|
||||
<Button icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
|
||||
Cookie Auth
|
||||
</Button>
|
||||
)}
|
||||
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
||||
{providerId === "iflow" ? "OAuth" : "Add Connection"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{connectionsList}
|
||||
{!isCompatible && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
{providerId === "iflow" && (
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
{/* Round Robin toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
<Toggle
|
||||
checked={providerStrategy === "round-robin"}
|
||||
onChange={handleRoundRobinToggle}
|
||||
/>
|
||||
{providerStrategy === "round-robin" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-text-muted">Sticky:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={providerStickyLimit}
|
||||
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
||||
placeholder="1"
|
||||
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">{isOAuth ? "lock" : "key"}</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
||||
{!isCompatible && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
{providerId === "iflow" && (
|
||||
<Button icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
|
||||
Cookie Auth
|
||||
</Button>
|
||||
)}
|
||||
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
||||
{providerId === "iflow" ? "OAuth" : "Add Connection"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{connectionsList}
|
||||
{!isCompatible && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
{providerId === "iflow" && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon="cookie"
|
||||
variant="secondary"
|
||||
onClick={() => setShowIFlowCookieModal(true)}
|
||||
title="Add connection using browser cookie"
|
||||
>
|
||||
Cookie
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
icon="cookie"
|
||||
variant="secondary"
|
||||
onClick={() => setShowIFlowCookieModal(true)}
|
||||
title="Add connection using browser cookie"
|
||||
icon="add"
|
||||
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
||||
>
|
||||
Cookie
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Models */}
|
||||
<Card>
|
||||
|
||||
@@ -502,6 +502,7 @@ export default function ProvidersPage() {
|
||||
|
||||
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const isNoAuth = !!provider.noAuth;
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -553,6 +554,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
Disabled
|
||||
</span>
|
||||
</Badge>
|
||||
) : isNoAuth ? (
|
||||
<Badge variant="success" size="sm" dot>Ready</Badge>
|
||||
) : (
|
||||
<>
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
|
||||
@@ -16,15 +16,23 @@ const getClaudeSettingsPath = () => {
|
||||
};
|
||||
|
||||
|
||||
// Check if claude CLI is installed
|
||||
// Check if claude CLI is installed (via which/where or config file exists)
|
||||
const checkClaudeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where claude" : "command -v claude";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
const command = isWindows ? "where claude" : "which claude";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
try {
|
||||
await fs.access(getClaudeSettingsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -41,15 +41,23 @@ const deleteNestedSection = (obj, dottedKey) => {
|
||||
delete cur[keys[keys.length - 1]];
|
||||
};
|
||||
|
||||
// Check if codex CLI is installed
|
||||
// Check if codex CLI is installed (via which/where or config file exists)
|
||||
const checkCodexInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where codex" : "command -v codex";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
const command = isWindows ? "where codex" : "which codex";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
try {
|
||||
await fs.access(getCodexConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,15 +12,23 @@ const execAsync = promisify(exec);
|
||||
const getDroidDir = () => path.join(os.homedir(), ".factory");
|
||||
const getDroidSettingsPath = () => path.join(getDroidDir(), "settings.json");
|
||||
|
||||
// Check if droid CLI is installed
|
||||
// Check if droid CLI is installed (via which/where or config file exists)
|
||||
const checkDroidInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where droid" : "command -v droid";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
const command = isWindows ? "where droid" : "which droid";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
try {
|
||||
await fs.access(getDroidSettingsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,15 +12,24 @@ const execAsync = promisify(exec);
|
||||
const getOpenClawDir = () => path.join(os.homedir(), ".openclaw");
|
||||
const getOpenClawSettingsPath = () => path.join(getOpenClawDir(), "openclaw.json");
|
||||
|
||||
// Check if openclaw CLI is installed
|
||||
// Check if openclaw CLI is installed (via which/where or config file exists)
|
||||
const checkOpenClawInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where openclaw" : "which openclaw";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
// On Windows, inject %APPDATA%\npm into PATH so npm global packages are found
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
try {
|
||||
await fs.access(getOpenClawSettingsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,6 +51,19 @@ const has9RouterConfig = (settings) => {
|
||||
return !!settings.models.providers["9router"];
|
||||
};
|
||||
|
||||
// Read per-agent models.json and return current model id (without "9router/" prefix)
|
||||
const readAgentModel = async (agentDir) => {
|
||||
try {
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
const content = await fs.readFile(modelsPath, "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
const models = data?.providers?.["9router"]?.models;
|
||||
return models?.[0]?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// GET - Check openclaw CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -57,9 +79,19 @@ export async function GET() {
|
||||
|
||||
const settings = await readSettings();
|
||||
|
||||
// Enrich agents list with current per-agent model from models.json
|
||||
const agentList = settings?.agents?.list || [];
|
||||
const enrichedAgents = await Promise.all(
|
||||
agentList.map(async (agent) => {
|
||||
const agentModel = agent.agentDir ? await readAgentModel(agent.agentDir) : null;
|
||||
return { ...agent, currentModel: agentModel };
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings,
|
||||
agents: enrichedAgents,
|
||||
has9Router: has9RouterConfig(settings),
|
||||
settingsPath: getOpenClawSettingsPath(),
|
||||
});
|
||||
@@ -69,10 +101,31 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
// Write per-agent models.json
|
||||
const writeAgentModels = async (agentDir, model, baseUrl, apiKey) => {
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
let existing = {};
|
||||
try {
|
||||
const content = await fs.readFile(modelsPath, "utf-8");
|
||||
existing = JSON.parse(content);
|
||||
} catch { /* No existing */ }
|
||||
|
||||
if (!existing.providers) existing.providers = {};
|
||||
existing.providers["9router"] = {
|
||||
baseUrl,
|
||||
apiKey: apiKey || "your_api_key",
|
||||
api: "openai-completions",
|
||||
models: [{ id: model, name: model.split("/").pop() || model }],
|
||||
};
|
||||
await fs.writeFile(modelsPath, JSON.stringify(existing, null, 2));
|
||||
};
|
||||
|
||||
// POST - Update 9Router settings (merge with existing settings)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
// agentModels: { [agentId]: modelId } for per-agent override
|
||||
const { baseUrl, apiKey, model, agentModels = {} } = await request.json();
|
||||
|
||||
if (!baseUrl || !model) {
|
||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
||||
@@ -81,17 +134,14 @@ export async function POST(request) {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(openclawDir, { recursive: true });
|
||||
|
||||
// Read existing settings or create new
|
||||
let settings = {};
|
||||
try {
|
||||
const existingSettings = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(existingSettings);
|
||||
} catch { /* No existing settings */ }
|
||||
|
||||
// Ensure structure exists
|
||||
if (!settings.agents) settings.agents = {};
|
||||
if (!settings.agents.defaults) settings.agents.defaults = {};
|
||||
if (!settings.agents.defaults.model) settings.agents.defaults.model = {};
|
||||
@@ -99,32 +149,64 @@ export async function POST(request) {
|
||||
if (!settings.models) settings.models = {};
|
||||
if (!settings.models.providers) settings.models.providers = {};
|
||||
|
||||
// Normalize baseUrl to ensure /v1 suffix
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
|
||||
// Update agents.defaults.model.primary
|
||||
const fullModelId = `9router/${model}`;
|
||||
|
||||
// Remove all old 9router/* entries from agents.defaults.models
|
||||
Object.keys(settings.agents.defaults.models)
|
||||
.filter((k) => k.startsWith("9router/"))
|
||||
.forEach((k) => { delete settings.agents.defaults.models[k]; });
|
||||
|
||||
// Update default model
|
||||
settings.agents.defaults.model.primary = fullModelId;
|
||||
|
||||
// IMPORTANT: Add to allowlist in agents.defaults.models
|
||||
if (!settings.agents.defaults.models[fullModelId]) {
|
||||
settings.agents.defaults.models[fullModelId] = {};
|
||||
// Collect all unique models (default + per-agent)
|
||||
const allModelIds = new Set([model]);
|
||||
Object.values(agentModels).forEach((m) => { if (m) allModelIds.add(m); });
|
||||
|
||||
// Add fresh 9router models to allowlist
|
||||
allModelIds.forEach((m) => {
|
||||
settings.agents.defaults.models[`9router/${m}`] = {};
|
||||
});
|
||||
|
||||
// Remove old 9router model from each agent in agents.list
|
||||
if (settings.agents.list) {
|
||||
settings.agents.list = settings.agents.list.map((agent) => {
|
||||
if (agent.model?.startsWith("9router/")) {
|
||||
const { model: _, ...rest } = agent;
|
||||
return rest;
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
|
||||
// Update models.providers.9router
|
||||
// Update models.providers.9router with all models
|
||||
settings.models.providers["9router"] = {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: apiKey || "your_api_key",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
name: model.split("/").pop() || model,
|
||||
},
|
||||
],
|
||||
models: [...allModelIds].map((m) => ({ id: m, name: m.split("/").pop() || m })),
|
||||
};
|
||||
|
||||
// Write settings
|
||||
// Set per-agent model in agents.list and write models.json
|
||||
if (settings.agents.list) {
|
||||
settings.agents.list = settings.agents.list.map((agent) => {
|
||||
const agentModel = agentModels[agent.id];
|
||||
if (agentModel) return { ...agent, model: `9router/${agentModel}` };
|
||||
return agent;
|
||||
});
|
||||
|
||||
// Write per-agent models.json for agents with agentDir
|
||||
await Promise.all(
|
||||
settings.agents.list.map(async (agent) => {
|
||||
if (!agent.agentDir) return;
|
||||
const agentModel = agentModels[agent.id];
|
||||
const modelToWrite = agentModel || model; // fallback to default
|
||||
await writeAgentModels(agent.agentDir, modelToWrite, normalizedBaseUrl, apiKey);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -12,14 +12,23 @@ const execAsync = promisify(exec);
|
||||
const getConfigDir = () => path.join(os.homedir(), ".config", "opencode");
|
||||
const getConfigPath = () => path.join(getConfigDir(), "opencode.json");
|
||||
|
||||
// Check if opencode CLI is installed (via which/where or config file exists)
|
||||
const checkOpenCodeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where opencode" : "command -v opencode";
|
||||
await execAsync(command, { windowsHide: true });
|
||||
const command = isWindows ? "where opencode" : "which opencode";
|
||||
const env = isWindows
|
||||
? { ...process.env, PATH: `${process.env.APPDATA}\\npm;${process.env.PATH}` }
|
||||
: process.env;
|
||||
await execAsync(command, { windowsHide: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
try {
|
||||
await fs.access(getConfigPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -122,7 +122,10 @@ async function scheduleReconnect(attempt) {
|
||||
isReconnecting = false;
|
||||
const nextAttempt = attempt + 1;
|
||||
if (nextAttempt < MAX_RECONNECT_ATTEMPTS) scheduleReconnect(nextAttempt);
|
||||
else console.log("[Tunnel] All reconnect attempts exhausted");
|
||||
else {
|
||||
console.log("[Tunnel] All reconnect attempts exhausted, disabling tunnel");
|
||||
await updateSettings({ tunnelEnabled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ import { useState, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Modal from "./Modal";
|
||||
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_TIER_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
// Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers)
|
||||
const PROVIDER_ORDER = [
|
||||
...Object.keys(OAUTH_PROVIDERS),
|
||||
...Object.keys(FREE_PROVIDERS),
|
||||
...Object.keys(FREE_TIER_PROVIDERS),
|
||||
...Object.keys(APIKEY_PROVIDERS),
|
||||
];
|
||||
|
||||
// Providers that need no auth — always show in model selector
|
||||
const NO_AUTH_PROVIDER_IDS = Object.keys(FREE_PROVIDERS).filter(id => FREE_PROVIDERS[id].noAuth);
|
||||
|
||||
export default function ModelSelectModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -58,7 +62,7 @@ export default function ModelSelectModal({
|
||||
if (isOpen) fetchProviderNodes();
|
||||
}, [isOpen]);
|
||||
|
||||
const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []);
|
||||
const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []);
|
||||
|
||||
// Group models by provider with priority order
|
||||
const groupedModels = useMemo(() => {
|
||||
@@ -70,6 +74,7 @@ export default function ModelSelectModal({
|
||||
// Only show connected providers (including both standard and custom)
|
||||
const providerIdsToShow = new Set([
|
||||
...activeConnectionIds, // Only connected providers
|
||||
...NO_AUTH_PROVIDER_IDS, // No-auth providers always visible
|
||||
]);
|
||||
|
||||
// Sort by PROVIDER_ORDER
|
||||
|
||||
@@ -23,11 +23,11 @@ const navItems = [
|
||||
|
||||
const debugItems = [
|
||||
{ href: "/dashboard/console-log", label: "Console Log", icon: "terminal" },
|
||||
{ href: "/dashboard/translator", label: "Translator", icon: "translate" },
|
||||
];
|
||||
|
||||
const systemItems = [
|
||||
{ href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan" },
|
||||
{ href: "/dashboard/profile", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export default function Sidebar({ onClose }) {
|
||||
@@ -171,52 +171,6 @@ export default function Sidebar({ onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug section */}
|
||||
<div className="pt-4 mt-2">
|
||||
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
|
||||
Debug
|
||||
</p>
|
||||
{enableTranslator && (
|
||||
<Link
|
||||
href="/dashboard/translator"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
isActive("/dashboard/translator")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className={cn("material-symbols-outlined text-[18px]", isActive("/dashboard/translator") ? "fill-1" : "group-hover:text-primary transition-colors")}>
|
||||
translate
|
||||
</span>
|
||||
<span className="text-sm font-medium">Translator</span>
|
||||
</Link>
|
||||
)}
|
||||
{debugItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined text-[18px]",
|
||||
isActive(item.href) ? "fill-1" : "group-hover:text-primary transition-colors"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* System section */}
|
||||
<div className="pt-4 mt-2">
|
||||
@@ -246,23 +200,61 @@ export default function Sidebar({ onClose }) {
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Debug items (inside System section, before Settings) */}
|
||||
{debugItems.map((item) => {
|
||||
const show = item.href !== "/dashboard/translator" || enableTranslator;
|
||||
return show ? (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined text-[18px]",
|
||||
isActive(item.href) ? "fill-1" : "group-hover:text-primary transition-colors"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
{/* Settings */}
|
||||
<Link
|
||||
href="/dashboard/profile"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
isActive("/dashboard/profile")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined text-[18px]",
|
||||
isActive("/dashboard/profile") ? "fill-1" : "group-hover:text-primary transition-colors"
|
||||
)}
|
||||
>
|
||||
settings
|
||||
</span>
|
||||
<span className="text-sm font-medium">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer section */}
|
||||
<div className="p-3 border-t border-black/5 dark:border-white/5">
|
||||
{/* Info message */}
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-surface/50 mb-2">
|
||||
<div className="flex items-center justify-center size-6 rounded-md bg-blue-500/10 text-blue-500 shrink-0 mt-0.5">
|
||||
<span className="material-symbols-outlined text-[14px]">info</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-text-main leading-relaxed">
|
||||
Service is running in terminal. You can close this web page. Shutdown will stop the service.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shutdown button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -9,6 +9,7 @@ export const FREE_PROVIDERS = {
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
// qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
|
||||
opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true },
|
||||
};
|
||||
|
||||
// Free Tier Providers (has free access but may require account/API key)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
|
||||
import { resolveProviderId } from "@/shared/constants/providers.js";
|
||||
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
// Mutex to prevent race conditions during account selection
|
||||
@@ -30,6 +30,11 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
// Resolve alias to provider ID (e.g., "kc" -> "kilocode")
|
||||
const providerId = resolveProviderId(provider);
|
||||
|
||||
// Inject a virtual connection for no-auth free providers
|
||||
if (FREE_PROVIDERS[providerId]?.noAuth) {
|
||||
return { id: "noauth", connectionName: "Public", isActive: true, accessToken: "public" };
|
||||
}
|
||||
|
||||
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
||||
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
|
||||
|
||||
@@ -164,6 +169,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
* @returns {{ shouldFallback: boolean, cooldownMs: number }}
|
||||
*/
|
||||
export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null) {
|
||||
if (!connectionId || connectionId === "noauth") return { shouldFallback: false, cooldownMs: 0 };
|
||||
const connections = await getProviderConnections({ provider });
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
const backoffLevel = conn?.backoffLevel || 0;
|
||||
@@ -204,6 +210,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||
* @param {string|null} model - model that succeeded
|
||||
*/
|
||||
export async function clearAccountError(connectionId, currentConnection, model = null) {
|
||||
if (!connectionId || connectionId === "noauth") return;
|
||||
const conn = currentConnection._connection || currentConnection;
|
||||
const now = Date.now();
|
||||
const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_"));
|
||||
|
||||
Reference in New Issue
Block a user