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:
parent
98bbc96bf5
commit
4fe15324c8
11 changed files with 973 additions and 125 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue