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( 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" },