refactor(gateway): migrate from child_process.spawn to utilityProcess.fork (#290)

This commit is contained in:
paisley
2026-03-04 20:56:19 +08:00
committed by GitHub
Unverified
parent 76df84e68c
commit 52748d78b5
4 changed files with 162 additions and 232 deletions

View File

@@ -2,9 +2,8 @@
* Gateway Process Manager
* Manages the OpenClaw Gateway process lifecycle
*/
import { app } from 'electron';
import { app, utilityProcess } from 'electron';
import path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { existsSync, writeFileSync } from 'fs';
import WebSocket from 'ws';
@@ -12,10 +11,8 @@ import { PORTS } from '../utils/config';
import {
getOpenClawDir,
getOpenClawEntryPath,
isOpenClawBuilt,
isOpenClawPresent,
appendNodeRequireToNodeOptions,
quoteForCmd,
} from '../utils/paths';
import { getAllSettings, getSetting } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
@@ -83,38 +80,8 @@ const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxDelay: 30000,
};
/**
* Get the Node.js-compatible executable path for spawning child processes.
*
* On macOS in packaged mode, using `process.execPath` directly causes the
* child process to appear as a separate dock icon (named "exec") because the
* binary lives inside a `.app` bundle that macOS treats as a GUI application.
*
* To avoid this, we resolve the Electron Helper binary which has
* `LSUIElement` set in its Info.plist, preventing dock icon creation.
* Falls back to `process.execPath` if the Helper binary is not found.
*/
function getNodeExecutablePath(): string {
if (process.platform === 'darwin' && app.isPackaged) {
// Electron Helper binary lives at:
// <App>.app/Contents/Frameworks/<ProductName> Helper.app/Contents/MacOS/<ProductName> Helper
const appName = app.getName();
const helperName = `${appName} Helper`;
const helperPath = path.join(
path.dirname(process.execPath), // .../Contents/MacOS
'../Frameworks',
`${helperName}.app`,
'Contents/MacOS',
helperName,
);
if (existsSync(helperPath)) {
logger.debug(`Using Electron Helper binary to avoid dock icon: ${helperPath}`);
return helperPath;
}
logger.debug(`Electron Helper binary not found at ${helperPath}, falling back to process.execPath`);
}
return process.execPath;
}
// getNodeExecutablePath() removed: utilityProcess.fork() handles process isolation
// natively on all platforms (no dock icon on macOS, no console on Windows).
/**
* Ensure the gateway fetch-preload script exists in userData and return
@@ -158,6 +125,35 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
}
return _f.call(globalThis, input, init);
};
// Global monkey-patch for child_process to enforce windowsHide: true on Windows
// This prevents OpenClaw's tools (e.g. Terminal, Python) from flashing black
// command boxes during AI conversations, without triggering AVs.
if (process.platform === 'win32') {
try {
var cp = require('child_process');
if (!cp.__clawxPatched) {
cp.__clawxPatched = true;
['spawn', 'exec', 'execFile', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
var original = cp[method];
if (typeof original === 'function') {
cp[method] = function() {
var args = Array.prototype.slice.call(arguments);
var lastArg = args[args.length - 1];
if (lastArg && typeof lastArg === 'object' && !Array.isArray(lastArg)) {
lastArg.windowsHide = true;
} else {
args.push({ windowsHide: true });
}
return original.apply(this, args);
};
}
});
}
} catch (e) {
// ignore
}
}
})();
`;
@@ -179,7 +175,8 @@ class LifecycleSupersededError extends Error {
* Handles starting, stopping, and communicating with the OpenClaw Gateway
*/
export class GatewayManager extends EventEmitter {
private process: ChildProcess | null = null;
private process: Electron.UtilityProcess | null = null;
private processExitCode: number | null = null; // set by exit event, replaces exitCode/signalCode
private ownsProcess = false;
private ws: WebSocket | null = null;
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
@@ -461,43 +458,32 @@ export class GatewayManager extends EventEmitter {
// Kill process
if (this.process && this.ownsProcess) {
const child = this.process;
// UtilityProcess doesn't expose exitCode/signalCode — track exit via event.
let exited = false;
await new Promise<void>((resolve) => {
// If process already exited, resolve immediately
if (child.exitCode !== null || child.signalCode !== null) {
return resolve();
}
child.once('exit', () => {
exited = true;
resolve();
});
// Kill the entire process group so respawned children are also terminated.
// The gateway entry script may respawn itself; killing only the parent PID
// leaves the child orphaned (PPID=1) and still holding the port.
const pid = child.pid;
logger.info(`Sending SIGTERM to Gateway process group (pid=${pid ?? 'unknown'})`);
if (pid) {
try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ }
}
child.kill('SIGTERM');
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
try { child.kill(); } catch { /* ignore if already exited */ }
// Force kill after timeout
// Force kill after timeout via OS-level kill on the PID
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
logger.warn(`Gateway did not exit in time, sending SIGKILL (pid=${pid ?? 'unknown'})`);
if (!exited) {
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
if (pid) {
try { process.kill(-pid, 'SIGKILL'); } catch { /* ignore */ }
try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
}
child.kill('SIGKILL');
}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
child.once('error', () => {
clearTimeout(timeout);
resolve();
});
});
@@ -721,11 +707,11 @@ export class GatewayManager extends EventEmitter {
try {
// Platform-specific command to find processes listening on the gateway port.
// On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead.
// -WindowStyle Hidden is used to prevent PowerShell from popping up a brief console window
// even when windowsHide: true is passed to cp.exec.
// We use native commands (netstat on Windows) to avoid triggering AV blocks
// that flag "powershell -WindowStyle Hidden" as malware behavior.
// windowsHide: true in cp.exec natively prevents the black command window.
const cmd = process.platform === 'win32'
? `powershell -WindowStyle Hidden -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`
? `netstat -ano | findstr :${port}`
: `lsof -i :${port} -sTCP:LISTEN -t`;
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
@@ -738,9 +724,23 @@ export class GatewayManager extends EventEmitter {
});
if (stdout.trim()) {
const pids = stdout.trim().split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean);
// Parse netstat or lsof output to extract PIDs
let pids: string[] = [];
if (process.platform === 'win32') {
// netstat -ano output format:
// TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 12345
const lines = stdout.trim().split(/\r?\n/);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[3] === 'LISTENING') {
pids.push(parts[4]);
}
}
} else {
pids = stdout.trim().split(/\r?\n/).map(s => s.trim()).filter(Boolean);
}
// Remove duplicate PIDs
pids = [...new Set(pids)];
if (pids.length > 0) {
if (!this.process || !pids.includes(String(this.process.pid))) {
@@ -756,12 +756,11 @@ export class GatewayManager extends EventEmitter {
for (const pid of pids) {
try {
if (process.platform === 'win32') {
// Use PowerShell with -WindowStyle Hidden to kill the process without
// flashing a black console window. taskkill.exe is a console app and
// can flash a window even when windowsHide: true is set.
// Use taskkill with windowsHide: true. This natively hides the console
// flash without needing PowerShell, avoiding AV alerts.
import('child_process').then(cp => {
cp.exec(
`powershell -WindowStyle Hidden -NoProfile -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`,
`taskkill /F /PID ${pid} /T`,
{ timeout: 5000, windowsHide: true },
() => { }
);
@@ -839,36 +838,23 @@ export class GatewayManager extends EventEmitter {
: process.env.PATH || '';
const uvEnv = await getUvMirrorEnv();
const command = app.isPackaged ? getNodeExecutablePath() : 'node';
const args = [entryScript, 'doctor', '--fix', '--yes', '--non-interactive'];
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
logger.info(
`Running OpenClaw doctor repair (command="${command}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`
);
return new Promise<boolean>((resolve) => {
const spawnEnv: Record<string, string | undefined> = {
const forkEnv: Record<string, string | undefined> = {
...process.env,
PATH: finalPath,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
};
if (app.isPackaged) {
spawnEnv['ELECTRON_RUN_AS_NODE'] = '1';
spawnEnv['OPENCLAW_NO_RESPAWN'] = '1';
const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? '';
if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') &&
!existingNodeOpts.includes('--no-warnings')) {
spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim();
}
}
const child = spawn(command, args, {
const child = utilityProcess.fork(entryScript, doctorArgs, {
cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
shell: false,
windowsHide: true,
env: spawnEnv,
stdio: 'pipe',
env: forkEnv as NodeJS.ProcessEnv,
});
let settled = false;
@@ -881,7 +867,7 @@ export class GatewayManager extends EventEmitter {
const timeout = setTimeout(() => {
logger.error('OpenClaw doctor repair timed out after 120000ms');
try {
child.kill('SIGTERM');
child.kill();
} catch {
// ignore
}
@@ -912,14 +898,14 @@ export class GatewayManager extends EventEmitter {
}
});
child.on('exit', (code, signal) => {
child.on('exit', (code: number) => {
clearTimeout(timeout);
if (code === 0) {
logger.info('OpenClaw doctor repair completed successfully');
finish(true);
return;
}
logger.warn(`OpenClaw doctor repair exited (${this.formatExit(code, signal)})`);
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
finish(false);
});
});
@@ -976,40 +962,16 @@ export class GatewayManager extends EventEmitter {
logger.warn('Failed to sync browser config to openclaw.json:', err);
}
let command: string;
let args: string[];
let mode: 'packaged' | 'dev-built' | 'dev-pnpm';
// Determine the Node.js executable
// In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1
// which makes the Electron binary behave as plain Node.js.
// In development, use system 'node'.
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
if (app.isPackaged) {
// Production: use Electron binary as Node.js via ELECTRON_RUN_AS_NODE
// On macOS, use the Electron Helper binary to avoid extra dock icons
if (existsSync(entryScript)) {
command = getNodeExecutablePath();
args = [entryScript, ...gatewayArgs];
mode = 'packaged';
} else {
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
logger.error(errMsg);
throw new Error(errMsg);
}
} else if (isOpenClawBuilt() && existsSync(entryScript)) {
// Development with built package: use system node
command = 'node';
args = [entryScript, ...gatewayArgs];
mode = 'dev-built';
} else {
// Development without build: use pnpm dev
command = 'pnpm';
args = ['run', 'dev', ...gatewayArgs];
mode = 'dev-pnpm';
// utilityProcess.fork() works for both dev and packaged — no ELECTRON_RUN_AS_NODE needed.
if (!existsSync(entryScript)) {
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
logger.error(errMsg);
throw new Error(errMsg);
}
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
const mode = app.isPackaged ? 'packaged' : 'dev';
// Resolve bundled bin path for uv
const platform = process.platform;
const arch = process.arch;
@@ -1067,13 +1029,15 @@ export class GatewayManager extends EventEmitter {
const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings);
logger.info(
`Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})`
`Starting Gateway process (mode=${mode}, port=${this.status.port}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})`
);
this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`;
this.lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
return new Promise((resolve, reject) => {
// Reset exit tracking for this new process instance.
this.processExitCode = null;
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const spawnEnv: Record<string, string | undefined> = {
const forkEnv: Record<string, string | undefined> = {
...baseEnv,
PATH: finalPath,
...providerEnv,
@@ -1082,29 +1046,17 @@ export class GatewayManager extends EventEmitter {
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
OPENCLAW_SKIP_CHANNELS: '',
CLAWDBOT_SKIP_CHANNELS: '',
// Prevent OpenClaw from respawning itself inside the utility process
OPENCLAW_NO_RESPAWN: '1',
};
// Critical: In packaged mode, make Electron binary act as Node.js
if (app.isPackaged) {
spawnEnv['ELECTRON_RUN_AS_NODE'] = '1';
// Prevent OpenClaw entry.ts from respawning itself (which would create
// another child process and a second "exec" dock icon on macOS)
spawnEnv['OPENCLAW_NO_RESPAWN'] = '1';
// Pre-set the NODE_OPTIONS that entry.ts would have added via respawn
const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? '';
if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') &&
!existingNodeOpts.includes('--no-warnings')) {
spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim();
}
}
// Inject fetch preload so OpenRouter requests carry ClawX headers.
// The preload patches globalThis.fetch before any module loads.
try {
const preloadPath = ensureGatewayFetchPreload();
if (existsSync(preloadPath)) {
spawnEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
spawnEnv['NODE_OPTIONS'],
forkEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
forkEnv['NODE_OPTIONS'],
preloadPath,
);
}
@@ -1112,17 +1064,13 @@ export class GatewayManager extends EventEmitter {
logger.warn('Failed to set up OpenRouter headers preload:', err);
}
const useShell = !app.isPackaged && process.platform === 'win32';
const spawnCmd = useShell ? quoteForCmd(command) : command;
const spawnArgs = useShell ? args.map(a => quoteForCmd(a)) : args;
this.process = spawn(spawnCmd, spawnArgs, {
// utilityProcess.fork() runs the .mjs entry directly without spawning a
// shell or visible console window. Works identically in dev and packaged.
this.process = utilityProcess.fork(entryScript, gatewayArgs, {
cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
shell: useShell,
windowsHide: true,
env: spawnEnv,
stdio: 'pipe',
env: forkEnv as NodeJS.ProcessEnv,
serviceName: 'OpenClaw Gateway',
});
const child = this.process;
this.ownsProcess = true;
@@ -1133,10 +1081,11 @@ export class GatewayManager extends EventEmitter {
reject(error);
});
child.on('exit', (code, signal) => {
child.on('exit', (code: number) => {
this.processExitCode = code;
const expectedExit = !this.shouldReconnect || this.status.state === 'stopped';
const level = expectedExit ? logger.info : logger.warn;
level(`Gateway process exited (${this.formatExit(code, signal)}, expected=${expectedExit ? 'yes' : 'no'})`);
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
this.ownsProcess = false;
if (this.process === child) {
this.process = null;
@@ -1149,9 +1098,7 @@ export class GatewayManager extends EventEmitter {
}
});
child.on('close', (code, signal) => {
logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`);
});
// UtilityProcess doesn't emit 'close'; stdout/stderr end naturally on exit.
// Log stderr
child.stderr?.on('data', (data) => {
@@ -1186,12 +1133,12 @@ export class GatewayManager extends EventEmitter {
private async waitForReady(retries = 2400, interval = 250): Promise<void> {
const child = this.process;
for (let i = 0; i < retries; i++) {
// Early exit if the gateway process has already exited
if (child && (child.exitCode !== null || child.signalCode !== null)) {
const code = child.exitCode;
const signal = child.signalCode;
logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`);
throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`);
// Early exit if the gateway process has already exited.
// UtilityProcess has no synchronous exitCode/signalCode — use our tracked flag.
if (child && this.processExitCode !== null) {
const code = this.processExitCode;
logger.error(`Gateway process exited before ready (code=${code})`);
throw new Error(`Gateway process exited before becoming ready (code=${code})`);
}
try {

View File

@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.1.22-alpha.2",
"version": "0.1.22-alpha.5",
"pnpm": {
"onlyBuiltDependencies": [
"@whiskeysockets/baileys",

View File

@@ -3,83 +3,64 @@
; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI.
; Uninstall: removes the PATH entry and optionally deletes user data.
; When customCheckAppRunning is defined, electron-builder skips its conditional
; !include for getProcessInfo.nsh and the "Var pid" declaration. We must do
; both ourselves so ${GetProcessInfo} and $pid are available.
!ifndef getProcessInfo_included
!define getProcessInfo_included
!include "getProcessInfo.nsh"
Var pid
!endif
!ifndef nsProcess_included
!define nsProcess_included
!ifndef nsProcess::FindProcess
!include "nsProcess.nsh"
!endif
!macro customCheckAppRunning
${GetProcessInfo} 0 $pid $1 $2 $3 $4
${if} $3 != "${APP_EXECUTABLE_FILENAME}"
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0
${if} ${isUpdated}
# allow app to exit without explicit kill
Sleep 300
Sleep 1000
Goto doStopProcess
${endIf}
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK doStopProcess
Quit
# Instead of launching cmd.exe /c tasklist, use the nsProcess plugin directly for all environments.
# This prevents the brief black cmd window from flashing.
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
doStopProcess:
DetailPrint `Closing running "${PRODUCT_NAME}"...`
${if} $R0 == 0
${if} ${isUpdated}
# allow app to exit without explicit kill
# Silently kill the process using nsProcess instead of taskkill / cmd.exe
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
# to ensure that files are not "in-use"
Sleep 300
# Retry counter
StrCpy $R1 0
loop:
IntOp $R1 $R1 + 1
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0
# wait to give a chance to exit gracefully
Sleep 1000
Goto doStopProcess
${endIf}
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK doStopProcess
Quit
doStopProcess:
DetailPrint `Closing running "${PRODUCT_NAME}"...`
# Silently kill the process using nsProcess instead of taskkill / cmd.exe
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
# to ensure that files are not "in-use"
Sleep 300
# Retry counter
StrCpy $R1 0
loop:
IntOp $R1 $R1 + 1
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${if} $R0 == 0
# wait to give a chance to exit gracefully
Sleep 1000
${nsProcess::KillProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
${If} $R0 == 0
DetailPrint `Waiting for "${PRODUCT_NAME}" to close.`
Sleep 2000
${else}
Goto not_running
${endIf}
${If} $R0 == 0
DetailPrint `Waiting for "${PRODUCT_NAME}" to close.`
Sleep 2000
${else}
Goto not_running
${endIf}
${else}
Goto not_running
${endIf}
# App likely running with elevated permissions.
# Ask user to close it manually
${if} $R1 > 1
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop
Quit
${else}
Goto loop
${endIf}
not_running:
nsProcess::Unload
${endIf}
# App likely running with elevated permissions.
# Ask user to close it manually
${if} $R1 > 1
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY loop
Quit
${else}
Goto loop
${endIf}
not_running:
${nsProcess::Unload}
${endIf}
!macroend

View File

@@ -948,7 +948,8 @@ function ProviderContent({
try {
// Validate key if the provider requires one and a key was entered
if (requiresKey && apiKey) {
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
if (isApiKeyRequired && apiKey) {
const result = await window.electron.ipcRenderer.invoke(
'provider:validateKey',
selectedProviderConfigId || selectedProvider,
@@ -1024,9 +1025,10 @@ function ProviderContent({
};
// Can the user submit?
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
const canSubmit =
selectedProvider
&& (requiresKey ? apiKey.length > 0 : true)
&& (isApiKeyRequired ? apiKey.length > 0 : true)
&& (showModelIdField ? modelId.trim().length > 0 : true)
&& !useOAuthFlow;
@@ -1197,7 +1199,7 @@ function ProviderContent({
)}
{/* API Key field (hidden for ollama) */}
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
<div className="space-y-2">
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
<div className="relative">