Skip to content
Merged
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
23 changes: 18 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ uv run python -m pytest --run-docker -m docker
Release tests require uv and may download dependencies. They build source and wheel archives,
test an extracted source tree, and exercise a non-editable wheel installation. Docker tests
require a running Linux daemon. They build one image, exercise all six bundled profiles, and
check a read-only replacement table. Neither group runs by default. The CI workflow is
check read-only replacement tables and the portable corpus on Linux amd64. Set
`GH_AW_ROUTER_TEST_IMAGE` to test an already-built image instead of building and removing one.
Neither group runs by default. The CI workflow is
configured to run both groups separately.

## Project layout
Expand Down Expand Up @@ -56,10 +58,21 @@ Use the shared synthetic table and request fixtures in `tests/conftest.py` for g
service, and transport tests. Reserve the bundled-table `service` fixture for release-data and
example integration checks so ranking refreshes do not affect unrelated behavior tests.

API version, package version, and routing-table schema version have different lifecycles.
Breaking request changes require a new API version and migration notes. Public contracts and
table helpers are also used by the separate training package. Check those callers when changing
shared contracts.
The HTTP contract follows the router release. There is no API-version request field or
negotiation layer. Review breaking changes with affected consumers, include migration notes,
and test the client, immutable image, OpenAPI document, and corpus as one release combination.
Keep the table format marker because files and mounts can change independently of the image.
Public contracts and table helpers are also used by the separate training package. Check those
callers when changing shared contracts.

The portable cases live in [tests/fixtures/routing-contract](tests/fixtures/routing-contract).
Keep complete requests and reviewed expected responses. Tests and the source-only exporter
must not derive expectations from the current service. Review classifier prompt changes as
contract fixture changes. Invalid non-null classifier output belongs in rejection cases;
caller-authorized degradation uses omitted or null classification.

Keep release publication and credentialed attestations in separately reviewed,
explicitly permissioned jobs. Do not execute untrusted PR code through `pull_request_target`.

## Dependencies and routing data

Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ RUN python -m pip install \
&& rm /tmp/requirements.lock

COPY src /app/src
RUN python -c "import sys; from gh_aw_router import __version__; sys.exit(0 if sys.argv[1] == __version__ else 'VERSION must match the package version')" "$VERSION"
COPY routing /routing
COPY LICENSE /usr/share/doc/gh-aw-router/LICENSE

Expand Down
103 changes: 86 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ eligible model. `--help` and `--version` exit successfully without loading a tab

## HTTP API

The authoritative contract is [openapi.yaml](openapi.yaml), currently API version `0.2.0`. Use
the document from the matching release for validation and client generation. The service does
The authoritative contract is [openapi.yaml](openapi.yaml). Its document version follows the
router release. Use the matching document for validation and client generation. The service does
not serve a generated `/openapi.json`, `/docs`, or `/redoc`.

- `GET /healthz` for readiness
- `GET /capabilities` for the API version, served routing profiles, models, and efforts
- `GET /capabilities` for the router release, served routing profiles, models, and efforts
- `POST /classify` for a classification prompt and ranked classifier choices
- `POST /route` for ranked task-model choices

Expand All @@ -77,16 +77,18 @@ Routing ignores model-effort pairs absent from the table and returns `no_route`
supported, context-eligible choices remain. Unsupported objectives and malformed requests
are still rejected. Neither operation calls a provider or invents a fallback choice.

Both planning requests require `api_version` set to `"0.2.0"`, `repository` in `owner/repo`
form, a nonblank `task_id`, and a `conversation` holding at least one user message with
nonblank text. Keep the repository and task identifiers stable for one task across
classification, routing, and retries. They identify the caller's work but do not select a
policy or create stored state.
Both planning requests require a `conversation` holding at least one user message with
nonblank text. Requests contain only decision inputs. The caller retains execution
identity for accounting and outcome correlation. The router stores no task history.
Remove `repository` and `task_id` from older client requests. Both fields are rejected,
not ignored. Routing-table `repository` metadata is separate and remains unchanged.

To update API `0.1.0` requests, set `api_version` to `"0.2.0"` and move task labels into
`classification.labels`. Include `classification.mode`, using `"unknown"` when no mode was
inferred. Remove `objective.overrides` entirely, even when null, and replace `custom` with
`economy`, `balanced`, `robust`, or `auto`. Requests using API `0.1.0` are rejected.
Requests do not negotiate an independent API version. When updating an older client, remove
`api_version` from requests and stop expecting `api_versions` in capabilities. The removed
request field is rejected, not ignored. Test the client, OpenAPI document, contract fixtures,
and immutable router image together. Update them together when adopting a changed contract.
Keep the previous tested image for rollback rather than assuming any newer image is compatible.
The reported release is diagnostic metadata, not proof of an image's identity.

Application errors use a `code` and `detail` envelope, including unknown paths and unsupported
methods. Schema violations name the failing fields without echoing submitted values. Serving
Expand All @@ -103,7 +105,9 @@ while routing skips those choices. Omit effort only for models without effort se
string `"none"` is an explicit effort, not an omission. The service never infers effort settings.
Routing uses `classification.labels` when supplied. When `classification` is omitted or null,
deterministic heuristics infer task type and scope from the last authored user message and
leave complexity unknown.
leave complexity unknown. Every uninferred field remains `unknown`. For example, `Proceed.`
uses all three unknown labels, while `Fix this function.` infers `fix` and `local` but leaves
complexity unknown. The eligible choices still follow that table cell's ranking.

### Examples

Expand Down Expand Up @@ -142,11 +146,21 @@ message, with no tools. Its expected output has this shape, defined by
Forward the validated classifier output unchanged in `/route`'s `classification` field,
as the routing example does. `/classify` itself returns a call plan, not this inferred result.
The caller runs the classifier and passes its output to `/route`.
Successful classification plans always contain at least one eligible choice.
If no offered classifier choice is supported, `/classify` returns `422 invalid_request`.
An empty successful ranking is not a signal to continue without classification.
Before executing a plan, the caller must check the complete generated messages against
the classifier model's context capacity, including output and reasoning allowance.
Prompt escaping can expand the input. The HTTP request-size limit does not prove fit.

Set `objective.mode` to `"auto"` to use `classification.mode`, which recommends `economy`,
`balanced`, or `robust`. Auto falls back to `balanced` when `classification` is omitted or
null, or its mode is `unknown`. If classification fails, omit it or send null. A supplied
classification must include valid `labels` and `mode` fields or the request is rejected.
The caller must parse and validate raw classifier output, including invalid JSON, fenced
responses, and schema violations. The router does not repair that output or decide whether
caller policy permits continuing after a failure. This fallback never invents a middle-ranked
model or a default reasoning effort. Context exclusions and missing-profile errors still apply.

An explicit `economy`, `balanced`, or `robust` objective mode always takes precedence over
the classifier's mode. The goal remains the caller's choice of `cost` or `cost-speed`.
Expand Down Expand Up @@ -200,7 +214,8 @@ order, so they differ only in their routing order. Select a directory or a singl
read-only.

Only schema 5 is supported. A schema-5 table declares one fixed `cost` or `cost-speed` profile
and carries no default-effort aliases.
and carries no default-effort aliases. This format marker remains independent because tables
can be supplied through a file or mount without replacing the router image.

## Container

Expand All @@ -210,6 +225,9 @@ Build from this directory. The Docker build context is self-contained.
docker build --tag gh-aw-router:dev .
```

The optional `VERSION` build argument must match the package version. A mismatch fails
the build rather than publishing image metadata that disagrees with the CLI or service.

The image bundles every published table under `/routing` and serves all six profiles. It runs as
user `10001:10001` and needs no credentials or outbound network. This invocation uses a private
network, a read-only filesystem, and no published host port. The service listens on port `8737`
Expand Down Expand Up @@ -238,11 +256,62 @@ To replace the bundled tables, add these options before the image name.
--env GH_AW_ROUTER_ROUTING_TABLES=/mnt/routing
```

## Contract fixtures and artifacts

[The portable corpus](tests/fixtures/routing-contract) covers all four endpoints
using synthetic model identities and six distinguishable table profiles. The same reviewed
request bytes run through HTTP adapter tests and the hardened Linux amd64 container tests.
Classifier prompts are fixed expected data, not regenerated during tests.

Endpoint files contain complete requests and reviewed expected responses. The test-only
`response_file` reference shares an exact classifier response between cases. The exporter
resolves it so consumers receive concrete JSON responses without a template language.

The archive contains this README, `cases.json`, six synthetic profiles in `tables/`,
`openapi.yaml`, the unchanged external `integration-contract.md`, and `manifest.json`.
Cases name the method, path, expected status, and optional headers. Send each UTF-8
`request_body` unchanged for wire replay. Default to `Content-Type: application/json`
when headers are omitted. A 204 response has no body. `raw_request` marks malformed
JSON, and `request_valid: false` marks a schema-invalid object.

A case's optional `profiles` list restricts the table files loaded. Otherwise load all
six. Mount the selected directory read-only and readable by UID/GID 10001. The fake model
identities are test data, not a provider catalogue. Never send them to live providers.

Compare successful responses exactly, including choice order, effort omission, and prompt
strings. For errors, compare status and stable `code`, then validate the full envelope
against OpenAPI. Fixture `detail` text is representative mock data, not a promise to
preserve incidental diagnostic wording. Verify archive and manifest checksums before use.

Export a corpus archive from a clean source checkout with Python 3.12 and development
dependencies installed. Supply the reviewed integration attachment and its expected checksum.
The attachment is preserved unchanged and is not included in the runtime package.

```bash
uv run --locked python tests/contract_corpus.py \
--integration-contract /path/to/integration-contract.md \
--integration-contract-sha256 "$INTEGRATION_CONTRACT_SHA256" \
--output dist/routing-contract.tar.gz
```

The command prints the archive checksum. Its manifest records the source SHA, router release,
table schema, attachment revision, and every payload file's SHA-256. A dirty or unidentified
checkout requires `--development` and is explicitly marked as non-release provenance.
Repeated exports with the same inputs and source state produce identical bytes. Repository
OpenAPI text uses LF in the archive, while the external attachment retains its original bytes.

Ordinary PR CI runs corpus, packaging, and native Linux amd64 Docker checks without
provider credentials. Use the exporter above to create a local contract archive.

Publication requires a separate reviewed source and registry authorization. A deployment pin
has the form `<approved-registry>/<repository>:<reviewed-tag>@sha256:<manifest-digest>`.
A local image ID or Docker archive checksum is not that registry manifest digest. Retain
supported immutable images and matching contract archives. Deliver security fixes through
supported release updates and tested client pin changes, not replacement bytes under old pins.

## Contributing and security

[CONTRIBUTING.md](CONTRIBUTING.md) covers development conventions and checks. The package
version, the planning API version, and the table schema version change independently, so read
all three from `/capabilities` and the loaded table rather than assuming they move together.
[CONTRIBUTING.md](CONTRIBUTING.md) covers development conventions and checks.

The service intentionally has no authentication or TLS. Keep it on a private network. Report
vulnerabilities through [SECURITY.md](SECURITY.md). The code is licensed under [MIT](LICENSE).
3 changes: 0 additions & 3 deletions examples/classify-request.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"api_version": "0.2.0",
"repository": "acme/widgets",
"task_id": "issue-123",
"conversation": [
{
"role": "user",
Expand Down
3 changes: 0 additions & 3 deletions examples/route-request.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"api_version": "0.2.0",
"repository": "acme/widgets",
"task_id": "issue-123",
"objective": {
"goal": "cost",
"mode": "auto"
Expand Down
57 changes: 18 additions & 39 deletions openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
openapi: 3.1.0
info:
title: gh-aw-router HTTP API
version: 0.2.0
version: 0.1.0
license:
name: MIT
identifier: MIT
description: |
Stateless classification planning and model routing for trusted host adapters.
gh-aw-router does not call model providers. The caller executes the returned
classifier prompts and dispatches one of the returned route choices.
This document describes the matching router release. Clients pin a tested
image digest; requests do not negotiate an independent API version.
Each route request supplies a goal, either cost or cost-speed, and a mode, either
economy, balanced, robust, or auto. Auto uses the supplied classification's mode,
falling back to balanced when classification is absent or its mode is unknown.
/capabilities lists the fixed profiles the loaded tables serve.
Schema-5 tables each carry one fixed profile.
Older schemas are rejected. Unserved profiles return invalid_request without fallback.
Both planning requests require repository and task_id as caller metadata.
These identifiers do not select a routing policy or cause data to be stored.
Planning requests contain only decision inputs. Execution identity and
outcome correlation remain with the caller; the router stores no task state.
Every application error uses the Error envelope, including unknown paths,
which return 404 with code not_found. Under overload the server may return a
bare 503 before the application runs.
Expand Down Expand Up @@ -50,7 +52,7 @@ paths:
/capabilities:
get:
operationId: getCapabilities
summary: Read supported contracts, profiles, models, and efforts
summary: Read release identity, profiles, models, and efforts
responses:
"200":
description: Current service capabilities.
Expand Down Expand Up @@ -120,6 +122,8 @@ paths:
Task labels are supplied only in classification.labels.
Without classification, deterministic heuristics infer task type
and scope from the last authored user message and leave complexity unknown.
Every uninferred field remains unknown, including all three fields when
no heuristic cues match. Invalid non-null classification is rejected.
The resolved profile must be loaded or the request returns invalid_request.
Every returned choice is one of the exact choices supplied by the caller.
Model-effort pairs absent from the routing table are ignored, including
Expand Down Expand Up @@ -150,7 +154,9 @@ paths:
"415":
$ref: "#/components/responses/UnsupportedMediaType"
"422":
description: Invalid request or no eligible route.
description: >-
Request schema violation (invalid_json), unsupported operation
(invalid_request), or no eligible route (no_route).
content:
application/json:
schema:
Expand All @@ -163,7 +169,7 @@ paths:
components:
responses:
InvalidJson:
description: The body is malformed or does not match the closed request schema.
description: The body is malformed JSON.
content:
application/json:
schema:
Expand All @@ -189,7 +195,9 @@ components:
schema:
$ref: "#/components/schemas/Error"
InvalidRequest:
description: Decoded values are unsupported or cannot produce an operation.
description: >-
The body violates the closed request schema (invalid_json), or decoded
values are unsupported or cannot produce an operation (invalid_request).
content:
application/json:
schema:
Expand All @@ -211,15 +219,8 @@ components:
ClassifyRequest:
type: object
additionalProperties: false
required: [api_version, repository, task_id, conversation, models]
required: [conversation, models]
properties:
api_version:
type: string
const: "0.2.0"
repository:
$ref: "#/components/schemas/Repository"
task_id:
$ref: "#/components/schemas/TaskId"
conversation:
$ref: "#/components/schemas/Conversation"
models:
Expand All @@ -240,6 +241,7 @@ components:
type: string
ranked_choices:
type: array
minItems: 1
items:
$ref: "#/components/schemas/ModelChoice"

Expand All @@ -256,15 +258,8 @@ components:
RouteRequest:
type: object
additionalProperties: false
required: [api_version, repository, task_id, objective, conversation]
required: [objective, conversation]
properties:
api_version:
type: string
const: "0.2.0"
repository:
$ref: "#/components/schemas/Repository"
task_id:
$ref: "#/components/schemas/TaskId"
objective:
$ref: "#/components/schemas/RoutingObjective"
conversation:
Expand All @@ -287,17 +282,6 @@ components:
items:
$ref: "#/components/schemas/ModelCandidate"

Repository:
type: string
pattern: '^[^/\s]+/[^/\s]+$'
description: GitHub repository in owner/repo form. Caller metadata, not policy selection.

TaskId:
type: string
minLength: 1
pattern: '\S'
description: Opaque task identifier, stable across classification, routing, and retries.

RouteResponse:
type: object
additionalProperties: false
Expand Down Expand Up @@ -498,18 +482,13 @@ components:
required:
- name
- version
- api_versions
- routing_profiles
- execution_catalogue
properties:
name:
type: string
version:
type: string
api_versions:
type: array
items:
type: string
routing_profiles:
type: array
minItems: 1
Expand Down
Loading