Every route requires Authorization: Bearer <service token>. JSON requests require a single Content-Length, Content-Type: application/json, a maximum of 262144 bytes, and unique object keys. Chunked request bodies are unsupported. A request line may hold at most 8192 bytes and the header block at most 16384 (every header line with its line end, not counting the blank line that ends the block); either over its limit is answered 431 with {"error":{"code":"headers_too_large",...}} before authentication, since the request was not read whole. All routes close the HTTP connection after a response.
| Route | Result |
|---|---|
GET /healthz |
200 with protocol version and configured backend; process liveness only, not model readiness |
GET /readyz |
200 {"status":"ready"} when the worker can start a job now, otherwise 503 {"status":"not_ready","reason":...} (below) |
GET /metrics |
200 with aggregate counts and timings in the Prometheus text format (below) |
POST /v1/jobs |
202 with a queued job, or with the retained job a repeated idempotency_key names |
GET /v1/jobs/{id} |
200 with a snapshot, or 404 |
POST /v1/jobs/{id}/cancel |
Send {}; returns the terminal/current snapshot, or 404 |
The worker takes its tokens from exactly one of three variables, read once at start:
BRIDGE_TOKEN=<token>: a single token, nameddefault.BRIDGE_TOKENS=name1=token1,name2=token2: one entry per client. A token here cannot contain a comma; use the file for one that does.BRIDGE_TOKENS_FILE=<path>: onename=tokenper line; lines that are blank or whose first non-space character is#are ignored, and entry lines are not trimmed. The file must be a regular file of at most 65536 bytes with mode 0600 or stricter (no access for group or others).
A name is 1–64 characters of A-Z, a-z, 0-9, _, . and -; a token is at least 32 non-whitespace ASCII characters, everything after the first = of its entry. At most 64 tokens; no name or token may appear twice. An empty variable counts as unset, so compose's ${X:-} forms work. Setting BRIDGE_TOKEN together with either list, or both lists, is refused. The worker refuses to start on any of these, and on a file it cannot read or that group or others can, with a message naming the variable and never a token. BRIDGE_TOKEN and BRIDGE_TOKENS leave the worker's environment before it starts any child process; the file's path stays, its content does not enter the environment.
A request is authenticated by comparing its bearer with every configured token in constant time; the matching token's name is the request's owner. A job belongs to the owner that submitted it: GET /v1/jobs/{id} and POST /v1/jobs/{id}/cancel with another token answer 404 not_found, exactly as for a job that does not exist, never 403. GET /healthz, GET /readyz and GET /metrics answer any configured token. The owner is not part of a job's snapshot. Capacity, concurrency and retention are shared by every owner; BRIDGE_OWNER_CAPACITY, with the other limits below, caps how much of the capacity one owner may hold.
Submit:
{"task":"rerank","input":{"query":"capital of France","documents":["Paris is the capital of France.","Saturn has rings."]},"timeout_ms":30000}rerank, embed and redact are enabled normally. For rerank, query and each document must contain 1–8192 characters with non-whitespace content; 1–512 documents are accepted, and the query plus all documents may total at most 200000 characters. The 262144 byte limit still applies to the encoded request, so multi-byte text and escaped quotes can reach it before the character budget; the PHP client counts the budget in bytes and sends raw UTF-8 to keep the two close. An optional integer top_k from 1 to the document count limits how many rankings are returned; omitting it returns every document. The deadline is an integer from 100 to 300000 milliseconds and includes queue time, process startup and execution. Missing timeout_ms means 30000.
An optional idempotency_key is a string of 1–128 printable ASCII characters (0x21–0x7e); any other value, null included, is 400 invalid_request. While the job first submitted with a key is retained, submitting the same key again starts nothing and returns 202 with that job's current snapshot, so its status may be queued, running or terminal; the repeat is answered even when capacity is full, and its task, input and timeout_ms are validated but not compared with the first. Once that job has expired, the key submits a new job. Keys are scoped to the submitting token within one service instance: the same key from two tokens is two jobs, and a repeat returns only the job its own token submitted. With a single token this is the behaviour it always had.
A job contains id, task, status, timeout_ms, result, error, and progress. IDs are 32 lowercase hex characters. Result/error/progress are null until applicable. Input documents are not echoed in status responses.
States: queued → running → succeeded or failed. A job is running once a worker process has taken it; if that process dies before taking it, the job stays queued and is tried once more, and a second such death, or any death after it started, fails it as worker_crashed. A queued/running job may also become timed_out or cancelled. Terminal states do not change. Cancelling an already completed job returns its unchanged state. Progress contains integer completed and total; it may be null before execution.
Successful reranking returns {"model":"...","rankings":[{"index":0,"score":1.0}]}. Rankings are sorted by descending score, with original order breaking ties; each original document index occurs at most once, and exactly top_k rankings are returned (every document when top_k is omitted). Progress is reported at most 66 times per job regardless of document count and always ends at the total. The deterministic backend identifies itself as lexical-demo-not-a-model. ONNX scores are not calibrated probabilities.
Submit {"task":"embed","input":{"texts":["..."]}} with 1–32 texts of 1–8192 characters each, totalling at most 200000 characters. A successful result is {"model":"...","dimensions":384,"vectors":[[...]]} with one unit-length vector per text in input order; components are rounded to six decimals, so the dot product of two vectors is their cosine similarity. The service returns vectors and stores nothing; the caller owns any index built from them. The deterministic backend identifies itself as hashing-bow-not-a-model: a signed feature hash of word tokens that is stable across processes and ignores word order. The ONNX backend is sentence-transformers/all-MiniLM-L6-v2 with mean pooling; it truncates input beyond 256 word pieces and is trained on English text.
Submit {"task":"redact","input":{"text":"...","entities":["PER"],"min_score":0.85}} with 1–8192 characters of valid Unicode text. entities is an optional list of distinct labels among PER, ORG and LOC (default ["PER"]; MISC is never offered) and min_score an optional number from 0 to 1 (default 0.85); both shape only the model pass. A successful result is {"model":"...","text":"...","spans":[{"start":0,"end":5,"label":"EMAIL","source":"rule:email","score":1.0}]}: the text with each span replaced by [LABEL], and the spans sorted and disjoint, with offsets counted in Unicode code points of the original text. Deterministic rules run on every backend and are tagged rule:: CARD (any 13–19 digit window of a digit run that passes Luhn, longest first), IBAN (mod 97, trimming trailing groups the pattern swallowed; a lower-case one also needs a country code from the IBAN registry, and mixed case is never taken), EMAIL (letters of any script in the local part and the domain, and a letter or punycode top-level label), IPV4 and IPV6 (any form Python's ipaddress parser accepts that holds a digit, not starting inside a word, an IPv4-mapped tail and a %zone identifier included, the colon of address: message excluded) at score 1.0, and PHONE at 0.8 because it is a pattern, not a proof: 7–15 digits with a leading plus or separators, or at least ten bare digits, never a date shape. Known pattern false positives remain: a dotted quadruple of small numbers such as a version string matches IPV4, hex words joined by colons that the parser accepts, such as dead::beef, and eight colon-separated groups of up to four digits, such as 10:20:30:40:50:60:70:80, match IPV6, and separated digit groups such as amounts can match PHONE; filter by source and score where that matters. On the default backend, or when entities is empty, the model name is rules-only-not-a-model and no model runs. The ONNX backend adds spans tagged model:PER, model:ORG or model:LOC carrying the model's mean word confidence, from two models trained on CoNLL-2003 English news, in the int8 ONNX conversions Xenova/bert-base-NER and Xenova/bert-base-NER-uncased; its results name Xenova/bert-base-NER:int8+Xenova/bert-base-NER-uncased:int8. The cased dslim/bert-base-NER reads the text, and reads a sentence written all in lower case or all in capitals, and a surname in capitals before a comma and a given name, a second time from a title-cased copy with the same offsets. The uncased dslim/bert-base-NER-uncased then reads the whole text ignoring case, which finds lower-case names the cased model misses, such as one in a sentence that holds a capital; it also tags a domain name such as example.com as ORG, which inside an email address the EMAIL span covers. Pieces of one name that touch or meet at a hyphen, full stop or apostrophe are joined before the threshold and carry the score of their most confident piece; any other model span carries its mean word confidence. Overlapping candidates are merged into one span carrying the leftmost, then longest, then strongest label, then the more confident of two model readings, so no part of any candidate stays visible. Redaction is assistive: rules cannot see unstructured personal data, and the models miss names unlike their news training data: on an invented sample they masked every entity in news-style sentences and terse records, but four in five in informal messages (the measurement), so it is not a compliance control.
Failures use generic error objects with code and message. Task errors include task_failed, worker_crashed, worker_start_failed, deadline_exceeded and service_restarted. Internal exception text is not sent to clients. HTTP contract failures return 400, missing auth 401, missing routes/jobs 404, a connection idle for five seconds while its body is read 408 (a stall while reading headers closes the connection without a response), oversized bodies 413, an oversized request line or header block 431 and capacity exhaustion 429. Two answers are 503 with Retry-After: 1 and create no job: draining for a submission to a worker that is stopping (below), and service_busy when all 32 of the worker's connections are taken; that one is written as the connection is accepted, before anything of the request is read, so it carries no auth check and answers any route.
Every 429 carries Retry-After in whole delay seconds (an integer of at least 1, never an HTTP date): when every slot is taken and some jobs have finished, the time until the first of them leaves retention, rounded up; when every slot holds an unfinished job, the worker is shutting down or the job table cannot be written, 1. A submission over its owner's cap (BRIDGE_OWNER_CAPACITY) is the same 429 capacity_exceeded with the same body, and its delay counts only that owner's jobs: the time until the first of its own finished jobs leaves retention, or 1 when it holds only unfinished ones. The owner's cap is checked before the total, and a repeated idempotency key is answered at either. A 429 created no job, so resubmitting after that delay cannot duplicate work. The PHP client exposes the value as BridgeException::$retryAfterSeconds, through the cURL and the PSR-18 transport alike, and the MCP server waits for it within a call's budget; both read one header of 1 to 6 digits and ignore anything else, a repeated header included.
The default capacity is 128 total queued, running and retained jobs, concurrency is 2, and terminal retention is 300 seconds. A full result cache rejects submissions until entries expire; it does not silently evict a result early. Configure these with BRIDGE_CAPACITY, BRIDGE_CONCURRENCY, and BRIDGE_RETENTION_SECONDS. BRIDGE_OWNER_CAPACITY caps the queued, running and retained jobs of one owner (one token), an integer from 1 to the capacity; unset, it is the whole capacity, so one token may fill the worker as before. The total still applies on top, so caps that add up to more than the capacity share what is left. The worker keeps as many child processes as its concurrency and replaces a child after BRIDGE_WORKER_MAX_JOBS jobs (default 1000, between 1 and 1000000), as well as after a cancellation, a deadline or a crash. A numeric setting that does not parse stops the worker at start with a message naming its variable; one outside its range is refused before any child starts.
With BRIDGE_STATE_DIR set, the job table (never a job's input) is written through to SQLite at <BRIDGE_STATE_DIR>/jobs.sqlite on every status change; unset, it stays in memory and a restart loses every job. When the worker starts on an existing file, a job still queued or running there becomes failed with error service_restarted (its input was never stored, so it is not replayed; a shutdown reports such jobs as cancelled to whoever polls the stopping process but leaves them unfinished in the file), a finished job whose retention has not run out by the wall clock is served by GET as before until it does, and an expired one is deleted. The file records each job's owner, the token's name and never its value. A file written before owners existed has no owner column: the worker adds it when it opens the file, and its rows belong to default, the single-token name, so a worker moved to a token list keeps serving them only to a token named default. A 404 for a well-formed ID therefore means the job expired, was never submitted to this instance, belongs to another token, or was lost to a restart without a state directory; the worker cannot tell these apart.
The PHP client uses a fresh cURL handle per call, refuses redirects, ignores proxy environment variables, caps response bytes, and verifies that GET/cancel responses match the requested ID. Configure its request timeout separately from job execution and local waiting deadlines. There are no transparent submission retries, a 429 included. The MCP server in mcp_server/ is a second, independent client with the same checks; unlike the PHP client it cancels a job whose local wait expires, because no later request will collect it.
Adding a task requires trusted source changes to worker/ai_bridge/tasks.py: an explicit name, input validation, bounded output and execution logic. Add a PHP result type where useful, plus successful, malformed-input and lifecycle tests. Task imports, file paths and command strings must not come from request data.
On SIGTERM or SIGINT the worker drains: every POST /v1/jobs is answered 503 draining with Retry-After: 1 (after the body is read, so a malformed one is still 400), every job still queued becomes failed with service_restarted at once and its input is dropped, and GET routes keep answering. Running jobs may finish for up to BRIDGE_DRAIN_SECONDS (an integer from 0 to 300, default 30; anything else stops the worker at start with a message naming the variable). When none runs any more, or the deadline passes, or a second signal arrives, the worker stops serving, stops its children (a job still running then ends cancelled to whoever polls the stopping process and, with BRIDGE_STATE_DIR, as service_restarted after the next start, as on any stop) and closes the job table. A container runtime that kills after a stop timeout cuts the drain short: give it a timeout longer than BRIDGE_DRAIN_SECONDS (Docker's default is 10 seconds).
GET /readyz takes any configured token, as /healthz does. It answers 200 {"status":"ready"} when the worker is not draining and at least one child is idle and has loaded the models of the configured backend; otherwise 503 {"status":"not_ready","reason":...} with reason one of draining, no_idle_child (every child is starting, loading its models or running a job) or models_loading (a child is idle but its models failed to load). A child loads its backend's models as soon as it starts, before it takes any job, so readiness is no_idle_child throughout the warm-up: on the onnx backend that is the four models, on lexical nothing, so a lexical child is ready as soon as it has started. A child whose models fail to load still takes jobs, which fail as task_failed, and readiness stays models_loading while it is the only idle child. /healthz stays a liveness check: it answers 200 throughout, a drain included.
GET /metrics takes any of the worker's tokens, as every other route does (401 without one; a POST is answered as on any unknown route, 404 for {}, and another spelling, a query string included, is 404) and answers Content-Type: text/plain; version=0.0.4; charset=utf-8, the Prometheus text exposition format, written by the standard library. Every value is an aggregate since the worker process started, held in memory only: a restart resets every counter and histogram to zero, the job table in BRIDGE_STATE_DIR does not feed them, and a job read back from that table counts nothing. Each family has one # HELP and one # TYPE line, and every series exists from the start, at zero. Label values come only from fixed sets: owner is the name of one of the worker's tokens (every configured name has its series from the start, default for BRIDGE_TOKEN), task is a task of the allowlist (rerank, embed, redact, plus the test tasks on a worker started with BRIDGE_TEST_TASKS=1), status one of the four terminal statuses, reason is capacity, and backend the configured backend; no job ID, idempotency key, input, result, error, model path or token appears.
| Family | Type | Labels | What it holds |
|---|---|---|---|
bridge_jobs_total |
counter | owner, task, status |
Jobs that reached succeeded, failed, timed_out or cancelled |
bridge_jobs_submitted_total |
counter | owner, task |
Accepted submissions that created a job; a repeated idempotency_key creates none and is not counted |
bridge_submissions_rejected_total |
counter | owner, reason="capacity" |
Submissions answered 429, for every cause the 429 section names |
bridge_queue_depth |
gauge | Jobs queued now |
|
bridge_jobs_running |
gauge | Jobs running now |
|
bridge_pool_size |
gauge | Child processes in the pool, starting or started | |
bridge_pool_ready |
gauge | Of those, the ones that have said they are ready, idle or running a job | |
bridge_concurrency |
gauge | The BRIDGE_CONCURRENCY setting |
|
bridge_capacity |
gauge | The BRIDGE_CAPACITY setting |
|
bridge_job_duration_seconds |
histogram | task |
Seconds from submission to terminal status, for every job counted by bridge_jobs_total |
bridge_job_queue_seconds |
histogram | task |
Seconds from submission to running, for jobs a child started; one that ended while queued adds nothing |
bridge_info |
gauge | protocol="1", backend |
Always 1 |
Both histograms have the buckets 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60 and 300 seconds and +Inf, cumulative as the format requires, with _sum and _count. A scrape copies the numbers under the store's lock and formats them without it, so it never waits for a job. An invalid submission (400) creates no job and counts nothing.
The worker writes no log by default: BRIDGE_LOG unset, empty or off writes nothing at all. With BRIDGE_LOG=json it writes one JSON object per line on stderr; any other value stops the worker at start with a message naming the variable. Every line has ts (UTC, ISO 8601 with milliseconds and Z), level (info or warning) and event, then exactly the keys of its event, null where a value is unknown or does not apply. Values come only from fixed sets, numbers, a job ID or a token's name: no input, result, header, query string, path, idempotency key, token, error message or exception text is ever written, and a key outside the table is dropped by the formatter whatever a caller passes. Lines are written by a thread of their own behind a queue of 1024: a stderr that blocks or fails never holds or fails a request or a job, and lines that do not fit are dropped. Logging is best effort: lines may be dropped when the queue is full or at exit. A stop waits about 2 s at most for the queued lines to be written, and a process that ends without that stop (killed, for instance) loses what was still queued.
event |
When | Keys |
|---|---|---|
request |
After the whole reply to a request is written, a 401, 408, 413, 431 and 503 included; a connection that ends before a reply is written in full (the peer gone mid-reply, or a busy 503 that could not be sent) writes none | method (GET, POST, other, or null when the request line could not be read or parsed), route (/healthz, /readyz, /metrics, /v1/jobs, /v1/jobs/{id}, /v1/jobs/{id}/cancel, or other for any other path, a query string included), status, duration_ms (from the start of reading the request to the reply sent), owner (the token's name, null when the request was not authenticated) |
job |
A job reaches succeeded, failed, timed_out or cancelled |
job_id, task, owner, job_status, queue_ms (submission to running, null if no child started it), run_ms (running to the terminal status, null likewise) |
start |
The port is bound and the children are starting | backend, concurrency, capacity, owner_capacity (the capacity when BRIDGE_OWNER_CAPACITY is unset), retention_seconds, max_jobs, drain_seconds, tokens (how many tokens are configured; never a name or a token) |
drain |
A drain ends, before the stop line: when it runs its course, before the worker stops serving; when a second signal ends it, once serving has stopped, waiting up to 5 s for a drain still held (one held longer writes none) |
duration_ms (from the signal), running (jobs still running when it ended, which the stop then cancels), interrupted (a second signal ended it) |
stop |
The worker has stopped its children and closed the job table | cancelled (unfinished jobs the stop ended as cancelled) |
level is warning for a request answered 5xx, a job that ended failed or timed_out, a drain that left jobs running and a stop that cancelled any; otherwise info. A 503 for a full connection budget is written without reading anything, so its method is null, its route other and its duration_ms 0. The timings are measured by the parent process, which reads a child's messages every 10 ms or so, so queue_ms and run_ms can each be off by that much. A job read back from BRIDGE_STATE_DIR ended in an earlier process and writes no line.