Router: port the routing core and CLI to Go (built and tested, not shipped)
Maintainers usually reply within 1 day
A pull request for this has already been merged.
- #10313 by @rabeckett — merged
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 1/100
Research direction
Start by reading the Go port scope in this issue, the parity fixture README under containers/router/tests/fixtures/parity/, and the Python reference in containers/router/src/gh_aw_router/. The implementation and tests belong in containers/router/go/, with CI changes in .github/workflows/test-router.yml. Done means the Go CLI, routing core, fixture replay and ported tests work, and the temporary differential job compares both CLIs; the linked pull request shows work is already underway.
Written by the indexing model from the issue text.
Description
Part of github/gh-aw-firewall#10261
(step 2 of 7).
Prerequisite
The parity fixtures from #10306 are merged (#10307) at containers/router/tests/fixtures/parity/,
with their Python generator and replay test in tests/. The folder holds function-level
fixtures for the behavior that comes from Python itself and a README.md that specifies the
format, the reference versions and the approved decisions for the Go port. In short:
labels.json: heuristic labels (task_type,scope) for a user turn'stext.decoding.json: whether a request body decodes, is malformed JSON (HTTP 400) or breaks the
schema (HTTP 422), including the parser's exact depth and digit limits.context.json: the context estimate's character and token counts for a conversation.classification.json: the classifier prompt's<conversation_data>section, or the
no_authored_turnerror, plus the fixed system prompt and contract text.unicode/: Python 3.12's Unicode 15.0.0 character data.
CLI, HTTP, routing and table-loading behavior isn't in the fixtures. This step and the HTTP
step test it by porting the Python tests to Go.
Summary
Add a Go implementation of the router to containers/router/, covering:
- request contracts and strict decoding;
- routing-table loading and validation;
- heuristics, classification planning and routing;
- the
classify,routeandvalidate-dataCLI commands, plus--version.
CI builds and tests the Go code on Linux and Windows. The Go tests replay the parity fixtures
and port the Python unit and CLI tests, and a temporary differential job compares the Python
and Go CLIs. The container image keeps running Python until a later image cut-over. serve and
the HTTP server are a separate subsequent step.
Background
The Python reference is in containers/router/src/gh_aw_router/:
| Module | Role |
|---|---|
contracts.py |
Request and response models, strict pydantic decoding (decode_request), and ErrorCode |
routing_table.py |
Schema-version-1 table loading, load-time validation and per-profile ranking |
service.py |
Multi-profile service: shared-catalogue checks, auto-mode resolution and capabilities |
heuristics.py |
Deterministic cue matching on normalized user text |
classification.py |
Classifier prompt construction and ranking of classifier choices |
routing.py |
Context estimate, eligibility and errors |
cli.py |
argparse commands, environment defaults and exit codes |
The authoritative HTTP contract is containers/router/openapi.yaml. Python-specific behavior
is pinned by the fixtures in containers/router/tests/fixtures/parity/, and the rest of the
router's behavior by the Python tests in containers/router/tests/.
Scope
- A Go module in
containers/router/go/, apart from the Python package (src/,tests/,
pyproject.toml) so the two implementations don't mix during the port. The module targets
Go 1.27 with a pinned toolchain. Its tests read the shared routing tables, examples,
fixtures,openapi.yamlandDockerfilefrom the parent directory. At the image cut-over,
the folder can stay or move up with onegit mv. - Standard library only for non-test code. Use the standard library's own behavior
(encoding/json/v2,unicode,flag,net/httplater) where it differs from Python in
inconsequential ways, and record each difference in the fixture README's decisions table,
rather than reimplementing Python's text and JSON behavior. - One flat
mainpackage incontainers/router/go/*.go, next togo.mod, with tests in
*_test.gobeside it. The router is small, and amainpackage can't be imported, so it
needs neitherinternal/norcmd/. A suggested split by file follows; the names aren't
required:contract.go: types, error kinds and codes;decode.go: strict request decoding;table.go: table loading and validation;heuristics.go;classify.go;route.go: the context estimate, routing and the service;cli.goandmain.go: the CLI.
- Unit tests (fixture replay and ported Python tests), CLI integration tests and fuzz tests.
- Go jobs in
.github/workflows/test-router.yml, and a temporary differential job that
compares the Python and Go CLIs.
Specification
The parity fixtures and the decisions in the fixture README take precedence over the rules
below. This step adds decisions for the standard library's behavior: Go's Unicode data and
lowercasing, NaN and Infinity as malformed JSON, no nesting or digit limits, saturated
context windows, UTF-8 CLI output, and the
decoder's own error wording. Each affected fixture case is listed in a known-differences map in
the Go tests. Where the README records no decision, keep Python's behavior.
Dependencies and Unicode data
- Use Go's
unicodepackage andstrings.ToLowerfor whitespace, word characters and case
folding. Go 1.27 follows Unicode 17.0.0 and Python 3.12 follows 15.0.0, and Go lowercases
where Python casefolds. Together these change a handful oflabels.jsoncases (letters
added after Unicode 15, andfi,ſ,ß,İ). That's an accepted difference, not worth
generated tables. - Read routing tables from disk at runtime, as Python does. Don't embed them in the
binary.
Request decoding (contracts.py)
-
Models. Mirror the request and response models:
- field names, required and optional fields, enums and constraints;
- cross-field rules, such as "model choice ids must be unique".
-
Pinned behaviors. The decoder must reproduce the behaviors
decoding.jsonpins, other
than the recorded decisions:- key matching is case-sensitive and unknown keys are rejected;
- a repeated key keeps its last value, at any depth. Only that value is validated, and it
replaces the earlier ones rather than being merged with them (theduplicate-key-*
cases). Routing tables follow the same rule; - a part has exactly one member:
{"text": "…", "tool_call": null}is rejected, as
Python's closed part variants do; nullis rejected for non-nullable fields;- integer fields accept only JSON integers, not
1.0ortrue; - trailing whitespace is accepted;
- trailing data, a BOM, invalid UTF-8 and lone surrogate escapes are rejected.
encoding/json/v2gives most of these directly (case-sensitive names, unknown-member
rejection, invalid UTF-8 rejection). It merges a repeated object key into the same struct,
so collapse repeated keys to their last value before unmarshalling, and run a syntax pass
first so that syntax errors are told apart from schema violations. Keep
tool_call.inputas rawjsontext.Value. It rejectsNaNandInfinityand has no
Python-style nesting or digit limits, which are recorded decisions. -
Failure classes. These map to the subsequent HTTP server and CLI exits as follows:
Failure HTTP CLI Syntax error 400 invalid_jsonexit 2, invalid request:Schema violation 422 invalid_jsonexit 2, invalid request:Domain or planning error 422 invalid_requestexit 2, invalid request:No route 422 no_routeexit 3, no route: -
Error messages.
- The CLI prints the decoder's own message after
invalid request:. It doesn't have to
match pydantic's wording, because the tests compare only the exit status and the stderr
prefix. - Messages name schema locations and fixed reasons. They may name an unknown field, as
pydantic's location does, but never echo request values (Python's CLI does, through
pydantic'sinput_value). - The
request does not match the contract: …format of HTTP error details belongs to
the HTTP step. - Messages may name catalogue model ids and profile keys, because those come from the
tables. For example:reasoning effort must be specified for '<catalogue model>'.
- The CLI prints the decoder's own message after
Heuristics and the context estimate
- Heuristic patterns. Reproduce the patterns in
heuristics.pywithout regex
lookarounds. At each start position whose preceding character isn't a word character
(for the two-sided form), try the cue alternatives in their original order. Accept
the first one whose following character isn't a word character. This mirrors Python
going back into later alternatives. Keep Python'smatch(anchored) andsearch
semantics where each is used. - Normalization and gating. Reproduce normalization (lowercase, split on whitespace,
rejoin with single spaces),_POLITE_PREFIXstripping, and the two-word imperative gate. - Context estimate. Count code points: text, tool names, the tool input re-encoded as
compact JSON, and tool result text. Spell tool-input numbers as Python'sjson.dumps
does after parsing: integers exactly (-0as0), floats as their shortestrepr
(100.0,1e+16,1.5e-07) and floats that overflow asInfinity. Repeated keys in
tool input keep their last value, as for the rest of the request. - CLI stdout. Marshal the response with
encoding/json/v2, with struct fields declared
in alphabetical order so the keys match Python'ssort_keys=True, optional fields left out
when absent (omitzero), and a single LF. Output is UTF-8 rather than Python's ASCII
escapes; both are the same JSON value.
Tables and service (routing_table.py, service.py)
- Loading. Load either a directory, taking its
*.jsonfiles non-recursively in
name order, or a single file. - Validation. Apply every load-time rule:
- canonical model-effort strings;
- integer
schema_versionequal to 1; - a fixed
costorcost-speedprofile; - a canonical repository;
- every label combination present;
- every ranking holds the full ordered pair list, and identical rankings share one
group; - known
applies_tocells, with no double assignment; - no bare default aliases next to efforts;
- tables are keyed by their own profile;
- all profiles share one repository, catalogue and classifier order;
- no profile is declared twice;
- a directory must not be empty.
- Bundled profiles. All six bundled profiles in
containers/router/routing/load. - Schema.
schemas/routing-table.v1.schema.jsonstays the documented format. Runtime
validation is hand-written, with no schema library.
Classification and routing
Port the behavior of classification.py, routing.py, the route logic in
routing_table.py and GhAwRouterService.route:
- the ranking order;
- returning caller ids, model strings and efforts exactly as supplied;
current_idhandling;- context eligibility based on the estimate. A
context_windowlarger than 64 bits
compares as larger than any estimate; - auto mode resolves to the classification's mode, or to
balancedwhen the
classification is missing orunknown; - an unserved profile is an
invalid_requesterror; no_routeerrors, with no invented fallbacks.
Keep the classifier prompt (build_classification_prompt) and the system prompt
byte-identical. classification.json records both fixed texts.
CLI (cli.py)
-
Commands.
classify [--input PATH],route [--input PATH]andvalidate-data.--versionprintsgh-aw-router <version>to stdout and exits 0 without loading
tables.servecomes in the subsequent HTTP-server step. Until then, it's an
unknown-command usage error.
-
--routing-tables PATH.- It's a global option that comes before the subcommand.
GH_AW_ROUTER_ROUTING_TABLESis the fallback: the flag wins and an empty env value is
ignored.- If neither is set, use
./routingrelative to the working directory when it exists.
Otherwise it's a usage error naming both the flag and the env var. (Python's default
is relative to its source checkout, which a binary doesn't have.)
-
Option placement and forms. Accept the placements argparse accepts:
--routing-tablesbefore the subcommand and--inputafter it;- both
--opt valueand--opt=value.
Abbreviated options aren't supported.
-
Order of operations, as in
run().- Parse the arguments.
- Load and validate the tables. On failure, exit 2 with
invalid model data: …on
stderr. - Read the input from a file or stdin as bytes, decoding it as UTF-8 regardless of the
console's code page. - Run the command.
- Write JSON and LF to stdout. On failure, write
no route: …to stderr and exit 3,
or writeinvalid request: …and exit 2.
Usage errors exit 2 and print usage text to stderr, starting with
usage: gh-aw-router. -
Output.
- stdout carries only the JSON result. stderr carries one plain-text line on errors.
- Use LF on every platform; Python writes CRLF on Windows.
--helpdoesn't need to match argparse byte for byte, but it lists the same commands
and options.
-
Version. Use one Go constant, equal to Python's
__version__(0.1.3) until the
image cut-over bumps it. A test asserts that it equalsopenapi.yamlinfo.version
and the Dockerfile'sARG VERSIONdefault.
Tests
The Go tests replace the Python tests at the cut-over, so they must cover the same behavior.
Prefer in-process unit tests; use the built binary only where the process boundary matters.
-
Fixture replay (unit tests). One table-driven test per fixture file, with each case as a
subtest named by itsid:labels.jsonagainst the heuristics;decoding.jsonagainst the strict decoder, mapping its failure classes tomalformed
andinvalid;context.jsonagainst the context estimate, checking the character count as well as the
tokens;classification.jsonagainst prompt construction, and itssystem_promptand
classification_contractagainst the Go constants.
Pass JSON-valued inputs as their raw JSON text (for example as
jsontext.Value), never
through a generic decode that would lose large integers or duplicate keys. Cases where Go
deliberately differs are listed with their Go result in a known-differences map per fixture,
and the test fails if a listed case no longer exists. Until the cut-over, the Python
generator stays the source of truth forexpect. -
Ported unit tests. Port the Python unit tests as Go table-driven tests in the matching
_test.gofiles:test_heuristics.py,test_contracts.py,test_classification.py,
test_routing.py,test_service.pyandtest_table_schema.py. Port behavior, not
pydantic- or argparse-specific details. In particular:- routing: ranking order, ties and offer order, caller ids and model strings returned as
supplied,current_id, context-window eligibility (including a window larger than 64
bits), explicit and omitted efforts, bare default aliases, automatic modes, profiles that
aren't served, and no-route; - classification: choice ranking, the omitted-effort error, effort mismatches, the same
pair offered twice, and no-route; - tables: a table-driven test that applies each load-time rule above to an in-code copy of
a small valid table, covering the defects intest_table_schema.py'sREJECTED_BY_BOTH
andLOADER_ONLYlists; files that aren't a JSON table (not JSON, empty, a BOM, trailing
data); a single file, a directory, an empty directory, nested directories (not read), a
duplicate profile, and table sets that disagree on repository, catalogue or classifier
order; - the bundled tables: all six profiles load, and one classify case and one route case per
profile succeed. Keep these few, because every retrain of the tables changes them.
- routing: ranking order, ties and offer order, caller ids and model strings returned as
-
CLI integration tests. Port
test_cli.py, running the CLI in-process through an entry
point that takes arguments, env, stdin, stdout and stderr and returns the exit code. Cover:classifyandroutefrom--inputand from stdin, including invalid UTF-8 on stdin;validate-dataoutput for the bundled tables;--routing-tablesoverGH_AW_ROUTER_ROUTING_TABLES, an empty env value ignored, and
the./routingdefault;--versionsucceeding with an invalid tables path;- usage errors (missing arguments, unknown commands, abbreviated options,
serveuntil the
HTTP step) with exit 2; - each exit code and stderr prefix (
invalid model data:,invalid request:,
no route:), with empty stdout on every failure; - stdout holding only the JSON response and a single LF, with non-ASCII text as UTF-8.
Run a few of these as a real process too, on Linux and Windows, so that real stdin, stdout
and exit codes are covered. The test binary can re-run itself as the command from
TestMain, so no separate build is needed. -
Fuzz targets for:
- the strict decoder: no panics, every failure has a request error kind, and anything it
accepts re-encodes to a request that decodes and re-encodes to the same bytes; - heuristics: no panics;
- the table loader: no panics.
Seed them from the fixture inputs and examples with
f.Add. - the strict decoder: no panics, every failure has a request error kind, and anything it
-
Race detector.
go test -race ./...passes.
CI (.github/workflows/test-router.yml)
- A
gojob onubuntu-latestandwindows-latest.- Install Go with
actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0,
taking the version fromcontainers/router/go/go.mod, and run the steps from
containers/router/go. - Check formatting: fail if
gofmt -llists any file. - Run
go vet ./...andgo test ./.... Add-raceon Linux, and on Windows too if
the runner has a C toolchain. - Run
govulncheck ./...at a pinned version.
- Install Go with
- A temporary
differentialjob onubuntu-latest.- Install the Python router with
uv sync --locked --dev, build the Go CLI, and run
tests/differential.py. The script runs the Python CLI in process (gh_aw_router.cli.run,
loading each table directory once) and the Go binary as a subprocess, with the same tables
on the same request bytes:- every classify and route request in the
routing-contractcorpus, with the synthetic
tables it uses; - requests built from the parity fixtures (each label text as a single user turn, each
decoding body, and each context and classification conversation), routed and
classified over the same synthetic tables and over the bundled tables. Context
conversations get candidates whose windows sit one token either side of the minimum; - a seeded set of generated requests: conversations that mix Python-only whitespace,
word-boundary, uppercase and astral characters, tool-call inputs with assorted numbers,
and random offers, efforts, objectives and classifications. The generator leaves out
what the recorded decisions cover (characters whosecasefold()differs fromlower(),
code points unassigned in Unicode 15, integral or exponent-form floats, and integers
beyond 2^53), so any difference it finds is a bug.
- every classify and route request in the
- Compare stdout as parsed JSON (Python escapes non-ASCII text, Go writes UTF-8), the exit
code and the stderr prefix. Allow only the fixture cases in the Go known-differences maps;
don't add blanket exclusions. - The image cut-over removes this job and the script along with Python.
- Install the Python router with
- Don't change the existing Python jobs.
Not affected
Dockerfileand.dockerignore. The image build still copies only Python sources,
because.dockerignoreexcludes everything else.- AWF code and the Python router.
Out of scope
serve, the HTTP server, logging and thehealthchecksubcommand.- Image, release and Dependabot changes, which belong to the image cut-over.
- Any behavior change, except the decisions recorded in the fixture README.
Acceptance criteria
- The Go module builds with no third-party non-test dependencies, as one flat
main
package incontainers/router/go/. - Every case in
labels.json,decoding.json,context.jsonand
classification.jsonpasses in Go on Linux and Windows, or is listed as a recorded
decision in the Go tests' known-differences maps. - The Python unit tests listed above are ported as Go tests, including a table-driven
test of every load-time table rule (theREJECTED_BY_BOTHandLOADER_ONLYdefects)
and the bundled-profile cases. - The CLI integration tests cover the commands, options, environment fallbacks, exit
codes, stderr prefixes and stdout listed above, and a subset runs as a real process on
both operating systems. - The differential job finds no differences between the Python and Go CLIs on the
contract corpus, fixture-derived and generated inputs, other than the decisions in the
fixture README. - Fuzz targets exist and their seed corpora pass.
go vet,gofmt,govulncheck
and thego testrace run all pass in CI. - The router README's development section explains how to build and test the Go
code.
Files likely touched
containers/router/go/go.mod(new)containers/router/go/*.go(new)containers/router/.gitignore(build output)containers/router/README.md(development section)containers/router/tests/fixtures/parity/README.md(decisions table)containers/router/tests/differential.py(new, temporary).github/workflows/test-router.yml
- Dominant language
- TypeScript
- Stars
- 150
- Forks
- 76
- Avg merge
- 4h 19m
- Merged PRs (30d)
- 445
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from github/gh-aw-firewall
-
code-quality refactoring
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
github/gh-aw-firewall#10364 ·
Maintainers usually reply within 1 day
-
code-quality refactoring
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
github/gh-aw-firewall#10363 ·
Maintainers usually reply within 1 day
-
code-quality refactoring
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
github/gh-aw-firewall#10362 ·
Maintainers usually reply within 1 day
-
agentic-workflows automated dependencies
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
github/gh-aw-firewall#3837 ·
Maintainers usually reply within 1 day
-
agentic-workflows
Difficulty 2/5 1-3 hours Newbie friendliness 46/100
github/gh-aw-firewall#10368 ·
Maintainers usually reply within 1 day
All issues in github/gh-aw-firewall
Similar issues
-
frontend
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 86/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
ranxianglei/billion-context#4172 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
awslabs/threat-composer#362 ·
Maintainers usually reply within 1 day
-
Area/Cloud Needs Triage Type/Bug
Difficulty 1/5 1-3 hours Newbie friendliness 86/100
wso2/product-integrator#2847 ·
Maintainers usually reply within 1 day