Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
### Breaking Changes - Behavioral toggles previously configured via DISPATCH_ environment variables have moved to fields in the dispatch.yaml configuration file. Update your configuration accordingly, as the old environment variables are no longer supported. ### Features - **Agent cloning and source download**: Agents can now be cloned with source retrieval support, making it easier to fork and build on existing agents. - **MCP create_agent promotion**: The create_agent tool is now surfaced as the required first step when onboarding new agents via MCP, improving guided workflows. - **LLM configuration warnings and run history** are now surfaced in the local UI - **Redesigned local development UI** with URL-based routing, a resizable output tray, a topic feed, run history panel, and per-agent function details panel, providing a significantly improved local development experience. ### Bug Fixes - Fixed a port detection issue in the local development server that could prevent the UI from connecting correctly.
## Bug Fixes
- MCP servers managed by Dispatch are now correctly skipped during local development, preventing conflicts when running agents locally.
73 changes: 55 additions & 18 deletions dispatch_cli/router/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2055,6 +2055,45 @@ async def local_llm_inference(request_data: LLMInferenceRequest):
}


# Mirrors backend ENDPOINT_TO_FORMAT / _format_from_endpoint
# (llm-proxy/llm_provider_service.py). Newer SDKs (PR #384) no longer send
# provider_format on /llm/proxy, so the local router derives it from the
# endpoint path the same way the production gateway does.
_ENDPOINT_TO_FORMAT: dict[str, str] = {
"/v1/chat/completions": "openai",
"/v1/responses": "openai",
"/v1/messages": "anthropic",
}


def _format_from_endpoint(endpoint: str) -> str | None:
"""Derive wire format from an endpoint path; None for unknown paths.

Sub-paths like /v1/messages/count_tokens match their parent prefix.
"""
for prefix, fmt in _ENDPOINT_TO_FORMAT.items():
if endpoint == prefix or endpoint.startswith(prefix + "/"):
return fmt
return None


def _resolve_provider(provider_format: str | None, path: str) -> str:
"""Resolve the wire format from an explicit value or the request path.

Raises HTTP 400 when neither yields a supported provider.
"""
provider = provider_format or _format_from_endpoint(path)
if not provider or provider not in _PROXY_PROVIDER_CONFIG:
raise HTTPException(
status_code=400,
detail=(
f"Cannot resolve provider for '{path}' "
f"(provider_format={provider_format!r}). Supported: openai, anthropic"
),
)
return provider


class _ProviderResponseInfo:
"""Parsed fields from a raw provider response, for trace logging."""

Expand Down Expand Up @@ -2156,7 +2195,10 @@ def _extract_provider_response_info(
class LLMProxyRequest(StrictBaseModel):
"""Request from the sidecar proxy for chat/messages endpoints."""

provider_format: str = Field(description="Provider: 'openai' or 'anthropic'")
provider_format: str | None = Field(
default=None,
description="Wire format: 'openai' or 'anthropic'. Derived from endpoint when omitted.",
)
body: dict[str, Any] = Field(description="Raw SDK request body")
endpoint: str = Field(
description="Provider endpoint path, e.g. /v1/chat/completions or /v1/responses"
Expand All @@ -2171,7 +2213,14 @@ class LLMProxyRequest(StrictBaseModel):
class LLMPassthroughRequest(StrictBaseModel):
"""Request from the sidecar proxy for unsupported/passthrough endpoints."""

provider_format: str = Field(description="Provider: 'openai' or 'anthropic'")
provider_format: str | None = Field(
default=None,
description=(
"Wire format: 'openai' or 'anthropic'. The sidecar normally sends "
"this; if omitted it is derived from the path, which only resolves "
"for known prefixes (/v1/chat/completions, /v1/responses, /v1/messages)."
),
)
path: str = Field(description="Provider API path, e.g. '/v1/embeddings'")
method: str = Field(default="POST", description="HTTP method")
body: dict[str, Any] | None = Field(default=None)
Expand Down Expand Up @@ -2200,14 +2249,8 @@ async def llm_proxy(request_data: LLMProxyRequest):
from dispatch_cli.router.local_llm import LocalLLMError, get_api_key

logger = get_logger()
provider = request_data.provider_format

config = _PROXY_PROVIDER_CONFIG.get(provider)
if not config:
raise HTTPException(
status_code=400,
detail=f"Unsupported provider_format: '{provider}'. Supported: openai, anthropic",
)
provider = _resolve_provider(request_data.provider_format, request_data.endpoint)
config = _PROXY_PROVIDER_CONFIG[provider]

# Get API key from environment / Keychain
try:
Expand Down Expand Up @@ -2509,14 +2552,8 @@ async def llm_passthrough(request_data: LLMPassthroughRequest):
from dispatch_cli.router.local_llm import LocalLLMError, get_api_key

logger = get_logger()
provider = request_data.provider_format

config = _PROXY_PROVIDER_CONFIG.get(provider)
if not config:
raise HTTPException(
status_code=400,
detail=f"Unsupported provider_format: '{provider}'. Supported: openai, anthropic",
)
provider = _resolve_provider(request_data.provider_format, request_data.path)
config = _PROXY_PROVIDER_CONFIG[provider]

# Get API key from environment / Keychain
try:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "dispatch-cli"
version = "0.11.1"
version = "0.11.2"
description = ""
authors = [
{name = "Diamond Bishop", email = "diamond.bishop@datadoghq.com"},
Expand Down
121 changes: 121 additions & 0 deletions tests/test_local_llm_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Tests for the local router LLM proxy endpoint (/llm/proxy).

Validates that the router derives the wire format from the endpoint path when
the SDK omits ``provider_format`` (SDK PR #384 dropped it from /llm/proxy
payloads), mirroring the production backend's _format_from_endpoint fallback.
"""

from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi.testclient import TestClient


@pytest.fixture
def test_client():
"""Create a test client for the router service."""
from fastapi import FastAPI

from dispatch_cli.router.service import api_router

app = FastAPI()
app.include_router(api_router)
return TestClient(app)


@pytest.fixture
def mock_get_api_key():
"""Mock API key retrieval so no real credentials are needed."""
with patch(
"dispatch_cli.router.local_llm.get_api_key",
return_value="sk-test-key",
) as mock:
yield mock


@pytest.fixture
def capture_provider_post():
"""Mock httpx.AsyncClient and capture the URL the proxy forwards to."""
posted = {}

async def fake_post(url, json=None, headers=None):
posted["url"] = url
resp = MagicMock()
resp.status_code = 200
resp.content = b'{"ok": true}'
resp.json.return_value = {"ok": True}
return resp

client = MagicMock()
client.post = AsyncMock(side_effect=fake_post)

@asynccontextmanager
async def fake_client(*args, **kwargs):
yield client

with patch("dispatch_cli.router.service.httpx.AsyncClient", fake_client):
yield posted


class TestProxyFormatResolution:
"""The router must accept payloads that omit provider_format."""

def test_responses_endpoint_routes_to_openai_without_provider_format(
self, test_client, mock_get_api_key, capture_provider_post
):
"""An OpenAI Responses call with no provider_format routes to OpenAI (no 422)."""
response = test_client.post(
"/llm/proxy",
json={
"body": {"model": "gpt-5.4", "input": []},
"endpoint": "/v1/responses",
},
)
assert response.status_code == 200
assert capture_provider_post["url"].startswith("https://api.openai.com")

def test_messages_endpoint_routes_to_anthropic_without_provider_format(
self, test_client, mock_get_api_key, capture_provider_post
):
"""An Anthropic Messages call with no provider_format routes to Anthropic."""
response = test_client.post(
"/llm/proxy",
json={
"body": {"model": "claude-3", "messages": []},
"endpoint": "/v1/messages",
},
)
assert response.status_code == 200
assert capture_provider_post["url"].startswith("https://api.anthropic.com")

def test_explicit_provider_format_still_honored(
self, test_client, mock_get_api_key, capture_provider_post
):
"""An explicit provider_format takes precedence (passthrough SDKs still send it)."""
response = test_client.post(
"/llm/proxy",
json={
"provider_format": "openai",
"body": {"model": "gpt-5.4", "input": []},
"endpoint": "/v1/responses",
},
)
assert response.status_code == 200
assert capture_provider_post["url"].startswith("https://api.openai.com")

def test_unknown_endpoint_without_provider_format_returns_400(
self, test_client, mock_get_api_key
):
"""An unresolvable endpoint yields the clear 400, not a misroute or 422."""
response = test_client.post(
"/llm/proxy",
json={
"body": {"model": "whatever"},
"endpoint": "/v1/unknown",
},
)
assert response.status_code == 400
detail = response.json()["detail"]
assert "Cannot resolve provider" in detail
assert "/v1/unknown" in detail
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading