Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Router: port the routing core and CLI to Go (built and tested, not shipped)

Closed
#10,312 0 comments 0 reactions 0 assignees View on GitHub

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
Issue type
Feature
Clarity
Clearly specified
Activity status
Stale
Tech stack
go, python

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

enhancement refactoring

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's text.
  • 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_turn error, 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, route and validate-data CLI 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.yaml and Dockerfile from the parent directory. At the image cut-over,
    the folder can stay or move up with one git mv.
  • Standard library only for non-test code. Use the standard library's own behavior
    (encoding/json/v2, unicode, flag, net/http later) 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 main package in containers/router/go/*.go, next to go.mod, with tests in
    *_test.go beside it. The router is small, and a main package can't be imported, so it
    needs neither internal/ nor cmd/. 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.go and main.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 unicode package and strings.ToLower for 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 of labels.json cases (letters
    added after Unicode 15, and fi, ſ, ß, İ). 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.json pins, 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 (the duplicate-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;
    • null is rejected for non-nullable fields;
    • integer fields accept only JSON integers, not 1.0 or true;
    • trailing whitespace is accepted;
    • trailing data, a BOM, invalid UTF-8 and lone surrogate escapes are rejected.

    encoding/json/v2 gives 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.input as raw jsontext.Value. It rejects NaN and Infinity and 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_json exit 2, invalid request:
    Schema violation 422 invalid_json exit 2, invalid request:
    Domain or planning error 422 invalid_request exit 2, invalid request:
    No route 422 no_route exit 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's input_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>'.
Heuristics and the context estimate
  • Heuristic patterns. Reproduce the patterns in heuristics.py without 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's match (anchored) and search
    semantics where each is used.
  • Normalization and gating. Reproduce normalization (lowercase, split on whitespace,
    rejoin with single spaces), _POLITE_PREFIX stripping, 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's json.dumps
    does after parsing: integers exactly (-0 as 0), floats as their shortest repr
    (100.0, 1e+16, 1.5e-07) and floats that overflow as Infinity. 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's sort_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 *.json files non-recursively in
    name order, or a single file.
  • Validation. Apply every load-time rule:
    • canonical model-effort strings;
    • integer schema_version equal to 1;
    • a fixed cost or cost-speed profile;
    • a canonical repository;
    • every label combination present;
    • every ranking holds the full ordered pair list, and identical rankings share one
      group;
    • known applies_to cells, 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.json stays 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_id handling;
  • context eligibility based on the estimate. A context_window larger than 64 bits
    compares as larger than any estimate;
  • auto mode resolves to the classification's mode, or to balanced when the
    classification is missing or unknown;
  • an unserved profile is an invalid_request error;
  • no_route errors, 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] and validate-data.
    • --version prints gh-aw-router <version> to stdout and exits 0 without loading
      tables.
    • serve comes 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_TABLES is the fallback: the flag wins and an empty env value is
      ignored.
    • If neither is set, use ./routing relative 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-tables before the subcommand and --input after it;
    • both --opt value and --opt=value.

    Abbreviated options aren't supported.

  • Order of operations, as in run().

    1. Parse the arguments.
    2. Load and validate the tables. On failure, exit 2 with invalid model data: … on
      stderr.
    3. Read the input from a file or stdin as bytes, decoding it as UTF-8 regardless of the
      console's code page.
    4. Run the command.
    5. Write JSON and LF to stdout. On failure, write no route: … to stderr and exit 3,
      or write invalid 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.
    • --help doesn'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 equals openapi.yaml info.version
    and the Dockerfile's ARG VERSION default.

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 its id:

    • labels.json against the heuristics;
    • decoding.json against the strict decoder, mapping its failure classes to malformed
      and invalid;
    • context.json against the context estimate, checking the character count as well as the
      tokens;
    • classification.json against prompt construction, and its system_prompt and
      classification_contract against 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 for expect.

  • Ported unit tests. Port the Python unit tests as Go table-driven tests in the matching
    _test.go files: test_heuristics.py, test_contracts.py, test_classification.py,
    test_routing.py, test_service.py and test_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 in test_table_schema.py's REJECTED_BY_BOTH
      and LOADER_ONLY lists; 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.
  • 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:

    • classify and route from --input and from stdin, including invalid UTF-8 on stdin;
    • validate-data output for the bundled tables;
    • --routing-tables over GH_AW_ROUTER_ROUTING_TABLES, an empty env value ignored, and
      the ./routing default;
    • --version succeeding with an invalid tables path;
    • usage errors (missing arguments, unknown commands, abbreviated options, serve until 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.

  • Race detector. go test -race ./... passes.

CI (.github/workflows/test-router.yml)
  • A go job on ubuntu-latest and windows-latest.
    • Install Go with actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0,
      taking the version from containers/router/go/go.mod, and run the steps from
      containers/router/go.
    • Check formatting: fail if gofmt -l lists any file.
    • Run go vet ./... and go test ./.... Add -race on Linux, and on Windows too if
      the runner has a C toolchain.
    • Run govulncheck ./... at a pinned version.
  • A temporary differential job on ubuntu-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-contract corpus, 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 whose casefold() differs from lower(),
        code points unassigned in Unicode 15, integral or exponent-form floats, and integers
        beyond 2^53), so any difference it finds is a bug.
    • 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.
  • Don't change the existing Python jobs.
Not affected
  • Dockerfile and .dockerignore. The image build still copies only Python sources,
    because .dockerignore excludes everything else.
  • AWF code and the Python router.

Out of scope

  • serve, the HTTP server, logging and the healthcheck subcommand.
  • 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 in containers/router/go/.
  • Every case in labels.json, decoding.json, context.json and
    classification.json passes 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 (the REJECTED_BY_BOTH and LOADER_ONLY defects)
    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 the go test race 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

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from github/gh-aw-firewall

All issues in github/gh-aw-firewall

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.