From 6a5e2036b67421e67942be46b6f1ae8038e659cc Mon Sep 17 00:00:00 2001 From: Ryan Beckett Date: Sat, 19 Sep 2026 14:13:27 -0700 Subject: [PATCH 1/2] Report an unroutable classifier cell as no_route /classify returned invalid_request when none of the offered models appeared in the classifier routing cell. That condition is not a caller mistake. The request is well formed and the router simply has nothing it can serve, which is what no_route means. /route already reports the same condition that way, and routing_table.route documents the rule the service follows, RoutingError for invalid requests and NoRouteError when no supported candidate remains. Classification was the one path that drifted from it. The asymmetry forced callers to branch on the endpoint to recognise a routine outcome. A client that handled no_route correctly for /route would misreport the identical situation from /classify as a contract violation, pointing operators at a bug that does not exist. Raise NoRouteError for the empty intersection so both endpoints share one taxonomy. Malformed input keeps invalid_request, including a missing authored user message and an omitted effort for a model that requires one. The CLI now exits 3 rather than 2 for this case, matching route. Add a contract case covering a non-empty pool whose models are all unrecognised. The existing case offered no models at all, so the ordinary production shape of this failure went unrecorded. --- Dockerfile | 2 +- openapi.yaml | 12 ++++++++++-- pyproject.toml | 2 +- src/gh_aw_router/__init__.py | 2 +- src/gh_aw_router/classification.py | 8 +++++--- tests/fixtures/routing-contract/classify.json | 10 +++++++++- tests/fixtures/routing-contract/discovery.json | 2 +- tests/test_classification.py | 3 ++- tests/test_cli.py | 8 +++++--- 9 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 436fd7a..6701881 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12.14-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea -ARG VERSION=0.1.0 +ARG VERSION=0.1.1 ARG VCS_REF=unknown ARG OPENAPI_SHA256 ARG PIP_INDEX_URL=https://pypi.org/simple diff --git a/openapi.yaml b/openapi.yaml index cb260a9..2f0e7ca 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: gh-aw-router HTTP API - version: 0.1.0 + version: 0.1.1 license: name: MIT identifier: MIT @@ -81,6 +81,8 @@ paths: Model choices follow the embedded classification_choices list, filtered to exact offered models and efforts. The first returned choice is preferred. Reasoning models require an explicit effort. No default effort is inferred. + If no offered choice is in the classifier cell, the response is 422 with + code no_route. Malformed requests are still rejected with invalid_request. requestBody: required: true content: @@ -103,7 +105,13 @@ paths: "415": $ref: "#/components/responses/UnsupportedMediaType" "422": - $ref: "#/components/responses/InvalidRequest" + description: >- + Request schema violation (invalid_json), unsupported operation + (invalid_request), or no eligible classifier choice (no_route). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" "504": diff --git a/pyproject.toml b/pyproject.toml index 526998b..e933760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gh-aw-router" -version = "0.1.0" +version = "0.1.1" description = "Standalone classification planning and model routing service" readme = "README.md" license = "MIT" diff --git a/src/gh_aw_router/__init__.py b/src/gh_aw_router/__init__.py index 81fc1a2..af65165 100644 --- a/src/gh_aw_router/__init__.py +++ b/src/gh_aw_router/__init__.py @@ -1,3 +1,3 @@ """Standalone gh-aw-router classification planning and model routing.""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/src/gh_aw_router/classification.py b/src/gh_aw_router/classification.py index b114567..9a255eb 100644 --- a/src/gh_aw_router/classification.py +++ b/src/gh_aw_router/classification.py @@ -18,6 +18,7 @@ TaskType, TextPart, ) +from gh_aw_router.routing import NoRouteError MAX_PRIOR_TURNS: Final = 6 MAX_PRIOR_TURN_CHARS: Final = 400 @@ -108,8 +109,9 @@ def create_classification_plan( """Rank exact offered choices by the embedded classifier preference order. Preserve caller identities and prefer the first matching table entry. Raise - ClassificationError for missing authored text, omitted reasoning effort, or - an empty intersection. No provider calls or default-effort inference occur. + ClassificationError for missing authored text or omitted reasoning effort, + and NoRouteError when no offered choice is in the classifier cell. No + provider calls or default-effort inference occur. """ authored = authored_messages(request.conversation) if not authored: @@ -139,7 +141,7 @@ def create_classification_plan( ) ) if not ranked: - raise ClassificationError("none of the available models is in the classifier routing cell") + raise NoRouteError("none of the available models is in the classifier routing cell") return ClassifyResponse( system_prompt=SYSTEM_PROMPT, prompt=build_classification_prompt(authored), diff --git a/tests/fixtures/routing-contract/classify.json b/tests/fixtures/routing-contract/classify.json index b245031..c5aa586 100644 --- a/tests/fixtures/routing-contract/classify.json +++ b/tests/fixtures/routing-contract/classify.json @@ -21,7 +21,7 @@ "conversation": [{"role": "user", "parts": [{"text": "Proceed."}]}], "models": [] }, - "status": 422, "response": {"code": "invalid_request", "detail": "invalid request: none of the available models is in the classifier routing cell"} + "status": 422, "response": {"code": "no_route", "detail": "no route: none of the available models is in the classifier routing cell"} }, { "id": "classify-missing-effort", "method": "POST", "path": "/classify", @@ -30,5 +30,13 @@ "models": [{"id": "reasoning", "model": "github-copilot/router-reasoning"}] }, "status": 422, "response": {"code": "invalid_request", "detail": "invalid request: reasoning effort must be specified for 'github-copilot/router-reasoning'"} + }, + { + "id": "classify-unrecognised-choices", "method": "POST", "path": "/classify", + "request": { + "conversation": [{"role": "user", "parts": [{"text": "Proceed."}]}], + "models": [{"id": "absent", "model": "github-copilot/router-absent"}] + }, + "status": 422, "response": {"code": "no_route", "detail": "no route: none of the available models is in the classifier routing cell"} } ] \ No newline at end of file diff --git a/tests/fixtures/routing-contract/discovery.json b/tests/fixtures/routing-contract/discovery.json index 599fa26..068f2bd 100644 --- a/tests/fixtures/routing-contract/discovery.json +++ b/tests/fixtures/routing-contract/discovery.json @@ -3,7 +3,7 @@ { "id": "capabilities", "method": "GET", "path": "/capabilities", "status": 200, "response": { - "name": "gh-aw-router", "version": "0.1.0", + "name": "gh-aw-router", "version": "0.1.1", "routing_profiles": [ {"goal": "cost", "mode": "economy"}, {"goal": "cost", "mode": "balanced"}, diff --git a/tests/test_classification.py b/tests/test_classification.py index 8428a3e..5c4eb77 100644 --- a/tests/test_classification.py +++ b/tests/test_classification.py @@ -25,6 +25,7 @@ RoutingModeRecommendation, TextPart, ) +from gh_aw_router.routing import NoRouteError def test_shared_classify_request_matches_policy_order( @@ -109,7 +110,7 @@ def test_classification_requires_an_offered_routing_identity() -> None: models=(ModelChoice(id="other", model="provider/other"),), ) - with pytest.raises(ClassificationError, match="none of the available models"): + with pytest.raises(NoRouteError, match="none of the available models"): create_classification_plan(request, (ModelArm(model="provider/preferred"),)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 65b57dd..13f52fd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -149,19 +149,21 @@ def test_validate_data_reports_the_loaded_table_summary() -> None: ] +@pytest.mark.parametrize("command", ["route", "classify"]) @pytest.mark.parametrize("unsupported", [False, True]) def test_no_route_has_a_distinct_exit_status( planning_payload: Callable[[str], dict[str, Any]], synthetic_table_path: Path, unsupported: bool, + command: str, ) -> None: - request = planning_payload("route") + request = planning_payload(command) request["models"] = [{"id": "unsupported", "model": "provider/unknown"}] if unsupported else [] stdout = io.StringIO() stderr = io.StringIO() status = run( - ["--routing-tables", str(synthetic_table_path), "route"], + ["--routing-tables", str(synthetic_table_path), command], stdin=io.StringIO(json.dumps(request)), stdout=stdout, stderr=stderr, @@ -224,7 +226,7 @@ def test_parser_exposes_package_version(capsys: pytest.CaptureFixture[str]) -> N cli.build_parser().parse_args(["--version"]) assert error.value.code == 0 - assert capsys.readouterr().out == "gh-aw-router 0.1.0\n" + assert capsys.readouterr().out == "gh-aw-router 0.1.1\n" @pytest.mark.parametrize( From 14beedef13f075c3000b4871bb717deaa2625c2d Mon Sep 17 00:00:00 2001 From: Ryan Beckett Date: Sat, 19 Sep 2026 14:17:16 -0700 Subject: [PATCH 2/2] Record the 0.1.1 project version in uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index bc9a4c1..eacf523 100644 --- a/uv.lock +++ b/uv.lock @@ -78,7 +78,7 @@ wheels = [ [[package]] name = "gh-aw-router" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "fastapi" },