@@ -41,6 +41,46 @@ interface OpenClawPluginApi {
4141 registerGatewayMethod ?( name : string , handler : ( ctx : { respond : ( ok : boolean , data : any ) => void } ) => void ) : void ;
4242}
4343
44+ /**
45+ * Build upstream config from either string or object format.
46+ * String format: proxy.upstream = "https://api.anthropic.com", proxy.apiKey = "sk-..."
47+ * Object format: proxy.upstream = { anthropic: { url, apiKey }, openai: { url, apiKey } }
48+ */
49+ function buildUpstreamConfig ( proxyConfig : NonNullable < PluginConfig [ "proxy" ] > ) : {
50+ anthropic ?: { url : string ; apiKey : string } ;
51+ openai ?: { url : string ; apiKey : string } ;
52+ } {
53+ const upstream = proxyConfig . upstream ;
54+
55+ if ( ! upstream ) return { } ;
56+
57+ // String format: single upstream URL + flat apiKey
58+ if ( typeof upstream === "string" ) {
59+ const apiKey = proxyConfig . apiKey ?? "" ;
60+ const url = upstream ;
61+ // Guess provider from URL
62+ if ( url . includes ( "anthropic" ) ) {
63+ return { anthropic : { url, apiKey } } ;
64+ } else if ( url . includes ( "openai" ) ) {
65+ return { openai : { url, apiKey } } ;
66+ }
67+ // Default to anthropic
68+ return { anthropic : { url, apiKey } } ;
69+ }
70+
71+ // Object format: multi-provider
72+ return {
73+ anthropic : upstream . anthropic ? {
74+ url : upstream . anthropic . url ?? "https://api.anthropic.com" ,
75+ apiKey : upstream . anthropic . apiKey ,
76+ } : undefined ,
77+ openai : upstream . openai ? {
78+ url : upstream . openai . url ?? "https://api.openai.com" ,
79+ apiKey : upstream . openai . apiKey ,
80+ } : undefined ,
81+ } ;
82+ }
83+
4484export default function register ( api : OpenClawPluginApi ) {
4585 const config : PluginConfig = api . pluginConfig ?? { } ;
4686 const logger = api . logger ;
@@ -58,6 +98,9 @@ export default function register(api: OpenClawPluginApi) {
5898 logger,
5999 } ) ;
60100
101+ // --- LLM Proxy: intercept tool calls at the API level ---
102+ const proxyConfig = config . proxy ;
103+
61104 const gui = new ControlGui ( {
62105 port : config . guiPort ?? 19820 ,
63106 aggregator,
@@ -66,20 +109,9 @@ export default function register(api: OpenClawPluginApi) {
66109 proxyEnabled : ! ! proxyConfig ?. enabled ,
67110 } ) ;
68111
69- // --- LLM Proxy: intercept tool calls at the API level ---
70- const proxyConfig = config . proxy ;
71- const proxy = proxyConfig ?. enabled ? new LlmProxy ( {
112+ let proxy : LlmProxy | null = proxyConfig ?. enabled ? new LlmProxy ( {
72113 port : proxyConfig . port ?? 19821 ,
73- upstream : {
74- anthropic : proxyConfig . upstream ?. anthropic ? {
75- url : proxyConfig . upstream . anthropic . url ?? "https://api.anthropic.com" ,
76- apiKey : proxyConfig . upstream . anthropic . apiKey ,
77- } : undefined ,
78- openai : proxyConfig . upstream ?. openai ? {
79- url : proxyConfig . upstream . openai . url ?? "https://api.openai.com" ,
80- apiKey : proxyConfig . upstream . openai . apiKey ,
81- } : undefined ,
82- } ,
114+ upstream : buildUpstreamConfig ( proxyConfig ) ,
83115 cedar,
84116 logger,
85117 } ) : null ;
@@ -131,6 +163,17 @@ export default function register(api: OpenClawPluginApi) {
131163 return { patched : toAdd , alreadyDenied } ;
132164 }
133165
166+ function backupConfig ( ) : void {
167+ const { readFileSync, writeFileSync, existsSync, copyFileSync } = require ( "node:fs" ) ;
168+ const { join } = require ( "node:path" ) ;
169+ const { homedir } = require ( "node:os" ) ;
170+ const configPath = join ( homedir ( ) , ".openclaw" , "openclaw.json" ) ;
171+ if ( existsSync ( configPath ) ) {
172+ const backupPath = configPath + ".carapace-backup" ;
173+ copyFileSync ( configPath , backupPath ) ;
174+ }
175+ }
176+
134177 function patchConfigProxyBaseUrl ( ) : { patched : string [ ] ; alreadySet : string [ ] } {
135178 const { readFileSync, writeFileSync, existsSync } = require ( "node:fs" ) ;
136179 const { join } = require ( "node:path" ) ;
@@ -143,28 +186,45 @@ export default function register(api: OpenClawPluginApi) {
143186 const port = config . proxy ?. port ?? 19821 ;
144187 const proxyUrl = `http://127.0.0.1:${ port } ` ;
145188
146- // Figure out which providers have upstream keys configured
147- const providers : string [ ] = [ ] ;
148- if ( config . proxy ?. upstream ?. anthropic ) providers . push ( "anthropic" ) ;
149- if ( config . proxy ?. upstream ?. openai ) providers . push ( "openai" ) ;
189+ // Figure out which providers are configured
190+ const upstreamConfig = proxyConfig ? buildUpstreamConfig ( proxyConfig ) : { } ;
191+ const providers = Object . keys ( upstreamConfig ) . filter (
192+ ( k ) => upstreamConfig [ k as keyof typeof upstreamConfig ] ,
193+ ) ;
150194
151195 const patched : string [ ] = [ ] ;
152196 const alreadySet : string [ ] = [ ] ;
153197
154198 if ( ! cfg . models ) cfg . models = { } ;
199+ if ( ! cfg . models . mode ) cfg . models . mode = "merge" ;
155200 if ( ! cfg . models . providers ) cfg . models . providers = { } ;
156201
157202 for ( const provider of providers ) {
158203 if ( ! cfg . models . providers [ provider ] ) cfg . models . providers [ provider ] = { } ;
204+ // Ensure models array exists (OpenClaw requires it)
205+ if ( ! Array . isArray ( cfg . models . providers [ provider ] . models ) ) {
206+ cfg . models . providers [ provider ] . models = [ ] ;
207+ }
159208 if ( cfg . models . providers [ provider ] . baseUrl === proxyUrl ) {
160209 alreadySet . push ( provider ) ;
161210 } else {
211+ // Store original baseUrl for clean revert
212+ if ( cfg . models . providers [ provider ] . baseUrl && cfg . models . providers [ provider ] . baseUrl !== proxyUrl ) {
213+ cfg . models . providers [ provider ] . _originalBaseUrl = cfg . models . providers [ provider ] . baseUrl ;
214+ }
162215 cfg . models . providers [ provider ] . baseUrl = proxyUrl ;
163216 patched . push ( provider ) ;
164217 }
165218 }
166219
167- if ( patched . length > 0 ) {
220+ // Ensure plugin config is under plugins.entries.carapace.config
221+ if ( ! cfg . plugins ) cfg . plugins = { } ;
222+ if ( ! cfg . plugins . entries ) cfg . plugins . entries = { } ;
223+ if ( ! cfg . plugins . entries . carapace ) cfg . plugins . entries . carapace = { } ;
224+ if ( ! cfg . plugins . entries . carapace . config ) cfg . plugins . entries . carapace . config = { } ;
225+
226+ if ( patched . length > 0 || ! cfg . plugins . entries . carapace . enabled ) {
227+ cfg . plugins . entries . carapace . enabled = true ;
168228 writeFileSync ( configPath , JSON . stringify ( cfg , null , 2 ) + "\n" , "utf-8" ) ;
169229 }
170230
@@ -183,10 +243,27 @@ export default function register(api: OpenClawPluginApi) {
183243
184244 if ( proxy ) {
185245 await proxy . start ( ) ;
186- logger . info (
187- `🛡️ LLM Proxy active on http://127.0.0.1:${ proxyConfig ! . port ?? 19821 } — ` +
188- `all tool calls go through Cedar`
189- ) ;
246+
247+ // Health check: verify proxy is actually responding
248+ const proxyPort = proxyConfig ! . port ?? 19821 ;
249+ try {
250+ const controller = new AbortController ( ) ;
251+ const timer = setTimeout ( ( ) => controller . abort ( ) , 3000 ) ;
252+ const healthResp = await fetch ( `http://127.0.0.1:${ proxyPort } /health` , { signal : controller . signal } ) ;
253+ clearTimeout ( timer ) ;
254+ if ( ! healthResp . ok ) throw new Error ( `HTTP ${ healthResp . status } ` ) ;
255+ } catch ( err : any ) {
256+ logger . error ( `❌ Proxy health check failed on port ${ proxyPort } : ${ err . message } . Disabling proxy.` ) ;
257+ try { await proxy . stop ( ) ; } catch { }
258+ proxy = null ;
259+ }
260+
261+ if ( proxy ) {
262+ logger . info (
263+ `🛡️ LLM Proxy active on http://127.0.0.1:${ proxyPort } — ` +
264+ `all tool calls go through Cedar`
265+ ) ;
266+ }
190267 } else {
191268 // Check for bypass vulnerabilities only when proxy is disabled
192269 const bypasses = checkForBypasses ( ) ;
@@ -524,6 +601,8 @@ export default function register(api: OpenClawPluginApi) {
524601 . description ( "Configure OpenClaw to route all traffic through Carapace" )
525602 . action ( async ( ) => {
526603 console . log ( "\n🦞 Carapace Setup\n" ) ;
604+ backupConfig ( ) ;
605+ console . log ( " 📦 Backed up openclaw.json → openclaw.json.carapace-backup" ) ;
527606 let anyChanges = false ;
528607
529608 // 1. Deny built-in bypass tools
@@ -555,7 +634,8 @@ export default function register(api: OpenClawPluginApi) {
555634 }
556635 if ( patched . length === 0 && alreadySet . length === 0 ) {
557636 console . log ( " ⚠️ No upstream providers configured in proxy config." ) ;
558- console . log ( " Add proxy.upstream.anthropic or proxy.upstream.openai to your plugin config." ) ;
637+ console . log ( ' Set proxy.upstream to a URL string (e.g., "https://api.anthropic.com") with proxy.apiKey,' ) ;
638+ console . log ( " or use the object format: proxy.upstream = { anthropic: { apiKey: '...' } }" ) ;
559639 }
560640 } else {
561641 console . log ( "\n LLM proxy not enabled — skipping baseUrl setup." ) ;
@@ -609,11 +689,18 @@ export default function register(api: OpenClawPluginApi) {
609689 if ( cfg . models ?. providers ) {
610690 for ( const [ name , provCfg ] of Object . entries ( cfg . models . providers ) ) {
611691 if ( ( provCfg as any ) ?. baseUrl === proxyUrl ) {
612- delete ( provCfg as any ) . baseUrl ;
692+ // Restore original baseUrl if stored
693+ if ( ( provCfg as any ) . _originalBaseUrl ) {
694+ ( provCfg as any ) . baseUrl = ( provCfg as any ) . _originalBaseUrl ;
695+ delete ( provCfg as any ) . _originalBaseUrl ;
696+ console . log ( ` ✅ Restored original baseUrl for ${ name } ` ) ;
697+ } else {
698+ delete ( provCfg as any ) . baseUrl ;
699+ console . log ( ` ✅ Removed baseUrl proxy override for ${ name } ` ) ;
700+ }
613701 // Clean up empty objects
614702 if ( Object . keys ( provCfg as any ) . length === 0 ) delete cfg . models . providers [ name ] ;
615703 changed = true ;
616- console . log ( ` ✅ Removed baseUrl proxy override for ${ name } ` ) ;
617704 console . log ( ` ${ name } will connect directly to its API again.` ) ;
618705 }
619706 }
0 commit comments