gh-aw-router picks the model for a software task. It builds a provider-neutral classification prompt, then ranks the exact model choices a caller offers using a routing table compiled offline. It never calls a provider, executes an agent, fits a model, or stores request history.
The router is a single Go binary. Apart from the optional OpenTelemetry SDK, it uses only the standard library. AWF runs it as a sidecar from the image this directory builds. Training, benchmark evidence, and publication tooling are maintained separately and are not needed to build or run it.
The router is one Go main package in this directory, and go.mod pins the toolchain. Shell
examples use Bash syntax and run from this directory. The server runs in the foreground, so use
another terminal for client commands.
go build -o gh-aw-router .
./gh-aw-router --routing-tables routing validate-data
./gh-aw-router --routing-tables routing serve --bind 127.0.0.1:8737Pass -o, because a plain go build names the binary router, after this directory.
Use trusted tables and keep them read-only. Tables are validated at startup, and replacing them requires a restart.
classify and route read JSON from standard input or from --input PATH. The routing-tables
option is global and must precede the command.
./gh-aw-router --routing-tables routing classify --input examples/classify-request.json
./gh-aw-router --routing-tables routing route --input examples/route-request.jsonCommand-line options override environment variables, which override defaults. An empty environment variable counts as unset.
| Option | Environment variable | Default |
|---|---|---|
--routing-tables |
GH_AW_ROUTER_ROUTING_TABLES |
./routing, if it exists |
serve --bind |
GH_AW_ROUTER_BIND |
127.0.0.1:8737 |
serve --log-level |
GH_AW_ROUTER_LOG_LEVEL |
info |
healthcheck --url |
http://127.0.0.1:8737/healthz |
The routing path may be a directory, which loads every table in it, or a single file, which
serves that one profile. Relative paths resolve from the current working directory. Bind to
another interface only on a trusted private network. The container binds to all of its own
interfaces. The bind port must be ASCII digits from 1 through 65535, and the log level is one of
critical, error, warning, info, debug or trace.
Results go to stdout and errors to stderr. Exit statuses are 0 for success, 2 for invalid
input, invalid table data, file errors, or argument errors, and 3 when routing finds no
eligible model. --help and --version exit successfully without loading a table.
healthcheck sends GET to its URL and exits 0 only on a 204. Any other status, a refused
connection or the 1 s timeout exits 1 with one line on stderr. It never uses a proxy or follows
a redirect, and it doesn't load routing tables, so --routing-tables doesn't affect it.
The authoritative contract is 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 /healthzfor readinessGET /capabilitiesfor the router release, served routing profiles, models, and effortsPOST /classifyfor a classification prompt and ranked classifier choicesPOST /routefor ranked task-model choices
Classification and routing return ranked choices drawn only from the supplied candidates.
Routing ignores model-effort pairs absent from the table and returns no_route when no
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 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.
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. See
Serving for the request limits. Clients must also handle connection failures and
enforce their own timeouts.
Classification follows the table's embedded classification_choices list and prefers its first
eligible offered choice. Routing filters the table's ordered identities against the request. Both
operations preserve the caller's exact choices. Models with effort settings are eligible only
with an explicit supported effort. Classification rejects omitted efforts for these models,
while routing skips those choices. Omit effort only for models without effort settings. The
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. 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.
The classification request and routing request are complete JSON examples. With the local server running, submit them directly.
curl --fail-with-body --silent --show-error \
--header 'Content-Type: application/json' \
--data-binary @examples/classify-request.json \
http://127.0.0.1:8737/classify
curl --fail-with-body --silent --show-error \
--header 'Content-Type: application/json' \
--data-binary @examples/route-request.json \
http://127.0.0.1:8737/route/classify returns system_prompt, prompt, and ranked_choices. The classifier call uses
system_prompt as its only system or developer instruction and prompt unchanged as the user
message, with no tools. Its expected output has this shape, defined by
x-gh-aw-router-classifier-output-schema in the OpenAPI document.
{
"labels": {
"task_type": "fix",
"scope": "local",
"task_complexity": "medium"
},
"mode": "robust"
}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 no_route.
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.
/route returns exact offered choices in preference order with the same response shape
for automatic and explicit modes.
{
"ranked_choices": [
{
"id": "openai-sol-medium",
"model": "openai/gpt-5.6-sol",
"effort": "medium"
},
{
"id": "copilot-luna-medium",
"model": "github-copilot/gpt-5.6-luna",
"effort": "medium"
}
]
}Supplying current_id places that offered choice first while it remains eligible. For each
routing candidate with a context_window in tokens, eligibility uses a character-count
estimate plus 16,000 tokens of headroom. Omitting context_window skips that candidate's
context check. This estimate is not a provider tokenizer and does not replace enforcement
of the provider's actual context limits.
serve loads and validates the tables (exit 2 on failure), checks the bind address (exit 2),
then listens (exit 1 if that fails). It uses Go's net/http server with these limits:
- At most 64 requests are in flight,
/healthzincluded. Further requests get a bare 503. - A body whose
Content-Lengthis over 1,048,576 bytes gets 413payload_too_largeon any path. A chunked body is counted as/routeand/classifyread it, and gets the same 413 once it passes the limit. Other paths answer without reading the upload. - Headers must arrive within 10 s, and the whole request within 30 s. The response must be written within 30 s, and an idle keep-alive connection closes after 5 s. A request past these limits has its connection closed.
- Framing errors, paths that aren't clean (
//healthzredirects to/healthz) andOPTIONS *are handled bynet/httpbefore the router sees them.
On SIGTERM or SIGINT the server stops accepting connections, lets in-flight requests finish for up to 0.5 s, closes the rest and exits 0. With telemetry on, it drains for up to 0.25 s and then flushes telemetry for up to 0.5 s, so that it still exits within the 1 s AWF allows before SIGKILL.
serve writes one JSON object per line to stderr, and nothing else once its arguments are
parsed. Usage errors stay plain text. Every record has time (RFC 3339 UTC with milliseconds),
level, msg, service (gh-aw-router) and version. The other fields are:
msg |
Level | Fields |
|---|---|---|
starting |
INFO | bind, log_level, go_version |
routing tables loaded |
INFO | tables_path, profiles, models, label_cells |
routing tables invalid |
ERROR | error_code (invalid_model_data), error |
configuration invalid |
ERROR | error_code (invalid_request), error |
telemetry setting invalid |
WARN | variable, effect (traces disabled, metrics disabled, traces and metrics disabled, ignored or other keys ignored) |
telemetry configured |
INFO | traces and metrics (enabled or disabled); with either on, protocol, and traces_endpoint or metrics_endpoint for each enabled signal |
listening |
INFO | address |
listen failed |
ERROR | error |
request |
INFO, or ERROR for a status of 500 or above | request_id, method, route, status, duration_ms, error_code (error envelopes only), request_bytes, response_bytes, trace_id and span_id (see Telemetry) |
panic |
ERROR | request_id, route, stack |
telemetry error |
WARN | signal (traces, metrics or sdk), reason (HTTP and the status, timeout, connection refused or other) |
shutting down |
INFO | signal |
telemetry flush incomplete |
WARN | none |
stopped |
INFO | drained (whether in-flight requests finished in time), duration_ms |
route is one of the four served paths or unmatched, and method is a standard HTTP method
or _OTHER. request_id is random for each request and isn't sent to the client. A successful
/healthz call isn't logged. Records never contain request or response bodies, headers, query
strings, unmatched paths, model or choice ids, error details or panic values. The one exception
is trace_id, which can come from a valid traceparent header. Telemetry records never contain
a setting's value, an endpoint's path, query or user info, or an exporter's error text.
In AWF, these records are the router's only log. At the end of a routed run, AWF stops the router
before its other containers, so that it logs shutting down and stopped, and saves the
container's whole log as router-logs/router.jsonl. That's next to api-proxy-logs/ in
--proxy-logs-dir when it's set, and in /tmp/router-logs-<timestamp>/ otherwise. Only the user
who ran AWF can read it, and AWF's diagnostic log collection leaves the router out so that no
copy is world-readable. See AWF's
log file inventory.
The router can export OpenTelemetry traces and metrics over OTLP/HTTP with protobuf encoding.
It's off by default: a signal is on only when it has an endpoint. With no endpoint, the router
starts no telemetry goroutines and makes no network calls, and only its stderr records change.
A setting that is invalid or unsupported never stops the router. It logs telemetry setting invalid, naming the variable but not its value, and turns off the signals it applies to.
| Variable | Default | Meaning |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
none | Base URL for both signals. The router adds /v1/traces and /v1/metrics to its path. |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT |
the base URL | Complete URL for one signal, used as is. |
OTEL_SDK_DISABLED |
false |
true turns both signals off. Incoming trace context still reaches the logs. |
OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER |
otlp |
otlp or none. |
OTEL_EXPORTER_OTLP_PROTOCOL |
http/protobuf |
Only http/protobuf. grpc and http/json turn the signal off. |
OTEL_EXPORTER_OTLP_HEADERS |
none | Comma-separated name=value pairs with percent-encoded values, for example authorization=Bearer%20token. |
OTEL_EXPORTER_OTLP_TIMEOUT |
10000 |
Export timeout in milliseconds. |
OTEL_EXPORTER_OTLP_CERTIFICATE |
the system roots | PEM file of the CA certificates to trust instead of the system roots. |
OTEL_TRACES_SAMPLER |
parentbased_always_on |
always_on, always_off, traceidratio, or parentbased_ and any of those. |
OTEL_TRACES_SAMPLER_ARG |
1 |
The ratio, from 0 to 1, for the ratio samplers. |
OTEL_METRIC_EXPORT_INTERVAL |
60000 |
Milliseconds between metric exports. |
OTEL_METRIC_EXPORT_TIMEOUT |
30000 |
Metric export timeout in milliseconds. |
OTEL_SERVICE_NAME |
gh-aw-router |
The service.name resource attribute. |
OTEL_RESOURCE_ATTRIBUTES |
none | Only service.namespace and deployment.environment.name. Other keys are ignored with a warning. |
The protocol, headers, timeout and certificate variables also have per-signal forms, such as
OTEL_EXPORTER_OTLP_TRACES_HEADERS, which replace the shared setting for that signal. Client
certificates aren't supported. The exporters use HTTPS_PROXY, NO_PROXY and SSL_CERT_FILE
as any Go client does. The SDK reads a few more variables itself, such as
OTEL_EXPORTER_OTLP_COMPRESSION, the metric temporality and histogram aggregation preferences,
and the span limits. The resource is service.name, service.version (the router version,
which can't be overridden), a random service.instance.id for each process, the two allowed
attributes and the SDK's name, language and version.
Metrics are cumulative and exported every 60 s by default, and shutdown exports what's left, so a
run shorter than the interval still reports its totals. Set OTEL_METRIC_EXPORT_INTERVAL lower
only to see metrics during a run or to keep them if the final flush fails. Spans are exported in
batches every 5 s. Shutdown flushes what's left within 0.5 s, but doesn't wait longer for a slow
collector; if the flush doesn't finish, the router logs telemetry flush incomplete. A failing
collector never slows requests: spans queue in memory (up to 2,048, then new spans are
dropped), and export failures are logged as telemetry error at most once a minute per signal.
An exporter retries a failed export until its export timeout ends, so a failure is logged when
that timeout ends. An export that shutdown interrupts isn't logged, because the final flush
reports its own result.
Trace context. The router reads W3C traceparent and tracestate and ignores baggage. A
valid traceparent makes the router's span its child; without one the span is a new root. The
caller's tracestate stays on the router's spans, as W3C propagation requires, but is never
logged. The router makes no outbound calls, so it never sends trace context on. The request
record gets trace_id and span_id when the router started a span, even an unsampled one.
Without a router span, such as when tracing is off, a request rejected with a bare 503 or a
/healthz call, it gets only the trace_id of a valid incoming traceparent.
In AWF, the api-proxy sends traceparent on each /capabilities, /classify and /route
call, from an api_proxy.router.<operation> client span in the workflow's trace. Those client spans,
which also carry the route's goal, mode and validated selection, are exported by the api-proxy
whether or not the router exports anything. See OTEL Tracing in Sentry.
In AWF. AWF passes the router only the settings in the table above, and only from
router-specific variables in AWF's own environment: AWF_ROUTER_OTEL_<NAME> becomes the router's
OTEL_<NAME>, for example AWF_ROUTER_OTEL_EXPORTER_OTLP_ENDPOINT becomes
OTEL_EXPORTER_OTLP_ENDPOINT. Blank values are skipped, other values, including any $, are
passed literally, and with none set the router gets no environment and exports nothing. The
shared OTEL_* variables in AWF's environment configure only the api-proxy, usually for an
external collector, and never reach the router. The router stays only on AWF's internal
awf-routing network, so its collector must be a container attached to that network; it can't
reach the internet or the host, where localhost is the router container itself. A certificate
path must exist inside the router image, because AWF mounts nothing into the router. The coding
agent never sees any AWF_ROUTER_OTEL_* variable. Audit and diagnostic compose snapshots hide
environment values by default, retaining only exact, bounded control and protocol values; collector
headers, endpoint URLs and other free-form values are redacted. See
OTEL Tracing in Sentry
for details.
Spans. Each request gets one SERVER span named {method} {route}, or just {method} for
an unmatched path, except GET /healthz and requests rejected with a bare 503. Its attributes
are http.request.method, http.route (matched paths only), url.scheme,
network.protocol.version, http.response.status_code and, for an error, error.type (the
error envelope's code). A request whose body never arrives has error.type
request_aborted and no status code. The span's status is Error only for a 5xx response or
an aborted request.
A /classify or /route request whose body decodes also gets an INTERNAL child span,
gh_aw_router.classify or gh_aw_router.route, with these attributes, all prefixed with
gh_aw_router.:
outcome:ok,invalid_request,no_routeorinternal_error;conversation.messagesandmodels.count: the number of messages and candidates;prior_turns.included(classify, when the outcome isok): the earlier turns in the prompt;goal,mode,resolved_modeandclassification.present(route);task_type,scope,task_complexityandmode_recommendation(route, when the request includes a classification);selected.modelandselected.effort(route, when the outcome isok): the top choice, taken from the loaded execution catalogue, never from the request.
Metrics.
| Instrument | Type and unit | Attributes |
|---|---|---|
http.server.request.duration |
histogram, s, with the semconv advisory buckets |
http.request.method, http.route (matched paths only), http.response.status_code (not for aborted requests), error.type (errors only; 503 for a bare 503) |
gh_aw_router.requests.rejected |
counter, {request} |
gh_aw_router.reason: overload or payload_too_large |
gh_aw_router.planning.outcomes |
counter, {request} |
gh_aw_router.operation (classify or route) and gh_aw_router.outcome; for route, also gh_aw_router.goal, gh_aw_router.mode and gh_aw_router.resolved_mode |
Every attribute value comes from a fixed set, so the number of series is bounded. Metrics never
carry model ids or classifier labels. The histogram's count also counts rejected requests;
requests.rejected repeats them under one name, so that load shedding is easy to find and
alert on.
Privacy. Spans and metrics never contain conversation text, tool names, inputs or results,
candidate lists or ids, current_id, request headers, query strings, unmatched paths, prompts
or error details. A model id appears only as selected.model, and only when it's in the loaded
catalogue.
routing/ holds one file per published profile, named {goal}-{mode}.json for the goals cost
and cost-speed and the modes economy, balanced, and robust. Each file contains the
ordered provider/model or provider/model-effort identities for every label cell and a separate
classifier ranking. Prices, benchmark evidence, fitted parameters, and provider credentials stay
outside the runtime package.
The service loads every table it is given and picks one per request from objective.goal and
the resolved mode. Auto is a selection rule, not a separate table profile. /capabilities
lists only the fixed profiles that are loaded. For all automatic choices to work, load the
three mode tables for each supported goal. The bundled routing/ directory contains all six.
A resolved profile that is not served fails with invalid_request, including when the
balanced fallback table is missing. An inferred robust mode never silently becomes
balanced because a table is unavailable. Single-file deployments work only when the
resolved profile matches that file.
Tables loaded together must agree on their repository, model catalogue, and classifier
order, so they differ only in their routing order. Select a directory or a single file with
--routing-tables or GH_AW_ROUTER_ROUTING_TABLES, and mount or publish generated tables
read-only.
Only schema_version 1 is supported, and the loader rejects any other value. A table declares
one fixed cost or cost-speed profile 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.
schemas/routing-table.v1.schema.json specifies the
table file format as a JSON Schema. The loader also enforces rules that relate parts of a table
to each other, which the schema can't express: every label cell appears in exactly one ranking
group, every ranking and the classifier order are permutations of the first group's choices,
no two groups have the same ranking, and a model listed with explicit efforts isn't also listed
without one. The schema requires schema_version, which the loader defaults to 1 when it's
missing.
A published schema file never changes. A format change adds a new schema file, such as
routing-table.v2.schema.json, and bumps schema_version. Every table must validate against
the schema for its schema_version.
Build from this directory. The Docker build context is self-contained.
docker build \
--build-arg OPENAPI_SHA256="$(sha256sum openapi.yaml | cut -d ' ' -f 1)" \
--tag gh-aw-router:dev .The builder stage cross-compiles, so a multi-platform build needs no emulation. The build fails
unless OPENAPI_SHA256 matches openapi.yaml, and unless the optional VERSION build argument
matches the version constant in main.go, so the image labels can't disagree with the
binary.
Each AWF release publishes the image for linux/amd64 and linux/arm64 as
ghcr.io/github/gh-aw-firewall/router:<awf-version>, signed with cosign and with an attested
SPDX SBOM. The tag is the AWF version, while the image's version label stays the router version.
The image is built FROM scratch. It holds only the static binary at
/usr/local/bin/gh-aw-router, the bundled tables under /routing, openapi.yaml and a CA
bundle filtered by AWF's CA distrust policy in trust/, and has no shell. It serves all six
profiles, runs as user 10001:10001, and needs no credentials or outbound network. It sets
GOMAXPROCS=2 and GOMEMLIMIT=384MiB to fit AWF's 512 MiB container limit. This invocation
uses a private network, a read-only filesystem, and no published host port. The service
listens on port 8737 inside the network.
docker network create --internal gh-aw-routing
docker run --detach \
--name gh-aw-router \
--network gh-aw-routing \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=16m \
--cap-drop ALL \
--security-opt no-new-privileges \
gh-aw-router:devOnly trusted clients such as the API proxy belong on this network, not the coding agent.
GET /healthz returns 204 when ready, and GET /capabilities describes the loaded profiles.
To replace the bundled tables, add these options before the image name.
--mount type=bind,src=/trusted/routing,dst=/mnt/routing,readonly \
--env GH_AW_ROUTER_ROUTING_TABLES=/mnt/routingLike AWF's other images, the image declares no HEALTHCHECK. AWF's compose configuration runs
/usr/local/bin/gh-aw-router healthcheck as an exec-form check. To check health yourself, use
the same exec form, such as docker exec gh-aw-router /usr/local/bin/gh-aw-router healthcheck
or a compose test: ["CMD", "/usr/local/bin/gh-aw-router", "healthcheck"]. docker run --health-cmd always runs its command through /bin/sh, which the image doesn't have.
AWF's release workflow, .github/workflows/release.yml, publishes the image together with the
other AWF images and lists its digest in the release's containers.txt. A deployment pin has
the form ghcr.io/github/gh-aw-firewall/router:<awf-version>@sha256:<manifest-digest>.
A local image ID or Docker archive checksum is not that registry manifest digest. Deliver
security fixes through new AWF releases and tested client pin changes, not replacement bytes
under old pins.
The routing-contract corpus covers all four endpoints using
synthetic model identities and six distinguishable table profiles. Each case names the method,
path, expected status, optional headers and a complete request; default to
Content-Type: application/json when headers are omitted. raw_request marks malformed JSON,
and request_valid: false marks a schema-invalid object. The test-only response_file
reference shares an exact classifier response between cases. A 204 response has no body.
A case's optional profiles list restricts the table files loaded. Otherwise load all six.
Compare successful responses exactly, including choice order, effort omission, and prompt
strings. For errors, compare status and the stable code; detail is diagnostic text, not a
promise. The Go tests build the six tables in corpus_test.go and replay every case through the
HTTP handler, and the container tests replay them against the image with the tables mounted.
They also require unique case IDs, a case for every endpoint, and explicit-* route cases that
cover all six profiles. The fake model identities are test data, not a provider catalogue.
Never send them to live providers. The API proxy's tests copy a subset of these cases.
The behavior fixtures record the expected heuristic labels, request decoding, context estimates and classifier prompts, which were first recorded from the Python router this Go implementation replaced. Their README lists where the Go router deliberately differs from it. Change an expectation only in a reviewed pull request that explains it.
Follow the repository's contributing guide and code of conduct. Open an issue to discuss substantial changes before implementation. Keep pull requests focused and include tests for changed behavior. Do not include credentials, private prompts, local environments, or generated build output.
Run these checks from this directory.
gofmt -l .
go vet ./...
go test ./...The container tests run only when GH_AW_ROUTER_TEST_IMAGE names a built image. They run it
hardened as AWF does, check its configuration, health check, shutdown and logs, and replay the
routing-contract corpus against it.
docker build --build-arg OPENAPI_SHA256="$(sha256sum openapi.yaml | cut -d ' ' -f 1)" \
--tag gh-aw-router:test .
GH_AW_ROUTER_TEST_IMAGE=gh-aw-router:test go test -run '^TestContainer' -v .FuzzDecodeRouteRequest, FuzzParseTable and FuzzInferLabels are fuzz targets, for example
go test -run '^$' -fuzz FuzzParseTable. The test-router.yml CI workflow runs the checks
on Linux and Windows, the race detector and govulncheck on Linux (also weekly), and the
container tests.
- The
.gofiles,go.modandgo.sumare the router: one flatmainpackage and its tests. The image build copies onlygo.mod,go.sumand the non-test.gofiles. routing/contains generated routing tables from training.schemas/contains the versioned JSON Schema for routing table files.trust/is AWF's shared CA distrust policy, which the image build applies to its CA bundle.examples/contains executable planning requests.testdata/contains the routing-contract corpus and the behavior fixtures.- The repository root's
.github/workflows/test-router.ymland.github/dependabot.ymlcontain CI and dependency-update configuration.
- Keep request and response types in
contract.goand request decoding indecode.go. - Keep routing in
route.go, classifier prompts inclassify.go, and deterministic fallback rules inheuristics.go. - Keep the CLI in
cli.go, HTTP serving inserve.goandserver.go, and OpenTelemetry configuration and export intelemetry.go. - Use only the Go standard library, apart from the OpenTelemetry SDK and its exporters. Never put conversation text, request ids, headers or other request-controlled values in telemetry.
- Document public behavior, errors, defaults, and approximation limits rather than repeating types.
- Preserve exact offered model identities and explicit reasoning efforts.
- Keep OpenAPI, examples, and fixtures aligned with runtime behavior.
The committed openapi.yaml is the authoritative HTTP contract. It distinguishes requested
modes from published profiles and includes the classifier-output schema used by callers.
Update it alongside contract changes. The Go tests check that it documents the served paths and
error codes and that its references resolve, and that the README's examples run against the
bundled tables and match the responses. Do not add a second generated public contract.
The hand-written schema in schemas/ is the authoritative routing-table contract. A published
schema file never changes, and the Go tests pin its hash and check its enums and patterns
against the Go types. Change the table format only by adding a new schema version.
The HTTP contract follows the router version. 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.
Router changes ship in the next AWF release. Bump the router version in the same pull request
as any change to routing behaviour or the HTTP contract. The version appears in the version
constant in main.go, the Dockerfile's VERSION default, openapi.yaml, and the
capabilities case in testdata/routing-contract/discovery.json; the Go tests fail if they
disagree.
Keep release publication and credentialed attestations in separately reviewed,
explicitly permissioned jobs, which today means AWF's .github/workflows/release.yml.
Do not execute untrusted PR code through pull_request_target.
The router's only Go module dependencies are the OpenTelemetry SDK, its OTLP/HTTP exporters
and what they pull in; the tests also decode OTLP protobuf. The exporters' generated OTLP
packages link parts of gRPC and grpc-gateway into the binary even though the router exports
only over HTTP, so govulncheck findings in those modules, and in golang.org/x/net, apply
to it. Raise the indirect requirement when an advisory needs it, and keep the OpenTelemetry
modules on one release. Dependabot updates the Go modules and the Docker base image weekly,
and go.mod's toolchain is updated by hand to match the base image. Keep external actions
pinned to full commit SHAs when reviewing updates. Routing JSON files are generated offline.
Do not hand-edit rankings or add runtime dependencies on training tools.
This service intentionally provides plain HTTP without authentication. It is designed for a private network shared with a trusted API proxy, not for direct public exposure. The proxy owns credentials, authorization, provider requests, and execution history.
Run the container without published ports, as a non-root user, with a read-only filesystem and all capabilities dropped. Mount only trusted routing tables. See the container example for the hardened invocation.
Request size and time limits are not a substitute for network isolation, connection limits, or proxy timeouts. Context-window estimation is approximate.
Supported router versions follow AWF releases. Report vulnerabilities through the repository's
security policy, and include the AWF
version and the router version that GET /capabilities reports. The code is licensed
under MIT.