feat: multi-provider support — Ollama Cloud + OpenCode Go

- Account.Provider field (ollama-cloud | opencode-go), backward compatible
- Model-based routing: common models served by combined pool, unique models
  routed to their provider only
- /go/v1/* path forces OpenCode Go provider (prefix stripped upstream)
- Merged /v1/models endpoint returns union of both catalogs (44 models)
- Failover: 429/402 → cooldown + failover; 5xx → retry without cooldown
- CLI: accounts add --provider flag, list shows provider column
- Body buffering: request body buffered (8 MiB cap) for failover replay
- opencode integration: unified provider 'oc' with all merged models
- 64 tests pass (unit + integration)
- Verified: glm-5 → ollama, mimo-v2.5 → go, gpt-oss:20b → ollama
This commit is contained in:
Atte149 2026-06-24 15:37:28 +03:00
parent 98bbc96bf5
commit 4fe15324c8
11 changed files with 973 additions and 125 deletions

View file

@ -1,28 +1,63 @@
package proxy
// retry.go holds the failover helpers used by handler.go. The retry loop itself
// lives inside Handler.proxyWithFailover (see handler.go) because it needs
// tight control over the moment a response starts streaming vs. is rejected
// pre-stream. This file documents the invariants the loop must maintain.
import (
"net/http"
"strings"
)
// Streaming invariant
// ===================
// Once an upstream account has returned a 2xx status AND the proxy has started
// writing the response body to the client (a single byte flushed), the request
// is committed: we MUST NOT switch accounts for that request. Any mid-stream
// upstream error is surfaced to the client as-is (truncated response); we never
// attempt to "restart" a streamed request on a different account, because the
// client has already received partial output and a retry would duplicate it.
// isRetriable reports whether an upstream status code should trigger failover
// to the next account before any bytes are streamed to the client.
//
// Pre-stream failover
// -------------------
// The window in which we CAN retry on another account is exactly:
// 1. The upstream HTTP request returned an error (network, timeout, EOF
// before any response).
// 2. The upstream returned 429 (Too Many Requests) — we mark the account
// cooldown and try the next.
// 3. The upstream returned 5xx — we try the next account WITHOUT marking
// cooldown (5xx may be transient and is not necessarily a rate limit).
// 429 — rate limit / usage limit (retryable cooldown)
// 402 — payment required; treated as retryable when the body text matches a
//
// Once copyResponse has called WriteHeader, no further retries are possible
// for this request.
// usage-window pattern ("usage limit", "limit reached", "exhausted",
// "daily/weekly/monthly limit"). A bare "insufficient credits" is also
// retriable (billing disable) — we still fail over, just with a longer
// cooldown.
//
// 5xx — transient server error
// 408 — request timeout
// Other 4xx are not retriable: the request is malformed or the model is
// unknown, so retrying on another account would fail identically.
func isRetriable(status int) bool {
switch {
case status == http.StatusTooManyRequests:
return true
case status == http.StatusPaymentRequired:
return true
case status >= 500:
return true
case status == http.StatusRequestTimeout:
return true
default:
return false
}
}
// shouldCooldown reports whether an account should be marked cooldown after a
// retriable failure. 5xx does not trigger cooldown (the error may be
// transient and unrelated to this account's quota). 429 and 402 always do.
func shouldCooldown(status int, bodyPreview string) bool {
switch {
case status == http.StatusTooManyRequests:
return true
case status == http.StatusPaymentRequired:
return true
default:
return false
}
}
// isBillingDisable reports whether the error body indicates a billing/credit
// exhaustion (longer cooldown) rather than a transient usage-window limit.
// Used for logging/diagnostics; the cooldown duration is the same in v1.
func isBillingDisable(bodyPreview string) bool {
low := strings.ToLower(bodyPreview)
for _, marker := range []string{"insufficient credits", "credit balance", "billing"} {
if strings.Contains(low, marker) {
return true
}
}
return false
}