Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ PROXY_RESPONSES_BUFFER_MS=600000
# against later stream failures, but delays visible output while buffering.
#PROXY_RESPONSES_EARLY_COMMIT=1

# Native Gemini streamGenerateContent?alt=sse: buffer through clean completion,
# then replay unchanged. Client timeouts must exceed this window. 0 keeps full
# buffering without a timed live switch; upstream idle/request deadlines remain.
PROXY_GEMINI_BUFFER_MS=600000

# Extra attempts for recoverable streaming failures before sending a response.
# 0 retains only one limited fast retry for selected connection/HTTP failures.
# Positive values enable broader retries on both Messages and Responses.
# Positive values enable broader retries on Messages, Responses, and Gemini SSE.
PROXY_TRANSACTIONAL_LOCAL_RETRIES=0

# Local retries wait for upstream Retry-After plus an exponential extra delay.
Expand Down
53 changes: 42 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,21 @@ It provides:
Agents / SDKs / applications → steady-proxy on localhost → your gateway
```

Stream protection is built around **Anthropic Messages** (`/v1/messages`) and
**OpenAI Responses** (`/v1/responses`). Claude Code and Codex are supported clients;
other agents and SDK-based applications can use these routes too, with compatible
timeout, retry, and SSE handling. Subagents using the same API settings are covered
without terminal automation. See [client compatibility](docs/ARCHITECTURE.md#client-compatibility)
for the requirements and behavior of other routes.
Stream protection supports **Anthropic Messages** (`/v1/messages`),
**OpenAI Responses** (`/v1/responses`), and native **Gemini GenerateContent**
(`/{version}/models/{model}:streamGenerateContent?alt=sse`). Claude Code and Codex
are supported clients; other agents and SDK-based applications can use these
routes with compatible timeout, retry, and SSE handling. Subagents using the same
API settings are covered without terminal automation. See
[client compatibility](docs/ARCHITECTURE.md#client-compatibility) for requirements
and the behavior of other routes.

## What to expect

By default, Messages streams are buffered for up to 10 minutes. A response that
finishes within that window appears all at once. Responses streams go live as soon
as output arrives; you can enable full buffering to protect them from later stream
failures.
By default, Messages and Gemini SSE streams are buffered for up to 10 minutes.
A response that finishes within that window appears all at once. Responses
streams go live as soon as output arrives; you can enable full buffering to
protect them from later stream failures.

Before the proxy sends a response, it can discard a failed attempt and retry.
After it starts streaming to the client, recovery depends on the client's own
Expand Down Expand Up @@ -125,9 +127,38 @@ PROXY_TRANSACTIONAL_LOCAL_RETRIES=3
```

Full buffering delays visible output until completion or the buffering window
ends. Extra retries apply to both supported streaming APIs and must fit within
ends. Extra retries apply to all three supported streaming APIs and must fit within
the active window. See [retry budgets](docs/ARCHITECTURE.md#retry-budgets).

### Gemini

Use a gateway that accepts native Gemini requests, or set
`PROXY_UPSTREAM_URL=https://generativelanguage.googleapis.com` for Google's
Developer API. Point your SDK's HTTP base URL at `http://127.0.0.1:8789`, retaining
its API version and model settings. The proxy forwards `x-goog-api-key`, bearer
authentication, and query parameters unchanged; it does not translate protocols.

For a direct SSE request, set `GEMINI_MODEL` to an available model and keep your
existing `GEMINI_API_KEY`:

```bash
curl -N --max-time 720 \
"http://127.0.0.1:8789/v1beta/models/${GEMINI_MODEL}:streamGenerateContent?alt=sse" \
-H "x-goog-api-key: ${GEMINI_API_KEY}" \
-H 'Content-Type: application/json' \
-d '{"contents":[{"parts":[{"text":"Say hello."}]}]}'
```

Gemini selects streaming through the method and `alt=sse`, not a `stream` body
field. `generateContent` and `countTokens` remain single-shot JSON requests with
Gemini-shaped HTTP errors. Standard Vertex AI project/location/publisher model
paths are also recognized; keep the Vertex upstream host and bearer credentials.

`PROXY_GEMINI_BUFFER_MS` controls the independent 600,000 ms buffering window.
Set client timeouts above it and configure bounded retries for HTTP `503`, honoring
`Retry-After`. After a timed switch to live output, keepalives are SSE comments;
failed streams abort the HTTP transport and require client recovery.

### Other API clients

Set your client's base URL so it sends Messages requests to
Expand Down
46 changes: 31 additions & 15 deletions classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ func anyContains(s string, subs []string) bool {
return false
}

// classifyTransport handles a failure that occurred before/around the response
// headers (dial/TLS/reset/timeout).
// classifyTransport handles failed upstream I/O and request cancellation.
func classifyTransport(err error, ctx context.Context) failure {
switch {
case ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded):
Expand Down Expand Up @@ -156,6 +155,19 @@ func classifyHTTPErrorBytes(resp *http.Response, b []byte) failure {
return classifyHTTPErrorBytesShaped(resp, b, anthropicRequestShaped)
}

// httpRetryHint returns the first recognized authoritative retry verdict.
func httpRetryHint(h http.Header) (source string, retry bool) {
for _, name := range [...]string{"x-gateway-retryable", "x-should-retry"} {
switch strings.ToLower(h.Get(name)) {
case "true":
return name, true
case "false":
return name, false
}
}
return "", false
}

// classifyHTTPErrorBytesShaped is classifyHTTPErrorBytes with a pluggable
// request-shape decision so each wire keeps its own "can never succeed" rule
// while sharing the status/header handling.
Expand All @@ -178,17 +190,17 @@ func classifyHTTPErrorBytesShaped(resp *http.Response, b []byte, reqShaped reque
// beats every heuristic below. x-should-retry is the Anthropic API's own near-
// equivalent. Both are absent from a plain provider, which falls through
// to status/body classification.
switch strings.ToLower(resp.Header.Get("x-gateway-retryable")) {
case "true":
return failure{transient: true, status: mapTransientStatus(st), atype: atype, code: "gateway_retryable_" + itoa(st), message: ae.Error.Message, retryAfter: ra}
case "false":
switch source, retry := httpRetryHint(resp.Header); source {
case "x-gateway-retryable":
if retry {
return failure{transient: true, status: mapTransientStatus(st), atype: atype, code: "gateway_retryable_" + itoa(st), message: ae.Error.Message, retryAfter: ra}
}
return failure{transient: false, status: st, atype: atype, code: "gateway_permanent", message: ae.Error.Message, retryAfter: ra}
}
switch strings.ToLower(resp.Header.Get("x-should-retry")) {
case "false":
case "x-should-retry":
if retry {
return failure{transient: true, status: mapTransientStatus(st), atype: atype, code: "upstream_retry_" + itoa(st), message: ae.Error.Message, retryAfter: ra}
}
return failure{transient: false, status: st, atype: atype, code: "upstream_no_retry", message: ae.Error.Message}
case "true":
return failure{transient: true, status: mapTransientStatus(st), atype: atype, code: "upstream_retry_" + itoa(st), message: ae.Error.Message, retryAfter: ra}
}

switch {
Expand Down Expand Up @@ -333,12 +345,16 @@ func surfaceFor(path string, f failure) (status int, atype string) {
return status, atype
}

// Codex ignores X-Should-Retry, so exhausting/disabling the proxy retry budget
// must also produce its terminal HTTP status. Keep the original failure intact
// for diagnostics, and preserve Claude's generic error shape with its false header.
// Clients that ignore X-Should-Retry need a terminal status when retry conversion
// is disabled or exhausted. Keep the original failure intact for diagnostics.
func retrySurfaceFor(path string, f failure, canRetry bool) (int, string) {
if isResponsesPath(path) && !canRetry {
if (isResponsesPath(path) || isGeminiPath(path)) && !canRetry {
wasTransient := f.transient
f.transient = false
status := statusFor(f)
if isGeminiPath(path) && (wasTransient || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500) {
f.status = http.StatusBadRequest
}
}
return surfaceFor(path, f)
}
Expand Down
2 changes: 2 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func TestParseConfigRejectsBadValues(t *testing.T) {
_, err := parseConfig(envOf(map[string]string{
"PROXY_UPSTREAM_URL": "https://gw.example.com",
"PROXY_KEEPALIVE_MS": "60s",
"PROXY_GEMINI_BUFFER_MS": "-1",
"PROXY_UPSTREAM_BYTE_IDLE_MS": "0",
"PROXY_SDK_RETRY_CAP": "-1",
"PROXY_MAX_REQUEST_BYTES": "0",
Expand All @@ -72,6 +73,7 @@ func TestParseConfigRejectsBadValues(t *testing.T) {
}
for _, want := range []string{
`PROXY_KEEPALIVE_MS="60s": not an integer`,
`PROXY_GEMINI_BUFFER_MS=-1: must be >= 0`,
`PROXY_UPSTREAM_BYTE_IDLE_MS=0: must be >= 1`,
`PROXY_SDK_RETRY_CAP=-1: must be >= 0`,
`PROXY_MAX_REQUEST_BYTES=0: must be >= 1`,
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ services:
PROXY_WORKFLOW_KEEPALIVE_MS: "${PROXY_WORKFLOW_KEEPALIVE_MS:-10000}" # Shorter window for Workflow content progress; see docs/ARCHITECTURE.md.
PROXY_RESPONSES_KEEPALIVE_MS: "${PROXY_RESPONSES_KEEPALIVE_MS:-30000}" # Silent-start window when early commit is enabled.
PROXY_RESPONSES_BUFFER_MS: "${PROXY_RESPONSES_BUFFER_MS:-600000}" # Full-buffer window when early commit is disabled.
PROXY_GEMINI_BUFFER_MS: "${PROXY_GEMINI_BUFFER_MS:-600000}" # Native Gemini SSE buffering window; 0 disables the timed live switch.
PROXY_RESPONSES_EARLY_COMMIT: "${PROXY_RESPONSES_EARLY_COMMIT:-1}" # 0 buffers Responses; client idle timeout must exceed the buffer window.
PROXY_RESPONSES_REPLAY_DELTA_BYTES: "${PROXY_RESPONSES_REPLAY_DELTA_BYTES:-65536}" # Combine compatible text deltas during replay; 0 keeps raw replay.
PROXY_RESP_HEADER_TIMEOUT_MS: "${PROXY_RESP_HEADER_TIMEOUT_MS:-660000}" # Overrides the binary default of 60000 ms for slow gateway headers.
Expand Down
62 changes: 47 additions & 15 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ attempt possible within the configured budgets.

## Client compatibility

The proxy selects stream behavior from the HTTP method, API path, and request's
`stream` field. It does not require a Claude Code or Codex user agent. Streaming
`POST` requests on the Anthropic Messages and OpenAI Responses routes use the same
buffering and retry engine, with a separate parser for each protocol.
The proxy selects stream behavior from the HTTP method, API path, and request.
Messages and Responses use the body's `stream` field; Gemini uses the
`streamGenerateContent` method with `alt=sse`. These streaming `POST` requests
share one buffering and retry engine, with a separate interpreter per protocol.
No specific user agent is required.

Other agents, SDKs, and applications can use these routes when they support the
proxy's timeout, retry, and stream behavior. The integration tests exercise both
proxy's timeout, retry, and stream behavior. Integration tests exercise all three
protocols using ordinary HTTP requests. The live CLI harness uses Claude Code;
it does not establish compatibility with every client.

Expand All @@ -24,6 +25,7 @@ it does not establish compatibility with every client.
| HTTP retries | Retry HTTP `503` with `Retry-After`. Honor `X-Should-Retry: false` where supported. Local proxy retries can recover some failures even if the client does not retry. |
| Stream recovery | Handle an incomplete response after headers have been sent. The proxy cannot restart a committed response for the client. |
| Responses events | Tolerate the additional `response.proxy_keepalive` event. Completion checks and content handling follow Codex's parser expectations. |
| Gemini events | Use native `GenerateContentResponse` SSE frames and tolerate SSE comment keepalives. The proxy preserves tool arguments, thought signatures, and unknown fields. |
| Retry counter | Without `X-Stainless-Retry-Count`, the proxy treats the client retry count as zero; a positive `PROXY_SDK_RETRY_CAP` cannot bound those client retries. |

Some compatibility choices apply to every request on a protocol's routes.
Expand All @@ -35,8 +37,9 @@ system-prompt marker.

Other routes, including `/v1/chat/completions`, are forwarded with one upstream
attempt per client call. They do not receive stream validation, transactional
buffering, or model fallback. HTTP errors can still be normalized for retry, using
the Anthropic-style error envelope on non-Responses paths.
buffering, or model fallback. HTTP errors can still be normalized for retry.
Known Gemini model methods use Google's error envelope; Responses uses OpenAI's,
and remaining routes use the Anthropic-style envelope.

## Buffering and the point of no return

Expand All @@ -51,19 +54,41 @@ cannot replace the response with an HTTP error. If a stream fails after that
point, the proxy ends it and logs `DROP`; recovery depends on the client's stream
retry support and remaining budget.

The two APIs choose that point differently:
The protocols choose that point differently:

| Request | Default behavior | Successful completion |
| --- | --- | --- |
| Anthropic Messages | Buffer for up to 600 seconds, then switch to live output. | A valid stream ending in `message_stop`. |
| Claude Code Workflow agent | Use a 10-second window, then switch once real content is available. | The same Messages completion. |
| OpenAI Responses | Switch to live output as soon as an output event arrives; a 30-second window bounds a silent start. | A valid `response.completed`. |
| Responses with `PROXY_RESPONSES_EARLY_COMMIT=0` | Buffer for up to 600 seconds, then switch to live output. | A valid `response.completed`. |
| Gemini `streamGenerateContent?alt=sse` | Buffer for up to 600 seconds, then switch to live output. | Clean transport EOF after every requested candidate has a `finishReason`, or a native prompt block. |

Buffering trades immediate output for a chance to recover before the client sees
anything. Choose full buffering when that protection matters more than seeing
tokens arrive live.

Gemini supports native model methods under `/v1`, `/v1beta`, `/v1alpha`, and
`/v1beta1`, including standard Vertex AI
`/projects/{project}/locations/{location}/publishers/google/models/{model}` paths.
Only `streamGenerateContent?alt=sse` receives transactional protection;
`generateContent`, `countTokens`, and non-SSE streaming transports remain
single-shot. Gemini has no separate terminal SSE event, so the proxy waits for
clean EOF, retaining usage-only frames after candidate completion. Missing
candidates, unfinished SSE frames, and read faults fail the attempt. SSE control
frames and empty data messages do not advance candidate completion. Native
`MAX_TOKENS`, safety stops, and prompt blocks without candidates are delivered
unchanged, not retried as transport failures. A prompt block accompanied by
candidates is contradictory and cannot satisfy completion. See Google's
[GenerateContent contract](https://ai.google.dev/api/generate-content).
Candidate counts follow [ProtoJSON](https://protobuf.dev/programming-guides/json/):
camelCase and snake_case field names, quoted integers, exponent notation, and zero
fractional parts are accepted without rewriting the request.

After a Gemini response commits, a failure aborts the downstream HTTP transport:
a normal EOF could otherwise appear successful to an SDK. No committed attempt
is replayed. Whether the client recovers depends on its stream-retry behavior.

### One window per client request

The buffering window starts when the client request reaches the proxy. Waiting
Expand All @@ -73,10 +98,10 @@ that same window. None of them restart its clock.
When the window ends, a pending header or error-body read is cancelled and becomes
eligible for a client retry. An active SSE capture switches to live output;
Workflow requests also wait for real forwardable content. In live mode, keepalives
help keep the client connection open. Messages clients receive SSE comments;
Responses clients receive a `response.proxy_keepalive` event. This event is
skippable in Codex, whose SSE reader discards comments without resetting its idle
timer; other Responses clients must also tolerate it.
help keep the client connection open. Messages and Gemini clients receive SSE
comments; Responses clients receive a `response.proxy_keepalive` event. This
event is skippable in Codex, whose SSE reader discards comments without resetting
its idle timer; other Responses clients must also tolerate it.

Keepalives cannot extend an absolute request deadline or make a silent upstream
produce content. The separate upstream idle timeout still applies. See
Expand All @@ -88,8 +113,9 @@ Before commit, a recoverable failure has two possible paths:

1. If extra local retries are enabled and there is enough time, the proxy waits
and sends the request again. The client continues waiting on its original call.
2. Otherwise, the proxy returns HTTP `503` with an `api_error`,
`X-Should-Retry: true`, and `Retry-After`. The client can then retry the call.
2. Otherwise, the proxy returns HTTP `503` with `X-Should-Retry: true` and
`Retry-After`. Messages and Responses use `api_error`; Gemini uses its native
numeric `error.code: 503` and `error.status: "UNAVAILABLE"` envelope.

Using one generic error shape matters for Claude Code. Recognizable overload and
rate-limit errors can enter special client handlers with smaller retry budgets.
Expand Down Expand Up @@ -136,6 +162,12 @@ not send the Stainless counter, so any positive proxy cap leaves its retry limit
to `request_max_retries` and `stream_max_retries`. Other clients without that
header likewise need to enforce their own retry limit.

Gemini clients likewise own their retry budgets when they do not send the
Stainless counter. If conversion is disabled or exhausted, a transient Gemini
failure becomes terminal HTTP `400`, since its SDK may ignore `X-Should-Retry`.
Explicitly non-retryable Gemini `408`, `429`, and `5xx` errors also become `400`
so a gateway's `false` retry verdict cannot trigger an SDK's status-based retry.

The same counter determines the `Retry-After` sent to the client when the upstream
provides no delay. It grows exponentially from one second to 30 seconds, plus
0–1 seconds of jitter. Without the counter, the synthesized delay remains
Expand Down Expand Up @@ -169,7 +201,7 @@ Recognized safeguards errors can also trigger fallback when the upstream sends
classification; disable fallback separately with `PROXY_REFUSAL_FALLBACK_MODEL=off`
if the original safeguards error should reach the client.

Fallback does not apply to OpenAI Responses or non-streaming requests. It detects
Fallback does not apply to OpenAI Responses, Gemini, or non-streaming requests. It detects
the structured refusal and recognized safeguards messages; it does not inspect
ordinary assistant text for a refusal.

Expand Down
Loading
Loading