From c917ffbf457048fe2c4856ed891759e7755739a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Jun 2026 19:19:39 +0000 Subject: [PATCH] Release v0.11.2 --- RELEASE_NOTES.md | 3 +- dispatch_cli/router/service.py | 73 +++++++++++++++----- pyproject.toml | 2 +- tests/test_local_llm_proxy.py | 121 +++++++++++++++++++++++++++++++++ uv.lock | 8 +-- 5 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 tests/test_local_llm_proxy.py diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 66ebdc7..1fa83ed 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. \ No newline at end of file +## Bug Fixes +- MCP servers managed by Dispatch are now correctly skipped during local development, preventing conflicts when running agents locally. \ No newline at end of file diff --git a/dispatch_cli/router/service.py b/dispatch_cli/router/service.py index 2e07373..db943af 100644 --- a/dispatch_cli/router/service.py +++ b/dispatch_cli/router/service.py @@ -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.""" @@ -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" @@ -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) @@ -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: @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 74bdfc2..c63470d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}, diff --git a/tests/test_local_llm_proxy.py b/tests/test_local_llm_proxy.py new file mode 100644 index 0000000..f4721cc --- /dev/null +++ b/tests/test_local_llm_proxy.py @@ -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 diff --git a/uv.lock b/uv.lock index e557dfa..904658f 100644 --- a/uv.lock +++ b/uv.lock @@ -545,7 +545,7 @@ wheels = [ [[package]] name = "dispatch-agents" -version = "0.14.1" +version = "0.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -558,14 +558,14 @@ dependencies = [ { name = "pyyaml" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/b2488b98dfcc0974e7390058cdae3b61e55bfb241a9b75ae8fe883b49976/dispatch_agents-0.14.1.tar.gz", hash = "sha256:4f6d82a4b140a19b3c082438e02d850303f5388ae1806ea2ca699bf29902ecec", size = 587934, upload-time = "2026-06-10T18:36:58.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/fe/4b09319cf493bacf073d42b33ad5ecd010d0f7cf93cb0c1a0765b0c5a4ae/dispatch_agents-0.14.2.tar.gz", hash = "sha256:49c7d972c6ebd16be10ed8f3f10a622ba1c733efe279bf781b410ac1a04f4236", size = 587899, upload-time = "2026-06-11T00:07:01.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/c4/b165540762711193be24d6f689fa20268fc64029d80c4574bc87bbb44a47/dispatch_agents-0.14.1-py3-none-any.whl", hash = "sha256:9da74ed1d403185be88f112192e8139fb36d5290c304239a6f58664b09fdced3", size = 116971, upload-time = "2026-06-10T18:36:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/68759b42e79f657bda3ed4ad7a14618c9dc574d305e655db5787f436b7fb/dispatch_agents-0.14.2-py3-none-any.whl", hash = "sha256:eb55d2c2d69e5e4d88ccd8a0b177272c6d534ea3b65bd5a40143e36db817c99f", size = 116956, upload-time = "2026-06-11T00:07:02.48Z" }, ] [[package]] name = "dispatch-cli" -version = "0.11.1" +version = "0.11.2" source = { editable = "." } dependencies = [ { name = "aiohttp" },