-
Notifications
You must be signed in to change notification settings - Fork 837
External plugins and middleware support #1455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
external middleware can dynamically register custom routes to Portkey without any core modifications (using external plugins/middlewares) Key Benefits
Why It's Good
Working Middleware Example
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds support for loading external plugins and middlewares from custom directories, enabling users to extend Portkey Gateway functionality without modifying its core codebase. The implementation includes CLI flags for specifying plugin/middleware directories, loader utilities for dynamic module loading, and comprehensive documentation with working examples.
Key Changes:
- Added
--plugins-dirand--middlewares-dirCLI flags to load external plugins and middlewares at runtime - Implemented loader utilities (
pluginLoader.tsandmiddlewareLoader.ts) to dynamically import and register external modules - Updated documentation with detailed guides and working examples of external plugins and middlewares
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/start-server.ts | Added CLI argument parsing and loading logic for external plugins/middlewares with error handling |
| src/loaders/pluginLoader.ts | New loader utility that scans directories, validates manifests, and dynamically imports plugin handlers |
| src/loaders/middlewareLoader.ts | New loader utility that imports middleware modules and determines if they're plugin-style or standard middlewares |
| src/handlers/handlerUtils.ts | Fixed hook conversion to handle already-formatted hooks and corrected camelCase property naming for guardrails |
| plugins/README.md | Added comprehensive documentation section on creating and using external plugins/middlewares |
| package.json | Added new dependencies for PostgreSQL support and updated package description |
| external-examples/test-webhook-verifier.sh | Added test script demonstrating webhook signature verification middleware functionality |
| external-examples/plugins/default-external/wordCountExternal.js | Example external plugin implementing word count guardrail validation |
| external-examples/plugins/default-external/regexMatchExternal.js | Example external plugin implementing regex pattern matching validation |
| external-examples/plugins/default-external/manifest.json | Manifest file defining external plugin metadata and function configurations |
| external-examples/middlewares/webhookSignatureVerifier.js | Example plugin-style middleware that registers custom webhook verification routes |
| external-examples/middlewares/loggerExternal.ts | Example standard middleware for logging incoming requests and responses |
| README.md | Updated main README with extensibility section and CLI options documentation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (pluginsDir) { | ||
| console.log('🔌 Loading external plugins from:', pluginsDir); | ||
| try { | ||
| const externalPlugins = await loadExternalPlugins([pluginsDir]); |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The loadExternalPlugins function accepts an array but only a single directory is passed. Consider either passing pluginsDir directly without wrapping it in an array, or support multiple plugin directories by allowing comma-separated values in the CLI argument.
| if (middlewaresDir) { | ||
| console.log('⚙️ Loading external middlewares from:', middlewaresDir); | ||
| try { | ||
| const externalMiddlewares = await loadExternalMiddlewares([middlewaresDir]); |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the plugin loader, loadExternalMiddlewares accepts an array but only a single directory is passed. Consider passing middlewaresDir directly or supporting multiple directories.
src/loaders/middlewareLoader.ts
Outdated
| // If middleware is a function that returns a function, it's a plugin | ||
| if (middleware.length === 0) { | ||
| // No parameters - likely middleware() that returns a function | ||
| try { | ||
| const result = middleware(); | ||
| if (typeof result === 'function') { | ||
| // It's a plugin - the result is the app handler | ||
| isPlugin = true; | ||
| handler = result; | ||
| } else { | ||
| // middleware() returned non-function, use original middleware | ||
| isPlugin = false; | ||
| handler = middleware; | ||
| } | ||
| } catch (e) { | ||
| // If calling middleware() throws, treat as standard middleware | ||
| isPlugin = false; | ||
| handler = middleware; | ||
| } |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using middleware.length === 0 to detect plugin-style middleware is unreliable. Arrow functions with no parameters still have length === 0, but so do functions with default parameters or rest parameters. This heuristic could misidentify standard middleware that uses default parameters. Consider requiring explicit metadata or checking if the result is a function that accepts an app parameter.
| // If middleware is a function that returns a function, it's a plugin | |
| if (middleware.length === 0) { | |
| // No parameters - likely middleware() that returns a function | |
| try { | |
| const result = middleware(); | |
| if (typeof result === 'function') { | |
| // It's a plugin - the result is the app handler | |
| isPlugin = true; | |
| handler = result; | |
| } else { | |
| // middleware() returned non-function, use original middleware | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| } catch (e) { | |
| // If calling middleware() throws, treat as standard middleware | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| // Prefer explicit metadata to detect plugin-style middleware | |
| if (metadata.isPlugin === true) { | |
| try { | |
| const result = middleware(); | |
| if (typeof result === 'function') { | |
| isPlugin = true; | |
| handler = result; | |
| } else { | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| } catch (e) { | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| } else if (metadata.isPlugin === undefined) { | |
| // Fallback: try calling middleware() and see if it returns a function that takes an app parameter | |
| try { | |
| const result = middleware(); | |
| if (typeof result === 'function' && (result.length === 1 || result.name === 'appHandler')) { | |
| isPlugin = true; | |
| handler = result; | |
| } else { | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| } catch (e) { | |
| isPlugin = false; | |
| handler = middleware; | |
| } |
src/loaders/middlewareLoader.ts
Outdated
| // Plugin: middleware() returns (app) => void | ||
| // Standard: middleware is (c, next) => Promise<any> | ||
| let isPlugin = false; | ||
| let handler = middleware; | ||
|
|
||
| // If middleware is a function that returns a function, it's a plugin | ||
| if (middleware.length === 0) { | ||
| // No parameters - likely middleware() that returns a function | ||
| try { | ||
| const result = middleware(); | ||
| if (typeof result === 'function') { | ||
| // It's a plugin - the result is the app handler | ||
| isPlugin = true; | ||
| handler = result; | ||
| } else { | ||
| // middleware() returned non-function, use original middleware | ||
| isPlugin = false; | ||
| handler = middleware; | ||
| } | ||
| } catch (e) { | ||
| // If calling middleware() throws, treat as standard middleware | ||
| isPlugin = false; | ||
| handler = middleware; | ||
| } | ||
| } | ||
|
|
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling middleware() without any context or parameters could have unintended side effects if the middleware function performs operations during initialization. Consider documenting this behavior or adding safeguards to prevent potentially harmful operations during detection.
| // Plugin: middleware() returns (app) => void | |
| // Standard: middleware is (c, next) => Promise<any> | |
| let isPlugin = false; | |
| let handler = middleware; | |
| // If middleware is a function that returns a function, it's a plugin | |
| if (middleware.length === 0) { | |
| // No parameters - likely middleware() that returns a function | |
| try { | |
| const result = middleware(); | |
| if (typeof result === 'function') { | |
| // It's a plugin - the result is the app handler | |
| isPlugin = true; | |
| handler = result; | |
| } else { | |
| // middleware() returned non-function, use original middleware | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| } catch (e) { | |
| // If calling middleware() throws, treat as standard middleware | |
| isPlugin = false; | |
| handler = middleware; | |
| } | |
| } | |
| // Plugin: middleware() returns (app) => void, but we avoid calling it to prevent side effects. | |
| // Standard: middleware is (c, next) => Promise<any> | |
| // Detection is now based on metadata.isPlugin to avoid calling middleware() during loading. | |
| let isPlugin = !!metadata.isPlugin; | |
| let handler = middleware; | |
| // If plugin, handler should be the factory function; actual invocation happens at runtime. |
package.json
Outdated
| "@portkey-ai/mustache": "^2.1.3", | ||
| "@smithy/signature-v4": "^2.1.1", | ||
| "@types/mustache": "^4.2.5", | ||
| "@types/pg": "^8.15.6", |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PostgreSQL dependencies (@types/pg, pg, pg-boss) are added but not used in any of the changed files. These dependencies should either be removed if not needed for this PR, or their usage should be documented.
package.json
Outdated
| "pg": "^8.16.3", | ||
| "pg-boss": "^12.3.1", |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PostgreSQL dependencies (@types/pg, pg, pg-boss) are added but not used in any of the changed files. These dependencies should either be removed if not needed for this PR, or their usage should be documented.
| # Build curl command | ||
| local curl_cmd="curl -s -w '\n%{http_code}' -X $method" | ||
|
|
||
| if [ -n "$headers" ]; then | ||
| curl_cmd="$curl_cmd $headers" | ||
| fi | ||
|
|
||
| if [ -n "$data" ]; then | ||
| curl_cmd="$curl_cmd -d '$data'" | ||
| fi | ||
|
|
||
| curl_cmd="$curl_cmd '$BASE_URL$url'" | ||
|
|
||
| # Run the test | ||
| local response=$(eval $curl_cmd) |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using eval with $curl_cmd is a security risk if the command contains untrusted input. While the inputs here are controlled, consider using arrays to build the command and executing it directly without eval.
| # Build curl command | |
| local curl_cmd="curl -s -w '\n%{http_code}' -X $method" | |
| if [ -n "$headers" ]; then | |
| curl_cmd="$curl_cmd $headers" | |
| fi | |
| if [ -n "$data" ]; then | |
| curl_cmd="$curl_cmd -d '$data'" | |
| fi | |
| curl_cmd="$curl_cmd '$BASE_URL$url'" | |
| # Run the test | |
| local response=$(eval $curl_cmd) | |
| # Build curl command as an array | |
| local curl_cmd=(curl -s -w '\n%{http_code}' -X "$method") | |
| if [ -n "$headers" ]; then | |
| # Split headers into array elements (assumes headers are space-separated, e.g. "-H 'X: Y' -H 'A: B'") | |
| # Use 'read -a' to split, but this is tricky if headers contain spaces inside quotes. | |
| # Instead, use 'eval set -- $headers' to parse quoted headers safely. | |
| eval set -- $headers | |
| while [ $# -gt 0 ]; do | |
| curl_cmd+=("$1") | |
| shift | |
| done | |
| fi | |
| if [ -n "$data" ]; then | |
| curl_cmd+=(-d "$data") | |
| fi | |
| curl_cmd+=("$BASE_URL$url") | |
| # Run the test | |
| local response | |
| response=$("${curl_cmd[@]}") |
| const isValid = | ||
| providedSignature.length === expectedSignature.length && | ||
| Buffer.from(providedSignature).equals(Buffer.from(expectedSignature)); |
Copilot
AI
Nov 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While using Buffer.equals() provides constant-time comparison, the length check before it (providedSignature.length === expectedSignature.length) is not constant-time and could leak information through timing attacks. Use a dedicated constant-time comparison function that checks length internally, or use crypto.timingSafeEqual().
|
@sandys thanks for this, it is valuable. Can you take a look at Copilot's comments? |
Removed three unused dependencies from gateway/package.json: - @types/pg (^8.15.6) - pg (^8.16.3) - pg-boss (^12.3.1) Gateway now aligns with upstream Portkey's dependency list. ✅ Task 2: Create External Dependency Installer - Directory scanning - Finds package.json files in external middleware/plugin directories - Peer dependency validation - Checks peer dependencies against gateway's available dependencies - npm install execution - Runs npm install --no-save in each directory - Comprehensive error reporting - Returns detailed installation status with failures and mismatches - Version compatibility checking - Validates semver ranges (^, ~, >=, =) for peer dependencies ✅ Task 3: Integrate Into Startup Flow - Call the installer before loading external plugins/middlewares - Find gateway's package.json from multiple fallback paths (handles both src and built code) - Report installation status and fail fast on peer dependency mismatches - Clear error messages for troubleshooting
|
also added support for external providers now ! npm run start:node -- Output shows everything loaded: 🔗 Loading external providers from: ./external-examples/providers 🔌 Loading external plugins from: ./external-examples/plugins ⚙️ Loading external middlewares from: ./external-examples/middlewares 🚀 Your AI Gateway is running at: http://localhost:8888 Test commands (after starting the server): Test ext-openai providercurl -X POST http://localhost:8888/v1/chat/completions Test ext-anthropic providercurl -X POST http://localhost:8888/v1/chat/completions Test ext-gemini providercurl -X POST http://localhost:8888/v1/chat/completions Test webhook verifier middlewarecurl -X POST http://localhost:8888/webhooks/verify |
|
hi @roh26it could you trigger a re-review please ? im not able to do it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "avsc": "^5.7.7", | ||
| "ioredis": "^5.8.0", | ||
| "hono": "^4.9.7", | ||
| "ioredis": "^5.8.0", |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The reordering of 'hono' and 'ioredis' dependencies appears to be unintentional formatting that doesn't add value to this PR. Consider keeping dependencies in their original order to minimize diff noise.
| "hono": "^4.9.7", | |
| "ioredis": "^5.8.0", | |
| "ioredis": "^5.8.0", | |
| "hono": "^4.9.7", |
| parsedConfigJson.defaultInputGuardrails = defaultsConfig.input_guardrails; | ||
| parsedConfigJson.defaultOutputGuardrails = defaultsConfig.output_guardrails; |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The property names have been changed from snake_case to camelCase (default_input_guardrails → defaultInputGuardrails), but the source properties remain in snake_case (defaultsConfig.input_guardrails). This inconsistency suggests either the source properties should also be camelCase, or these target properties should remain snake_case for consistency.
| // Remove 'v' prefix and operators if present | ||
| gatewayVersion = gatewayVersion.replace(/^[v~^>=]+/, ''); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex /^[v~^>=]+/ removes all leading version operators from gatewayVersion, but it should only remove the 'v' prefix. Operators like ^, ~, >= are valid in gatewayVersion and removing them will break version comparison. Change to /^v/ to only remove the 'v' prefix.
| // Remove 'v' prefix and operators if present | |
| gatewayVersion = gatewayVersion.replace(/^[v~^>=]+/, ''); | |
| // Remove 'v' prefix if present | |
| gatewayVersion = gatewayVersion.replace(/^v/, ''); |
| echo -e "${YELLOW}Test: $test_name${NC}" | ||
|
|
||
| # Build curl arguments array to avoid eval | ||
| local curl_args=('-s' '-w' $'\n%{http_code}' '-X' "$method") |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using $'\\n%{http_code}' with ANSI-C quoting in an array assignment may not work correctly in all shells. Consider using a regular string with explicit newline: \"\\n%{http_code}\"
| local curl_args=('-s' '-w' $'\n%{http_code}' '-X' "$method") | |
| local curl_args=('-s' '-w' "\n%{http_code}" '-X' "$method") |
|
I quite like this approach too @roh26it and @sandys |
| // Export the provider config | ||
| export default { | ||
| api, | ||
| chatComplete, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see you are trying to use porktey as an npm package without having to spin up the gateway, this is nice to have but this approach to export each provider separately as a package kinda defeats the purpose of having a unified signature, people should just import one chatComplete function and the final provider should be determined by passing either provider in initialization or any of the other ways that are supported
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd also add that if the focus of this PR is to allow people to install external plugins, these changes to export provider transforms is an off track, would recommend having one set of feature changes in the PR to keep it clean and make it easier for reviewers to understand what is happening
| @@ -0,0 +1,132 @@ | |||
| import fs from 'fs'; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fs wouldnt work on workers
hi @narengogi so two things:
the providers are a package because they need their own dependencies. this was actually the hardest thing to do :) as server maintainers, i didnt want you to deal with infinite bugs of "i need mysql package too. please add that to portkey" |
portkey is a brilliant project. however, those of us who are experimenting with portkey via npmjs, find it hard to use plugins and middlwares. Because you need to change the source code of portkey to get plugins to work.
i have added an incremental patch to portkey that allows inclusion of external middlwares/plugins...while maintaining the sanctity of the bundled plugins/middlewares. the readme has been updated with the usage with external middlwares.
additionally there is a sample folder with external middlewares/plugins - which is a copy of the bundled plugins/middlewares (just to demonstrate).