feat(gateway): add InvocationTarget routing to /v1/chat/completions - #1647
feat(gateway): add InvocationTarget routing to /v1/chat/completions#1647ashum9 wants to merge 1 commit into
Conversation
Replace the placeholder 'Hello from MoFA gateway!' mock response with
real InvocationTarget-based dispatch through the AgentRegistry.
## What changed
### mofa-kernel: new InvocationTarget type
- Add `InvocationTarget` enum to `mofa-kernel/src/gateway/types.rs`
with three variants: Agent, LocalInference, Proxy
- `#[non_exhaustive]` for forward compatibility
- `Display` and `label()` helpers for logs and metrics
- Re-export from `mofa-kernel::gateway`
### mofa-gateway: InvocationRouter
- New `handlers/invocation.rs`: `InvocationRouter` struct wraps
`AgentRegistry` + `InferenceBridge` and provides:
- `resolve(model) -> InvocationTarget`: checks registry first,
falls back to LocalInference if no agent matches
- `dispatch(target, request) -> ChatCompletionResponse`: executes
the resolved target
- Agent dispatch: returns agent metadata (description/name) confirming
the routing decision; full agent invocation is a follow-up task
- LocalInference dispatch: delegates to existing InferenceBridge
- Proxy dispatch: returns Internal error (handled by proxy/ module)
- Wildcard arm for future non_exhaustive variants
### mofa-gateway: openai handler
- Remove mock handler from `handlers/openai.rs`
- Wire `InvocationRouter via `axum::Extension`
- Resolve and dispatch on every request with structured tracing logs
### mofa-gateway: GatewayServer
- Inject `InvocationRouter` Extension in both socketio and non-socketio
build paths in `server.rs`
- OpenAI router is now always added (no longer optional); uses
`OrchestratorConfig::default()` when no explicit config is provided
## Tests (5 new, all pass)
- `resolve_known_agent_returns_agent_target`
- `resolve_unknown_model_returns_local_inference`
- `dispatch_local_inference_returns_valid_response`
- `dispatch_agent_returns_valid_response`
- `dispatch_proxy_returns_error`
Fixes: #TODO (open issue for mock /v1/chat/completions)
|
@lijingrs @yangrudan kindly review this PR :) |
There was a problem hiding this comment.
Pull request overview
Adds a kernel-level InvocationTarget type and wires the gateway’s OpenAI /v1/chat/completions endpoint through a new InvocationRouter that resolves requests via the AgentRegistry first, then falls back to local inference.
Changes:
- Introduce
InvocationTarget(Agent / LocalInference / Proxy) inmofa-kerneland re-export it from the gateway module. - Add
InvocationRouterinmofa-gatewayto resolve + dispatch chat-completion requests based on registry contents. - Update the gateway server + OpenAI handler to inject and use
InvocationRouterinstead of a hard-coded mock response.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/mofa-kernel/src/gateway/types.rs | Adds the InvocationTarget routing enum plus helpers (label, Display). |
| crates/mofa-kernel/src/gateway/mod.rs | Re-exports InvocationTarget and documents it in the module-level table. |
| crates/mofa-gateway/src/server.rs | Always mounts OpenAI routes and injects InvocationRouter (built from registry + inference bridge). |
| crates/mofa-gateway/src/handlers/openai.rs | Routes /v1/chat/completions through InvocationRouter and updates tests. |
| crates/mofa-gateway/src/handlers/mod.rs | Exposes the new invocation module and InvocationRouter export. |
| crates/mofa-gateway/src/handlers/invocation.rs | Implements resolve/dispatch logic and adds unit tests for routing/dispatch. |
| // Always add OpenAI router with InvocationTarget routing. | ||
| // InvocationRouter checks the registry first; if no agent matches | ||
| // it falls back to InferenceBridge → InferenceOrchestrator. | ||
| let bridge_config = self | ||
| .orchestrator_config | ||
| .clone() | ||
| .unwrap_or_default(); | ||
| let bridge = Arc::new(InferenceBridge::new(bridge_config)); | ||
| let invocation_router = Arc::new(InvocationRouter::new( | ||
| self.registry.clone(), | ||
| bridge, | ||
| )); | ||
| router = router | ||
| .merge(openai_router()) | ||
| .layer(axum::Extension(invocation_router)); |
There was a problem hiding this comment.
Same concern as the Socket.IO branch: this always initializes an InferenceBridge (and mounts /v1/chat/completions) even when orchestrator_config is None, which changes startup behavior and may do unnecessary hardware detection. Consider gating LocalInference behind explicit configuration and returning GatewayError::NotConfigured("inference") when inference isn’t enabled.
| //! that route requests through [`InvocationTarget`] dispatch: | ||
| //! | ||
| //! 1. **Resolve** — check the [`AgentRegistry`] for a matching agent. | ||
| //! 2. **Dispatch** — route to the registered agent OR fall back to the | ||
| //! [`InferenceOrchestrator`] when no agent is found. |
There was a problem hiding this comment.
The module docs use intra-doc links like [InvocationTarget], [AgentRegistry], and [InferenceOrchestrator] but those names aren’t in scope in this module. This can produce broken intra-doc link warnings. Prefer fully-qualified paths (e.g. [mofa_kernel::gateway::InvocationTarget]) or import the types solely for doc-link resolution.
| #[tokio::test] | ||
| async fn test_chat_completions_unknown_model_falls_back_to_inference() { | ||
| let router_ext = make_router_ext(); | ||
| // No agent registered — should fall through to LocalInference | ||
| let result = chat_completions( | ||
| router_ext, | ||
| HeaderMap::new(), | ||
| Json(make_request("gpt-4")), | ||
| ) | ||
| .await; | ||
| assert!(result.is_ok()); |
There was a problem hiding this comment.
This test only asserts result.is_ok() and would still pass if the handler returned a malformed OpenAI response (or routed to the wrong target). Since the handler now does resolution + dispatch, it would be more robust to also assert key response fields (e.g. object, id prefix, choices[0].message.role) and add a second test case where an agent is registered and the response reflects the agent route.
| //! | ||
| //! [`InvocationRouter`] resolves an incoming OpenAI-compatible request to an | ||
| //! [`InvocationTarget`] by consulting the [`AgentRegistry`] first, then | ||
| //! falling back to the local [`InferenceOrchestrator`] when no registered |
There was a problem hiding this comment.
The top-level docs reference [InferenceOrchestrator], but that type isn’t in scope in this module, so the intra-doc link may be broken. Consider using a fully-qualified path (e.g. mofa_foundation::inference::InferenceOrchestrator) or importing it for documentation purposes.
| //! falling back to the local [`InferenceOrchestrator`] when no registered | |
| //! falling back to the local | |
| //! [`mofa_foundation::inference::InferenceOrchestrator`] when no registered |
| InvocationTarget::Proxy { ref url } => { | ||
| // Proxy dispatch is handled by `crates/mofa-gateway/src/proxy/`. | ||
| Err(GatewayError::Internal(format!( | ||
| "Proxy dispatch to '{}' is not supported through InvocationRouter; \ | ||
| use the dedicated proxy module instead.", | ||
| url | ||
| ))) |
There was a problem hiding this comment.
InvocationTarget::Proxy currently returns GatewayError::Internal(...). Since GatewayError::Internal maps to HTTP 500, this will look like a server bug rather than an unsupported/configuration state. Consider returning GatewayError::NotConfigured("proxy") (501) or GatewayError::InvalidRequest(...) (400) so clients get an accurate status code if this variant becomes reachable via routing config.
| InvocationTarget::Proxy { ref url } => { | |
| // Proxy dispatch is handled by `crates/mofa-gateway/src/proxy/`. | |
| Err(GatewayError::Internal(format!( | |
| "Proxy dispatch to '{}' is not supported through InvocationRouter; \ | |
| use the dedicated proxy module instead.", | |
| url | |
| ))) | |
| InvocationTarget::Proxy { .. } => { | |
| // Proxy dispatch is handled by `crates/mofa-gateway/src/proxy/`. | |
| Err(GatewayError::NotConfigured("proxy")) |
| Ok(ChatCompletionResponse { | ||
| id: format!("chatcmpl-{}", uuid::Uuid::new_v4()), | ||
| object_type: "chat.completion".to_string(), | ||
| created: std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) |
There was a problem hiding this comment.
The Agent dispatch path manually constructs ChatCompletionResponse (id/object/created/usage). Similar response-building logic already exists in inference_bridge (e.g., convert_to_openai_response) and elsewhere; duplicating it increases the risk of response-shape drift over time. Consider extracting a shared helper (e.g., build_chat_completion_response(model, content, prompt_tokens, completion_tokens)) and reusing it here and in the bridge.
| InvocationTarget::Agent { agent_id } => | ||
| write!(f, "Agent({})", agent_id), | ||
| InvocationTarget::LocalInference { model } => | ||
| write!(f, "LocalInference({})", model), | ||
| InvocationTarget::Proxy { url } => | ||
| write!(f, "Proxy({})", url), |
There was a problem hiding this comment.
InvocationTarget::Proxy is formatted with the full url in Display. Since InvocationTarget is used in logs/metrics, this risks leaking sensitive URL components (e.g., embedded credentials, internal hostnames). Consider redacting the URL (log only scheme/host, or a hash), or make Display omit variant payloads and use explicit structured fields when needed.
| InvocationTarget::Agent { agent_id } => | |
| write!(f, "Agent({})", agent_id), | |
| InvocationTarget::LocalInference { model } => | |
| write!(f, "LocalInference({})", model), | |
| InvocationTarget::Proxy { url } => | |
| write!(f, "Proxy({})", url), | |
| InvocationTarget::Agent { .. } => | |
| write!(f, "Agent"), | |
| InvocationTarget::LocalInference { .. } => | |
| write!(f, "LocalInference"), | |
| InvocationTarget::Proxy { .. } => | |
| write!(f, "Proxy"), |
| // Always add OpenAI router with InvocationTarget routing. | ||
| // InvocationRouter checks the registry first; if no agent matches | ||
| // it falls back to InferenceBridge → InferenceOrchestrator. | ||
| let bridge_config = self | ||
| .orchestrator_config | ||
| .clone() | ||
| .unwrap_or_default(); | ||
| let bridge = Arc::new(InferenceBridge::new(bridge_config)); | ||
| let invocation_router = Arc::new(InvocationRouter::new( | ||
| self.registry.clone(), | ||
| bridge, | ||
| )); | ||
| router = router | ||
| .merge(openai_router()) | ||
| .layer(axum::Extension(invocation_router)); |
There was a problem hiding this comment.
GatewayServer::new(...) previously did not mount the OpenAI routes unless orchestrator_config was provided. This change always constructs an InferenceBridge with OrchestratorConfig::default(), which triggers hardware detection in InferenceOrchestrator::new and changes the exposed API surface (the endpoint now exists even when inference wasn’t configured). If the intent is “agents-only unless inference is enabled”, consider keeping the router mounted but making InvocationRouter hold an Option<InferenceBridge> and returning GatewayError::NotConfigured("inference") for LocalInference when orchestrator_config is None, to avoid surprising startup cost/behavior.
| // Always add OpenAI router with InvocationTarget routing. | |
| // InvocationRouter checks the registry first; if no agent matches | |
| // it falls back to InferenceBridge → InferenceOrchestrator. | |
| let bridge_config = self | |
| .orchestrator_config | |
| .clone() | |
| .unwrap_or_default(); | |
| let bridge = Arc::new(InferenceBridge::new(bridge_config)); | |
| let invocation_router = Arc::new(InvocationRouter::new( | |
| self.registry.clone(), | |
| bridge, | |
| )); | |
| router = router | |
| .merge(openai_router()) | |
| .layer(axum::Extension(invocation_router)); | |
| // Only add OpenAI routing when inference is explicitly configured. | |
| // This preserves the previous agents-only behavior when no | |
| // orchestrator configuration is provided and avoids unexpected | |
| // inference initialization side effects during startup. | |
| if let Some(bridge_config) = self.orchestrator_config.clone() { | |
| let bridge = Arc::new(InferenceBridge::new(bridge_config)); | |
| let invocation_router = Arc::new(InvocationRouter::new( | |
| self.registry.clone(), | |
| bridge, | |
| )); | |
| router = router | |
| .merge(openai_router()) | |
| .layer(axum::Extension(invocation_router)); | |
| } |
|
@BH3GEI kindly review this PR sir. |
Summary
This PR replaces the hard-coded mock response (
"Hello from MoFA gateway!") in the/v1/chat/completionsendpoint with a robust, kernel-level InvocationTarget routing layer.By introducing the
InvocationRouter, the gateway now resolves requests through theAgentRegistryfirst, providing a seamless "Agentic OS" experience where registered agents can overshadow or complement local model inference.Architecture
The request flow is now explicitly decoupled from the HTTP handler: