module

LLM::HttpTransport

Shared HTTP transport for the provider clients (OpenAI-compatible and Ollama).

Both clients used to post through the bare one-shot helpers (HTTP::Client.post / Crest.post), which set no timeouts at all: a provider that accepts the connection and then never answers — a wedged local ollama serve, a proxy that black-holes the request, a hosted endpoint that stalls mid-generation — hung the scan forever, with no output and no indication of what it was waiting on. Requests now fail after a bounded wait.

It also retries the transient failures every hosted provider produces (429 rate limits, 502/503 from a load balancer, a dropped socket). Without that, one blip turned into a silent zero-endpoint AI result: the clients map any failure to "", which the analyzer treats as "this code defines no endpoints".

Constants

CONNECT_TIMEOUT_ENV = "NOIR_AI_CONNECT_TIMEOUT"
DEFAULT_CONNECT_TIMEOUT = 10.seconds

Connecting is fast even for remote providers, so a short budget here only shortens the "server isn't running" feedback loop that local provider users (ollama, vLLM, LM Studio) hit most often.

DEFAULT_TIMEOUT = 300.seconds

Generation is not fast: a large bundle against a CPU-bound local model legitimately takes minutes, so the read budget is generous and tunable rather than tight.

INPUT_TOKENS = /"(?:prompt_tokens|input_tokens|prompt_eval_count)"\s*:\s*(\d+)/

Token counts as the provider reports them: OpenAI-style usage (prompt_tokens / completion_tokens, or Anthropic-style input_tokens / output_tokens) and Ollama's eval counts. Read with a regex rather than parsed: the model's own text is a JSON string, where these keys would appear with escaped quotes.

MAX_ATTEMPTS = 3
MAX_ERROR_SNIPPET_SIZE = 1024
MAX_RETRY_AFTER = 30.seconds

Cap on how long a provider's Retry-After may park the scan.

OUTPUT_TOKENS = /"(?:completion_tokens|output_tokens|eval_count)"\s*:\s*(\d+)/
RETRYABLE_STATUS = Set {408, 425, 429, 500, 502, 503, 504, 529}

Status codes worth another attempt: rate limits plus the transient gateway/overload family. A 400/401/404 is a configuration problem that retrying can only make slower.

TIMEOUT_ENV = "NOIR_AI_TIMEOUT"

Class methods

backoff(attempt : Int32) : Time::Span

1s, then 2s — bounded, and short enough that exhausting all attempts still returns while the caller is waiting.

Source
claim_request
Source
connect_timeout
Source
credential(headers : HTTP::Headers) : String | Nil

The key a request carries: a bearer token, or Anthropic's x-api-key.

Source
duration_from_env(name : String) : Time::Span | Nil

Reads a timeout override, in seconds. Anything that isn't a positive number (empty, 0, a word) falls back to the default instead of disabling the timeout — the point of the override is to move the bound, not to remove it.

Source
loopback?(host : String) : Bool
Source
max_requests

--ai-max-requests: a hard bound on how many HTTP requests (retries included) one run may send. 0 is unlimited. A request past the cap fails like any other, so the files it was for are listed as coverage gaps instead of silently dropped.

Source
max_requests=(max_requests : Int32)

--ai-max-requests: a hard bound on how many HTTP requests (retries included) one run may send. 0 is unlimited. A request past the cap fails like any other, so the files it was for are listed as coverage gaps instead of silently dropped.

Source
no_proxy?(host : String) : Bool

ponytail: suffix and * entries only; CIDR ranges are not matched.

Source
post_json(url : String, body : String, headers : HTTP::Headers) : String | Nil

POSTs a JSON body and returns the response body, or nil when the request could not be completed. Failures are reported here so every provider path surfaces them the same way instead of each client inventing its own (or, in Ollama's case, staying silent).

Source
post_json_result(url : String, body : String, headers : HTTP::Headers) : String | Rejection | Nil

post_json, except a rejection is returned unreported, for a caller that can recover from some of them.

Source
proxy_for(uri : URI) : URI | Nil

The proxy HTTPS_PROXY / HTTP_PROXY name for this request (lowercase wins, as in curl), or nil to connect directly. Loopback and NO_PROXY hosts always go direct: a local model behind a corporate proxy variable must stay reachable.

Source
read_timeout?(error : Exception) : Bool

A read/write timeout runs out the long budget: usually the model was still generating, and another attempt waits it out again and may pay for the same generation twice. Only a connect timeout is worth retrying. Matched on the message because the proxy shard re-wraps the error as a plain IO::Error ("... (Read timed out)"). ponytail: event loops word it "Connect timed out" / "connect timed out"; one that words it differently just loses the connect retry.

Source
record_usage(body : String) : Nil
Source
remember_secret(headers : HTTP::Headers) : Nil
Source
report(rejection : Rejection) : Nil
Source
requests_left?

False once --ai-max-requests is used up: a skipped request, not a provider failure.

Source
retry_after(response : HTTP::Client::Response | Nil) : Time::Span | Nil

A provider that tells us when to come back (429s usually do) knows better than the fixed backoff, as long as it stays within our cap. Either form the RFC allows: delay-seconds or an HTTP-date.

Source
retry_delay(response : HTTP::Client::Response | Nil, attempt : Int32) : Time::Span
Source
retryable_status?(code : Int32) : Bool
Source
timeout
Source
truncate_error_snippet(body : String) : String

Every provider error text that reaches stderr goes through here, so it is also where the key is masked — before the cut, so a key straddling it cannot leak its prefix.

Source
usage_summary(cache_hits : Int32) : String | Nil

One line for the end of a run, nil when it made no AI requests.

Source
warn_cleartext_key(uri : URI, headers : HTTP::Headers) : Nil

A key over plain http:// to another machine is readable by anything on the path. Local servers (ollama, vLLM, LM Studio) are the normal http case and stay quiet.

Source

Nested types