Skip to content

ADR-038: One endpoint

Status

Accepted; amended by ADR-039 (D10).

Decision

D1. Nine words for what an adapter could not answer

ProviderError carries one word of the closed models.ProviderFailure, in the order the bytes reach each stage: endpoint_unreachable (nothing answered, or not in time), endpoint_redirected (a 3xx, D7), endpoint_refused (any other status outside 200-299), response_too_large, response_not_json, response_not_a_completion (no choices[0].message.content string), answer_not_json (no object by any of D6's readings), answer_not_a_proposal, proposal_does_not_hold (EditProposal refuses it: unanswered, not refused). A Reply with None is an answer, not a failure.

D2. The system prompt is fixed, the user message is facts and one file, and the delimiter is not a boundary

The system message is a constant naming no pack or file; it asks for what the guard enforces, including that __import__ and importlib.import_module are imports. The user message is eight labelled facts from Consultation, the pack note, and the redacted context between two marker lines, each line behind its number in the file and a colon (12: code), which the system message says are not code. The markers only aid reading; providers/guard.py is the boundary. The numbers are added after redaction, so one cannot split a secret, and use a colon, because 12: f(x) is an illegal annotation and cannot compile where 12 | f(x) can: a model that copies them into its replacement is refused output_does_not_compile, not written. Because a model that has to count lines to answer with start_line and end_line can be off by one.

D3. The pack note is sent whole

All of Consultation.limitations, in the pack's order, after a sentence saying it is background and not the task. Because ADR-036 D1 never sends a row the pack refuses by policy, and nothing in the data links a limitation to a bail.

D4. The column is not sent

The message carries the line and the symbol only. Because Finding.column is 0-based where every line number here is 1-based, and the guard does not read it.

D5. One request per consultation, no retries, and a deadline in code

No retry: a 429 is endpoint_refused with the endpoint's own sentence. The body is JSON with sorted keys, so the same question is the same bytes. REQUEST_TIMEOUT_S = 120, deliberately not a configuration key, and below verification's 600 s, which is for a whole test suite. It bounds the whole body: it is read in pieces of at most 64 KiB and the deadline checked after each, so a reply dripped a byte at a time is endpoint_unreachable too, though it may run one read past the limit. The body of an error status is read the same way, and one that stalls or resets leaves the status line as the detail. Not bounded by it: the wait for the headers and the chunk-size line of a chunked body, which the HTTP library reads in a loop of its own; each wait there is bounded by the socket timeout alone, so an endpoint that drips those can hold a run. Because a retry resends source code for a row that is already needs_review, and 120 s is enough for a local model on a CPU.

D6. Three readings of the content, in a fixed order

The first reading that yields a JSON object wins: the whole content, then each fenced block, then the span from the first { to the last }. The object must be the envelope {"proposal": <object or null>}; a bare proposal is answer_not_a_proposal. Because the envelope separates "an edit" from "none", and being generous to a malformed answer is being generous to an untrusted one.

D7. The request goes to the host that was configured, and to no other

The model: block comes only from the user's own ~/.config/obelize/config.yml, with --model replacing its provider; a repository's model: block is refused with exit 2 before any adapter exists. The opener departs from urlopen's defaults: no proxy (ProxyHandler({})), no redirect (a 3xx is endpoint_redirected), no cookies, no compression, so the byte cap counts wire bytes. The URL is base_url.rstrip("/") + "/chat/completions", never urljoin, which drops a /v1; so a base_url with a query string or fragment is refused, as the path would land inside it. A failure detail names the host and port, never the URL. Plain http:// is accepted only for localhost, 127.0.0.0/8 and ::1, judged on the text with no lookup; any other host needs https:// or the user's model.allow_insecure_http: true, because the key and the source would cross the network in the clear. RESPONSE_BYTE_LIMIT is 1 MiB, far above any replacement the guard accepts. Because the payload and the key must reach only the host the run prints.

D8. The endpoint's own words are recorded, redacted and capped

A failure detail carries the status and the endpoint's body, passed through obelize.verify.redact with the run's environment and then cut to 500 characters. Every string in a ProviderError that obelize did not write is redacted, the operating system's included, and a lone surrogate in them is spelt out (\ud800), since the run folder is UTF-8. Because a gateway may quote back the key it rejected.

D9. An unset key sends no Authorization header

An empty or unset key in the variable api_key_env names omits the header; a remote endpoint then answers 401 through D8. That variable is named only in the user's configuration, and the key is read at request time from the mapping the redactor gets and never enters the payload. Because a local Ollama wants no credential.

D10. Nothing calls it yet

Superseded by ADR-039: obelize fix builds the adapter; build() returns None for provider: none, the default.

Consequences

  • temperature is 0 (an endpoint refusing it is endpoint_refused), response_format is json_object (degrading to D6), and max_tokens is not sent, since either spelling makes some endpoint refuse.
  • No runtime dependency is added: urllib, json, http.client and re are standard library.
  • A proxy is not honoured; its address belongs in base_url, where the run prints it.