providers: a provider has a Requests per minute limit beside Concurrency: each key or account sends the vendor at most that many requests in any rolling minute, one more waits for room (coeo91 on Discord: OpenRouter's free models take 20 a minute, which Concurrency 1 can't keep to)
Set in the provider editor (under Concurrency; empty or 0 is no limit) or with `magpie provider rpm <id> [n|off]`; kept per provider as MaxRPM, a subscription's with its picks. Over the limit the gateway waits until the oldest request of the minute is a minute old, in the order they came, after the lane's slot and right before sending. A wait longer than 2 minutes (or the provider's Queue wait, when shorter) is turned away at once with a 429 and a Retry-After of when the minute has room; nothing rests for it. An agent gone while waiting gives its place back and is never sent. Every request that reaches the vendor counts: each try of the forward loop (retries, the next key, fallbacks) waits in serve, and requests a try makes on the way plus the gateway's own (count_tokens, System One decisions, embeddings, drawing/video, Kimi search) are counted by an rpmTransport on s.client. count_tokens never waits: a key with no room is skipped and the local estimate answers. A key or account with no room in its minute gives way to a ready one of the provider or the group, as a full lane does (laneMate). No wait happens under rpms.mu. Not counted: a plugin account's own extra requests (its own transport; its tries are). Verified (Go tests on Linux in Docker, golang:1.26.8, since new binaries hang at launch on this Mac while syspolicyd is pegged): - internal/gateway rpm_test.go through the real server stack (lanGuard(s.Handler())): limit 2, third request reaches the vendor a window after the first, all 200; past the bound a 429 with Retry-After and no rest; a cancelled waiter is never sent and gives its place back; a 503 retry and a count_tokens count, and a count with no room is estimated; a group member with no room gives way. With RPMLimit forced to 0, those five gateway tests fail. -race -count=5 passes. - internal/provider TestSetRPM (clamp, subscription picks reach each account), TestRPMCmd, internal/gui TestRPMSave. - go test -tags nogui ./... on Linux: all pass except internal/agent's TestAside* write-refusal tests, which fail only as root in Docker and pass as uid 1000. - go vet -tags nogui for linux, windows and darwin; gofmt -l clean. - GUI: new provider-rpm.test.cjs (en/zh/ja/de, 360 and 440px, Chromium and WebKit; 32 pass, and all 16 Chromium cases fail on the old app.js), plus gui-ja, gui-de, gui-zh-tw, i18n-keys, provider-concurrency, lane-limit, provider-price-rate and clinepass-pin in both engines. Not tried against the real OpenRouter.
This commit is contained in:
1 parent
de5455b8fe
commit
0470411122
19 files changed
+1165
-24
No files matched your search
@@ -83,3 +83,47 @@ func TestAccountConcurrencyCmd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// magpie provider rpm sets how many requests each key sends in a minute
|
||||
// (coeo91 on Discord), off for none; a number out of range is an error.
|
||||
func TestRPMCmd(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
t.Setenv("DSH_HOME", "")
|
||||
if err := provider.Save(provider.Provider{ID: "or", Name: "OpenRouter", Key: "k1", Chat: "http://127.0.0.1:1/v1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rpm := func() int {
|
||||
p, err := provider.Find("or")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p.RPMLimit()
|
||||
}
|
||||
if err := providerCmd([]string{"provider", "rpm", "or", "20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := rpm(); n != 20 {
|
||||
t.Fatalf("set: %d, want 20", n)
|
||||
}
|
||||
if err := providerCmd([]string{"provider", "rpm", "or"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range [][]string{{"-3"}, {"20000"}, {"x"}, {"2", "3"}} {
|
||||
if err := providerCmd(append([]string{"provider", "rpm", "or"}, bad...)); err == nil {
|
||||
t.Fatalf("%v taken", bad)
|
||||
}
|
||||
}
|
||||
if n := rpm(); n != 20 {
|
||||
t.Fatalf("after refused ones: %d, want 20", n)
|
||||
}
|
||||
if err := providerCmd([]string{"provider", "rpm", "or", "off"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := rpm(); n != 0 {
|
||||
t.Fatalf("off: %d", n)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
| Model resolution | `serve` resolves the model the agent asked for: Claude tier stand-ins, a routing group (`provider.GroupFor`: its id, the model's id however a vendor spells it, else the one group whose name it is, unless a provider serves a model by that id), auto stand-ins, then `provider.Resolve`. An unknown model gets a 404 naming why: a switched-off provider, a disabled group or an empty group. | [`gateway.go`](../../internal/gateway/gateway.go), [`internal/provider/group.go`](../../internal/provider/group.go) |
|
||||
| Candidates | `plan` lists one provider's candidates: one per key or account on, then each `Fallback` model, with resting ones last (`restLast`). `planGroup` and `planLevel` weigh a group's members together. A nested group plans by its own routing (#576). A member's own fallbacks are not the group's. | [`fallback.go`](../../internal/gateway/fallback.go) |
|
||||
| Order of keys and accounts | `Routing`: smart (""), `order`, `rotate`, `usage` (least used, with an `usageHalfLife` of 1h), `pace` or `weight` (`weightedFirst`). Smart, `usage` and `pace` weigh a subscription by its allowance (`allowances`), and a key by its own usage windows when magpie reads them (`keyAllowance`: a sub2api key given a 5-hour, day or 7-day limit, or a key bought as a plan, such as Kimi Code's or a GLM Coding Plan's); any other key has none. A lone key or account is weighed against nobody, but what it has left is still read into the trace (`weighRouted`), so the Routing page shows it as it does for several (#1016); all but used up, it keeps its conversation rather than being said to move (`affine`). A failure rests a candidate (`failure`, `failureOf`). | [`routing.go`](../../internal/gateway/routing.go), [`weighted.go`](../../internal/gateway/weighted.go), [`internal/provider/routing.go`](../../internal/provider/routing.go) |
|
||||
| Limits | Each key or account (a candidate's `who()`) has at most `MaxConcurrency` requests out at once (its own `AccountConcurrency` over it); one more waits in its lane's queue, bounded by `QueueLimit` and `QueueWait` and turned away with a 429 past either (`lanes.take`). `MaxRPM` (coeo91 on Discord: OpenRouter's free models take 20 a minute) bounds how many requests it sends the vendor in any rolling 60 seconds (`rpms`): one more waits for room in the minute, in the order they came, after it has its slot and right before it is sent. A wait that would be longer than 2 minutes (or the provider's `QueueWait`, when shorter: `rpmWait`) is turned away at once with a 429 whose `Retry-After` is when the minute has room; nothing rests for it. An agent gone while it waits gives its place back. Every request that reaches the vendor counts: each try of the candidate loop (a retry, the next key, a fallback) waits in `serve`, and the requests a try makes on the way and the gateway's own asks (count_tokens, System One's decisions, embeddings, drawing, Kimi's search) are counted by `rpmTransport` on `s.client` (`metered`, `paidFor`). count_tokens never waits: a candidate with no room is skipped, and the local estimate answers. A full lane or a minute with no room gives way to a ready key or account of the provider or the group (`laneMate`, `ready`); a fallback is never asked for want of either. A plugin's account sends through its own transport, so only its tries are counted, not a plugin's own extra requests. Set in the provider editor (Concurrency, Requests per minute, Queue size, Queue wait) or with `magpie provider account-concurrency`, `queue` and `rpm`; a subscription's are kept with its picks. | [`concurrency.go`](../../internal/gateway/concurrency.go), [`rpm.go`](../../internal/gateway/rpm.go), [`internal/provider/concurrency.go`](../../internal/provider/concurrency.go) |
|
||||
| Sink | With `Sink` on, a key or account that answers 429 while it still has quota goes to the back, behind every one not rate limited since. Held in memory until restart. | [`sink.go`](../../internal/gateway/sink.go) |
|
||||
| Affinity | A conversation stays with the key or account that answered it, so the vendor's prompt cache is read again. Modes: auto (""), `session`, `turn`, `off`. Stored in `affinity.json` next to `providers.json`. | [`affinity.go`](../../internal/gateway/affinity.go) |
|
||||
| One try | `attempt` sends one candidate the request. It goes through the proxy of the account or key it uses, else the provider's (`p.Via`, `ProxyChoice`: `AccountProxies` holds an account's by its name, a key's by its `KeyID`). A subscription with its own client is served specially, such as Claude through the genuine Claude Code. The request is relayed as is (`passthrough`) when the model is usable on the agent's API, except that a Claude model where the provider has an Anthropic URL is translated to Messages from Chat or Responses too (`OnMessages`: OpenCode speaks Chat for every model, and a relay drops cache_control there), unless the user set the model's API or the vendor's list names its APIs. A request offering web search to a provider that doesn't search by itself is translated too, so magpie can search for it (`search.go`), but on the agent's own API where the provider serves it and no `ResponsesFirst`/`MessagesFirst` rule prefers another: Codex offers web_search on every turn, and each went out as Chat (#997). Otherwise it is translated (`translate`) to the first API `usable` gives: Chat, except Responses for OpenAI's models where their maker serves them (`ResponsesFirst`) and Anthropic's Messages for a Claude model where the provider has an Anthropic URL (`MessagesFirst`, which keeps its cache breakpoints, #997). `Provider.Native`, the API agents with a per-model API (Pi, omp, droid) are wired to ask on, follows the same order. | [`gateway.go`](../../internal/gateway/gateway.go) |
|
||||
@@ -64,8 +65,8 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestModelNotInPlanRestsTheModelNotTheKey|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestProtection|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords'
|
||||
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed|TestSkipsRedaction'
|
||||
go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestModelNotInPlanRestsTheModelNotTheKey|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestProtection|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords|RPM'
|
||||
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed|TestSkipsRedaction|TestSetRPM'
|
||||
```
|
||||
|
||||
The gateway's `TestMain` gives the package a home of its own, so these tests
|
||||
|
||||
@@ -152,13 +152,14 @@ func (l *lanes) free(who string, limit int) bool {
|
||||
}
|
||||
|
||||
// laneMate is where, after i, the first candidate is with a slot free now
|
||||
// — another key or account of cands[i]'s provider, or in a routing group
|
||||
// any member — when cands[i]'s key or account is full; -1 when it isn't
|
||||
// and room in its minute — another key or account of cands[i]'s provider,
|
||||
// or in a routing group any member — when cands[i]'s key or account is
|
||||
// full, or has sent its MaxRPM in the last minute; -1 when it isn't
|
||||
// full, gave way once already, or none has room.
|
||||
func (s *Server) laneMate(cands []candidate, i int, group bool, gave map[string]bool) int {
|
||||
c := cands[i]
|
||||
who := c.who()
|
||||
if gave[who] || s.lanes.free(who, c.p.LaneLimit()) {
|
||||
if gave[who] || s.ready(c) {
|
||||
return -1
|
||||
}
|
||||
for j := i + 1; j < len(cands); j++ {
|
||||
@@ -166,16 +167,25 @@ func (s *Server) laneMate(cands []candidate, i int, group bool, gave map[string]
|
||||
if m.who() == who || !group && m.p.ID != c.p.ID {
|
||||
continue
|
||||
}
|
||||
if s.lanes.free(m.who(), m.p.LaneLimit()) {
|
||||
if s.ready(m) {
|
||||
return j
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ready is whether c's request would go at once: a slot free in its lane,
|
||||
// and room in its minute (rpm.go).
|
||||
func (s *Server) ready(c candidate) bool {
|
||||
return s.lanes.free(c.who(), c.p.LaneLimit()) && s.rpms.free(c.who(), c.p.RPMLimit())
|
||||
}
|
||||
|
||||
// laneMessage tells the agent why its request was turned away by c's
|
||||
// queue: full, or waited out.
|
||||
func laneMessage(c candidate, err error, queued int64) string {
|
||||
if e := (*errRPM)(nil); errors.As(err, &e) {
|
||||
return fmt.Sprintf("%s: %s; try again then", c.label(), e.Error())
|
||||
}
|
||||
lim := c.p.LaneLimit()
|
||||
if errors.Is(err, errQueueFull) {
|
||||
return fmt.Sprintf("%s: %d requests at once is its limit, and its queue is full (%d waiting); try again shortly", c.label(), lim, c.p.QueueLimit)
|
||||
|
||||
@@ -353,7 +353,7 @@ func (s *Server) systemOne(ctx context.Context, p provider.Provider, model strin
|
||||
// the provider answers in another one. DecideURL and Sign errors are as
|
||||
// they are; a transport error is named with p.
|
||||
func (s *Server) postDecide(ctx context.Context, p provider.Provider, model string, body []byte) (int, []byte, string, error) {
|
||||
ctx = p.Via(ctx)
|
||||
ctx = s.metered(p.Via(ctx), p, "") // counted against its MaxRPM (rpm.go)
|
||||
u, err := p.DecideModelURL(ctx, model)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
|
||||
@@ -693,7 +693,7 @@ func (s *Server) sendAs(ctx context.Context, p provider.Provider, method, url, c
|
||||
|
||||
// sendWith is sendAs with headers of the vendor's own besides.
|
||||
func (s *Server) sendWith(ctx context.Context, p provider.Provider, method, url, contentType string, body []byte, sign bool, extra http.Header) ([]byte, int, error) {
|
||||
ctx = p.Via(ctx)
|
||||
ctx = s.metered(p.Via(ctx), p, "") // counted against its MaxRPM (rpm.go)
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 500, err
|
||||
|
||||
@@ -181,7 +181,7 @@ func resolveRetrievalModel(id string) (provider.Provider, string, bool) {
|
||||
// retrieveFrom posts body to url as the provider signs its requests, and
|
||||
// reads the answer; a failure's code is the vendor's, with its message.
|
||||
func (s *Server) retrieveFrom(ctx context.Context, p provider.Provider, url string, body []byte) ([]byte, int, error) {
|
||||
ctx = p.Via(ctx)
|
||||
ctx = s.metered(p.Via(ctx), p, "") // counted against its MaxRPM (rpm.go)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 500, err
|
||||
|
||||
@@ -324,7 +324,10 @@ type Server struct {
|
||||
sightOrder []string
|
||||
// the requests out at each key or account with a MaxConcurrency, and
|
||||
// those waiting their turn (concurrency.go)
|
||||
lanes lanes
|
||||
lanes lanes
|
||||
// the requests each key or account with a MaxRPM sent in the last
|
||||
// minute, and those about to go (rpm.go)
|
||||
rpms rpms
|
||||
requestLimits requestLimits
|
||||
// what a restart would cut short (busy.go)
|
||||
busyCounters
|
||||
@@ -335,14 +338,15 @@ func New() *Server {
|
||||
redact.SetKeyPath(filepath.Join(settings.Dir(), "redact.key"))
|
||||
return &Server{
|
||||
// a provider with a proxy of its own is sent through a transport
|
||||
// kept for that proxy (#237)
|
||||
client: &http.Client{Transport: netproxy.Dispatch(&http.Transport{
|
||||
// kept for that proxy (#237); a request counted against a MaxRPM
|
||||
// waits for room in its minute first (rpm.go)
|
||||
client: &http.Client{Transport: rpmTransport{netproxy.Dispatch(&http.Transport{
|
||||
Proxy: netproxy.Func,
|
||||
ResponseHeaderTimeout: 10 * time.Minute,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
})},
|
||||
})}},
|
||||
unfit: make(map[string]bool),
|
||||
subscription: newSubscriptionBridge(),
|
||||
debug: os.Getenv("MAGPIE_DEBUG") != "",
|
||||
@@ -969,12 +973,19 @@ func (s *Server) countOn(w http.ResponseWriter, r *http.Request, p provider.Prov
|
||||
if who, held := accountHolds(r); held {
|
||||
counts = slices.DeleteFunc(counts, func(c candidate) bool { return !accountAllowed(who, c) })
|
||||
}
|
||||
// a count doesn't wait for room in a MaxRPM's minute: one with
|
||||
// none now is left out, and where none has room the estimate
|
||||
// below says the count (rpm.go)
|
||||
counts = slices.DeleteFunc(counts, func(c candidate) bool { return !s.rpms.free(c.who(), c.p.RPMLimit()) })
|
||||
}
|
||||
// the names in force, read at most once however many candidates are
|
||||
// counted, and not at all where there are none
|
||||
wires := sync.OnceValue(func() map[string]string { return settings.Load().ModelWires })
|
||||
for i, c := range counts {
|
||||
res, err := s.forward(r.Context(), c.p, provider.Anthropic, "/v1/messages/count_tokens", rewriteModel(body, provider.UpstreamNameIn(wires(), c.p.ID, model)), r.Header)
|
||||
if tooMany := (*errRPM)(nil); errors.As(err, &tooMany) {
|
||||
break // its minute filled since: nobody rests, the estimate answers
|
||||
}
|
||||
if err != nil {
|
||||
if r.Context().Err() == nil {
|
||||
s.restAfter(c, http.StatusBadGateway, nil, []byte(err.Error()))
|
||||
@@ -1886,8 +1897,19 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
// turned away below
|
||||
waited := time.Now()
|
||||
release, err := s.lanes.take(ctx, c.who(), c.p.LaneLimit(), c.p.QueueLimit, time.Duration(c.p.QueueWait)*time.Second)
|
||||
if err == nil && c.p.RPMLimit() > 0 {
|
||||
// with a MaxRPM, it then waits for room in the minute, and
|
||||
// goes counted in it; the requests it makes on the way
|
||||
// count too (rpm.go)
|
||||
if _, err = s.rpms.wait(ctx, c.who(), c.p.RPMLimit(), rpmWait(c.p)); err != nil {
|
||||
release()
|
||||
} else {
|
||||
ctx = s.paidFor(ctx, c.p, c.who())
|
||||
}
|
||||
}
|
||||
queued = time.Since(waited).Milliseconds()
|
||||
if errors.Is(err, errQueueFull) || errors.Is(err, errQueueWait) {
|
||||
var tooMany *errRPM
|
||||
if errors.Is(err, errQueueFull) || errors.Is(err, errQueueWait) || errors.As(err, &tooMany) {
|
||||
laneErr = err
|
||||
}
|
||||
if err == nil {
|
||||
@@ -1918,7 +1940,11 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
continue
|
||||
}
|
||||
if !kept.sent {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
after := "1"
|
||||
if e := (*errRPM)(nil); errors.As(laneErr, &e) {
|
||||
after = e.retryAfter() // when the minute has room
|
||||
}
|
||||
w.Header().Set("Retry-After", after)
|
||||
}
|
||||
failTo(w, kept, from, call.Status, call.Error)
|
||||
// nobody was asked, and the agent is told: the ledger gets
|
||||
@@ -2706,7 +2732,7 @@ func (s *Server) forward(ctx context.Context, p provider.Provider, to provider.P
|
||||
|
||||
// forwardOnce is one request to the provider, as forward makes it.
|
||||
func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provider.Protocol, path string, body []byte, in http.Header) (*http.Response, error) {
|
||||
ctx = p.Via(ctx)
|
||||
ctx = s.metered(p.Via(ctx), p, "")
|
||||
body = deepseekToolPatterns(p, to, body)
|
||||
body = toolOneOfAsAnyOf(p, to, body)
|
||||
body = kimiToolEnumTypes(p, to, body)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// A provider's MaxRPM (coeo91 on Discord: OpenRouter's free models take 20
|
||||
// requests a minute, which a limit at once of 1 doesn't keep to) is how
|
||||
// many requests each of its keys or accounts — a candidate's who(), as for
|
||||
// MaxConcurrency — sends the vendor in any 60 seconds. Every request that
|
||||
// goes out counts: each try of the forward loop (a retry, the next key or
|
||||
// account, a fallback), each request a try makes on the way (a second ask
|
||||
// after the vendor refused a beta or a shape), and the gateway's own asks
|
||||
// of the provider (count_tokens, System One's decisions, embeddings,
|
||||
// drawing, video and search). One more waits until the oldest of the
|
||||
// minute is a minute old: in the order they came, and never longer than
|
||||
// rpmWait (2 minutes, or the provider's QueueWait when that is shorter).
|
||||
// One that would wait longer is turned away at once with a 429 whose
|
||||
// Retry-After is when it could go; one whose agent goes away while it
|
||||
// waits gives its place back and is never sent.
|
||||
//
|
||||
// A try waits for room in the minute after it has its slot at once
|
||||
// (concurrency.go), right before it is sent, so the time the minute counts
|
||||
// is when it really goes. Like a full lane, a key or account with no room
|
||||
// in the minute gives way to another of its provider's, or of its routing
|
||||
// group, that has room now (laneMate).
|
||||
//
|
||||
// The count is kept as each request's time to go, past and to come: room
|
||||
// is there when fewer than the limit fall in the last minute, and else the
|
||||
// next goes a minute after the limit-th from the end. Times only grow, so
|
||||
// the list stays in order; one taken back (its agent gone) only leaves
|
||||
// more room. Nothing waits while holding rpms.mu.
|
||||
|
||||
// rpmWindow is the rolling window MaxRPM counts over; tests shorten it.
|
||||
var rpmWindow = time.Minute
|
||||
|
||||
// rpmLongest is the longest a request waits for room in the minute when
|
||||
// the provider's QueueWait isn't shorter; tests shorten it.
|
||||
var rpmLongest = 2 * time.Minute
|
||||
|
||||
// rpms are the times of the requests of each key or account with a MaxRPM.
|
||||
type rpms struct {
|
||||
mu sync.Mutex
|
||||
m map[string][]time.Time
|
||||
}
|
||||
|
||||
// errRPM turns a request away that would wait longer than it may for room
|
||||
// in the minute: after is how long until it could go.
|
||||
type errRPM struct {
|
||||
limit int
|
||||
after time.Duration
|
||||
wait time.Duration
|
||||
}
|
||||
|
||||
func (e *errRPM) Error() string {
|
||||
return fmt.Sprintf("%d requests a minute is its limit, and the next could go in %.0fs, longer than a request waits (%.0fs)", e.limit, math.Ceil(e.after.Seconds()), e.wait.Seconds())
|
||||
}
|
||||
|
||||
// retryAfter is e's Retry-After, in whole seconds, at least 1.
|
||||
func (e *errRPM) retryAfter() string {
|
||||
return fmt.Sprint(max(int(math.Ceil(e.after.Seconds())), 1))
|
||||
}
|
||||
|
||||
// rpmWait is how long a request to p waits for room in the minute at most.
|
||||
func rpmWait(p provider.Provider) time.Duration {
|
||||
if w := time.Duration(p.QueueWait) * time.Second; w > 0 && w < rpmLongest {
|
||||
return w
|
||||
}
|
||||
return rpmLongest
|
||||
}
|
||||
|
||||
// prune drops who's times a minute old or older, at now.
|
||||
func (l *rpms) prune(who string, now time.Time) []time.Time {
|
||||
ts := l.m[who]
|
||||
i := 0
|
||||
for i < len(ts) && !ts[i].After(now.Add(-rpmWindow)) {
|
||||
i++
|
||||
}
|
||||
ts = ts[i:]
|
||||
if len(ts) == 0 {
|
||||
delete(l.m, who)
|
||||
return nil
|
||||
}
|
||||
l.m[who] = ts
|
||||
return ts
|
||||
}
|
||||
|
||||
// reserve takes who's next time to go, at most longest from now (0: no
|
||||
// bound): now when the minute has room, else a minute after the limit-th
|
||||
// from the end.
|
||||
func (l *rpms) reserve(who string, limit int, longest time.Duration, now time.Time) (time.Time, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.m == nil {
|
||||
l.m = map[string][]time.Time{}
|
||||
}
|
||||
ts := l.prune(who, now)
|
||||
at := now
|
||||
if len(ts) >= limit {
|
||||
at = ts[len(ts)-limit].Add(rpmWindow)
|
||||
}
|
||||
if d := at.Sub(now); longest > 0 && d > longest {
|
||||
return time.Time{}, &errRPM{limit: limit, after: d, wait: longest}
|
||||
}
|
||||
l.m[who] = append(ts, at)
|
||||
return at, nil
|
||||
}
|
||||
|
||||
// giveBack takes back a time to go that wasn't used, its agent gone.
|
||||
func (l *rpms) giveBack(who string, at time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
ts := l.m[who]
|
||||
for i := len(ts) - 1; i >= 0; i-- {
|
||||
if ts[i].Equal(at) {
|
||||
l.m[who] = append(ts[:i:i], ts[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(l.m[who]) == 0 {
|
||||
delete(l.m, who)
|
||||
}
|
||||
}
|
||||
|
||||
// wait waits for room in who's minute and counts the request in it. It
|
||||
// answers how long it waited, and an *errRPM, with nothing counted, when
|
||||
// the wait would be longer than longest, or ctx's error when ctx ended
|
||||
// first, its time given back. A limit of 0 counts nothing.
|
||||
func (l *rpms) wait(ctx context.Context, who string, limit int, longest time.Duration) (time.Duration, error) {
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
at, err := l.reserve(who, limit, longest, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d := at.Sub(now)
|
||||
if d <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return d, nil
|
||||
case <-ctx.Done():
|
||||
l.giveBack(who, at)
|
||||
return time.Since(now), ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// free says whether a request for who would go at once: no limit, or room
|
||||
// in its minute.
|
||||
func (l *rpms) free(who string, limit int) bool {
|
||||
if limit <= 0 {
|
||||
return true
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.m == nil {
|
||||
return true
|
||||
}
|
||||
return len(l.prune(who, time.Now())) < limit
|
||||
}
|
||||
|
||||
// used is how many requests of who's are counted in its minute now, to go
|
||||
// or gone.
|
||||
func (l *rpms) used(who string) int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.m == nil {
|
||||
return 0
|
||||
}
|
||||
return len(l.prune(who, time.Now()))
|
||||
}
|
||||
|
||||
// rpmTicket, on a request's context, counts each request made with it
|
||||
// against who's MaxRPM as it goes out (rpmTransport), waiting at most
|
||||
// longest (0: no bound). paid is a place the forward loop took already,
|
||||
// which the first request uses; try marks the forward loop's ticket for
|
||||
// provider id's try.
|
||||
type rpmTicket struct {
|
||||
l *rpms
|
||||
id string
|
||||
try bool
|
||||
who string
|
||||
limit int
|
||||
longest time.Duration
|
||||
paid atomic.Bool
|
||||
}
|
||||
|
||||
type rpmKey struct{}
|
||||
|
||||
// meterWho is p's key or account as its limits count it: candidate.who for a
|
||||
// provider not planned as a candidate.
|
||||
func meterWho(p provider.Provider) string {
|
||||
switch {
|
||||
case p.Account != nil && p.Account.User != "":
|
||||
return candidate{p: p}.restKey()
|
||||
case p.Account == nil && p.Key != "":
|
||||
return candidate{p: p}.who()
|
||||
}
|
||||
return p.ID
|
||||
}
|
||||
|
||||
// metered is ctx with requests made with it counted against p's MaxRPM
|
||||
// for who, "" for p's own key or account. One counted for who already is
|
||||
// kept, and so is the forward loop's for a try of p: a try's own requests
|
||||
// are counted against the key or account the try is of.
|
||||
func (s *Server) metered(ctx context.Context, p provider.Provider, who string) context.Context {
|
||||
limit := p.RPMLimit()
|
||||
if limit <= 0 {
|
||||
return ctx
|
||||
}
|
||||
if who == "" {
|
||||
who = meterWho(p)
|
||||
}
|
||||
if t, ok := ctx.Value(rpmKey{}).(*rpmTicket); ok && (t.who == who || t.try && t.id == p.ID) {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, rpmKey{}, &rpmTicket{l: &s.rpms, who: who, limit: limit, longest: rpmWait(p)})
|
||||
}
|
||||
|
||||
// paidFor is ctx whose first request to who is counted already: the
|
||||
// forward loop waited for it. The ones the try makes after it (a second
|
||||
// ask after the vendor refused a beta or a shape) wait as long as it
|
||||
// takes: the try is under way, and turned away it would rest its key or
|
||||
// account for nothing. That is never long, the minute's places to come
|
||||
// being bounded by rpmWait.
|
||||
func (s *Server) paidFor(ctx context.Context, p provider.Provider, who string) context.Context {
|
||||
t := &rpmTicket{l: &s.rpms, id: p.ID, try: true, who: who, limit: p.RPMLimit()}
|
||||
t.paid.Store(true)
|
||||
return context.WithValue(ctx, rpmKey{}, t)
|
||||
}
|
||||
|
||||
// rpmTransport counts each request whose context has a ticket against its
|
||||
// MaxRPM before it goes out, waiting for room in the minute.
|
||||
type rpmTransport struct{ next http.RoundTripper }
|
||||
|
||||
func (t rpmTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if tk, ok := r.Context().Value(rpmKey{}).(*rpmTicket); ok && !tk.paid.CompareAndSwap(true, false) {
|
||||
if _, err := tk.l.wait(r.Context(), tk.who, tk.limit, tk.longest); err != nil {
|
||||
if r.Body != nil {
|
||||
r.Body.Close()
|
||||
}
|
||||
var e *errRPM
|
||||
if errors.As(err, &e) {
|
||||
return nil, fmt.Errorf("%s: %w", tk.who, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return t.next.RoundTrip(r)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// minuteVendor answers chat completions, Messages and count_tokens, noting
|
||||
// when each request came; fail is how many chat completions it answers
|
||||
// 503 first.
|
||||
type minuteVendor struct {
|
||||
mu sync.Mutex
|
||||
at []time.Time
|
||||
path []string
|
||||
fail int
|
||||
}
|
||||
|
||||
func (v *minuteVendor) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
v.mu.Lock()
|
||||
v.at = append(v.at, time.Now())
|
||||
v.path = append(v.path, r.URL.Path)
|
||||
failing := v.fail > 0 && strings.HasSuffix(r.URL.Path, "/chat/completions")
|
||||
if failing {
|
||||
v.fail--
|
||||
}
|
||||
v.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case failing:
|
||||
w.WriteHeader(503)
|
||||
io.WriteString(w, `{"error":{"message":"busy a moment"}}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/count_tokens"):
|
||||
io.WriteString(w, `{"input_tokens":7}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/messages"):
|
||||
io.WriteString(w, `{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":1}}`)
|
||||
default:
|
||||
io.WriteString(w, `{"id":"x","object":"chat.completion","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *minuteVendor) times() []time.Time {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return append([]time.Time(nil), v.at...)
|
||||
}
|
||||
|
||||
// shortMinute makes MaxRPM's window and longest wait w and longest for the
|
||||
// test.
|
||||
func shortMinute(t *testing.T, w, longest time.Duration) {
|
||||
t.Helper()
|
||||
ow, ol := rpmWindow, rpmLongest
|
||||
rpmWindow, rpmLongest = w, longest
|
||||
t.Cleanup(func() { rpmWindow, rpmLongest = ow, ol })
|
||||
}
|
||||
|
||||
// rpmGateway is a gateway as the real server serves it (lanGuard in
|
||||
// front), with a provider "rl" of one key whose MaxRPM is limit.
|
||||
func rpmGateway(t *testing.T, v *minuteVendor, limit int) (*Server, string) {
|
||||
t.Helper()
|
||||
fresh(t)
|
||||
up := httptest.NewServer(v)
|
||||
t.Cleanup(up.Close)
|
||||
if err := provider.Save(provider.Provider{ID: "rl", Name: "Limited", Key: "k", Models: []string{"m"}, Chat: up.URL + "/v1", Anthropic: up.URL, MaxRPM: limit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New()
|
||||
gw := httptest.NewServer(lanGuard(s.Handler()))
|
||||
t.Cleanup(gw.Close)
|
||||
return s, gw.URL
|
||||
}
|
||||
|
||||
func chatOnce(ctx context.Context, gw string) (*http.Response, string, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", gw+"/v1/chat/completions", strings.NewReader(`{"model":"rl/m","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return res, string(b), nil
|
||||
}
|
||||
|
||||
// coeo91 on Discord: OpenRouter's free models take 20 requests a minute,
|
||||
// which a limit at once of 1 didn't keep to. With a MaxRPM of 2, through
|
||||
// the real server, two requests go at once and the third waits until the
|
||||
// first is a minute old, then is answered.
|
||||
func TestMaxRPMWaitsForRoomInTheMinute(t *testing.T) {
|
||||
window := 1500 * time.Millisecond
|
||||
shortMinute(t, window, time.Minute)
|
||||
v := &minuteVendor{}
|
||||
_, gw := rpmGateway(t, v, 2)
|
||||
start := time.Now()
|
||||
for i := range 3 {
|
||||
res, body, err := chatOnce(context.Background(), gw)
|
||||
if err != nil || res.StatusCode != 200 || !strings.Contains(body, `"ok"`) {
|
||||
t.Fatalf("request %d: %v %v %s", i+1, err, res, body)
|
||||
}
|
||||
}
|
||||
at := v.times()
|
||||
if len(at) != 3 {
|
||||
t.Fatalf("the vendor saw %d requests, want 3", len(at))
|
||||
}
|
||||
if d := at[1].Sub(start); d > window/2 {
|
||||
t.Fatalf("the second waited %s with room in the minute", d)
|
||||
}
|
||||
if d := at[2].Sub(at[0]); d < window-50*time.Millisecond {
|
||||
t.Fatalf("the third went %s after the first, inside the %s window", d, window)
|
||||
}
|
||||
}
|
||||
|
||||
// One that would wait longer than a request waits is turned away at once
|
||||
// with a 429 whose Retry-After says when the minute has room, and never
|
||||
// reaches the vendor; nobody rests for it, and once the minute has room
|
||||
// the next is answered.
|
||||
func TestMaxRPMTooLongIs429WithRetryAfter(t *testing.T) {
|
||||
window := 1500 * time.Millisecond
|
||||
shortMinute(t, window, 200*time.Millisecond)
|
||||
v := &minuteVendor{}
|
||||
_, gw := rpmGateway(t, v, 1)
|
||||
if res, body, err := chatOnce(context.Background(), gw); err != nil || res.StatusCode != 200 {
|
||||
t.Fatalf("first: %v %v %s", err, res, body)
|
||||
}
|
||||
began := time.Now()
|
||||
res, body, err := chatOnce(context.Background(), gw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != 429 || res.Header.Get("Retry-After") != "2" || !strings.Contains(body, "1 requests a minute is its limit") {
|
||||
t.Fatalf("second = %d Retry-After %q %s, want a 429 saying the limit, Retry-After 2", res.StatusCode, res.Header.Get("Retry-After"), body)
|
||||
}
|
||||
if d := time.Since(began); d > window/2 {
|
||||
t.Fatalf("turned away after %s, not at once", d)
|
||||
}
|
||||
if n := len(v.times()); n != 1 {
|
||||
t.Fatalf("the vendor saw %d, want 1", n)
|
||||
}
|
||||
if r := restingNow(t); len(r) != 0 {
|
||||
t.Fatalf("%v rest for a limit of the user's", r)
|
||||
}
|
||||
time.Sleep(window)
|
||||
if res, body, err := chatOnce(context.Background(), gw); err != nil || res.StatusCode != 200 {
|
||||
t.Fatalf("after the minute: %v %v %s", err, res, body)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent that goes away while its request waits for room ends the wait:
|
||||
// nothing is sent, and its place in the minute is given back.
|
||||
func TestMaxRPMAgentGoneWhileWaiting(t *testing.T) {
|
||||
shortMinute(t, 3*time.Second, time.Minute)
|
||||
v := &minuteVendor{}
|
||||
s, gw := rpmGateway(t, v, 1)
|
||||
if res, _, err := chatOnce(context.Background(), gw); err != nil || res.StatusCode != 200 {
|
||||
t.Fatalf("first: %v %v", err, res)
|
||||
}
|
||||
who := meterWho(provider.Provider{ID: "rl", Key: "k"})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := chatOnce(ctx, gw)
|
||||
done <- err
|
||||
}()
|
||||
within(t, "the second waiting", func() bool { return s.rpms.used(who) == 2 })
|
||||
cancel()
|
||||
if err := <-done; err == nil {
|
||||
t.Fatal("the canceled request answered")
|
||||
}
|
||||
within(t, "its place given back", func() bool { return s.rpms.used(who) == 1 })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if n := len(v.times()); n != 1 {
|
||||
t.Fatalf("the vendor saw %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// What magpie sends on its own counts too: a retry after the vendor's 503
|
||||
// is a request of the minute, and so is a count_tokens that reaches the
|
||||
// vendor. With 3 a minute, one request tried twice and one count leave no
|
||||
// room, so the next chat waits for the minute.
|
||||
func TestMaxRPMCountsRetriesAndCounts(t *testing.T) {
|
||||
window := 1500 * time.Millisecond
|
||||
shortMinute(t, window, time.Minute)
|
||||
v := &minuteVendor{fail: 1}
|
||||
_, gw := rpmGateway(t, v, 3)
|
||||
if res, body, err := chatOnce(context.Background(), gw); err != nil || res.StatusCode != 200 {
|
||||
t.Fatalf("first: %v %v %s", err, res, body)
|
||||
}
|
||||
if n := len(v.times()); n != 2 {
|
||||
t.Fatalf("the vendor saw %d, want the 503 and its retry", n)
|
||||
}
|
||||
req, _ := http.NewRequest("POST", gw+"/v1/messages/count_tokens", strings.NewReader(`{"model":"rl/m","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
}
|
||||
json.NewDecoder(res.Body).Decode(&count)
|
||||
res.Body.Close()
|
||||
if count.InputTokens != 7 {
|
||||
t.Fatalf("count = %d, want the vendor's 7", count.InputTokens)
|
||||
}
|
||||
// with no room in the minute, a count doesn't wait: the estimate
|
||||
// answers, and the vendor isn't asked
|
||||
req, _ = http.NewRequest("POST", gw+"/v1/messages/count_tokens", strings.NewReader(`{"model":"rl/m","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
began := time.Now()
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 200 || time.Since(began) > window/2 {
|
||||
t.Fatalf("a count with no room: %d after %s", res.StatusCode, time.Since(began))
|
||||
}
|
||||
if n := len(v.times()); n != 3 {
|
||||
t.Fatalf("the vendor saw %d, want the count estimated", n)
|
||||
}
|
||||
if res, body, err := chatOnce(context.Background(), gw); err != nil || res.StatusCode != 200 {
|
||||
t.Fatalf("last: %v %v %s", err, res, body)
|
||||
}
|
||||
at := v.times()
|
||||
if len(at) != 4 {
|
||||
t.Fatalf("the vendor saw %d (%v), want 4", len(at), v.path)
|
||||
}
|
||||
if d := at[3].Sub(at[0]); d < window-50*time.Millisecond {
|
||||
t.Fatalf("the last went %s after the first, inside the %s window: the retry or the count wasn't counted", d, window)
|
||||
}
|
||||
}
|
||||
|
||||
// A group whose member has sent its MaxRPM in the last minute asks another
|
||||
// member with room first, as it does one whose slots are all out, rather
|
||||
// than wait.
|
||||
func TestMaxRPMGroupPrefersMemberWithRoom(t *testing.T) {
|
||||
shortMinute(t, 5*time.Second, time.Minute)
|
||||
fresh(t)
|
||||
a, b := &minuteVendor{}, &minuteVendor{}
|
||||
ua, ub := httptest.NewServer(a), httptest.NewServer(b)
|
||||
t.Cleanup(ua.Close)
|
||||
t.Cleanup(ub.Close)
|
||||
if err := provider.Save(provider.Provider{ID: "pa", Name: "A", Key: "ka", Models: []string{"m"}, Chat: ua.URL + "/v1", MaxRPM: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := provider.Save(provider.Provider{ID: "pb", Name: "B", Key: "kb", Models: []string{"m"}, Chat: ub.URL + "/v1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := provider.SaveGroup(provider.Group{Name: "Duo", Routing: provider.Ordered, Members: []string{"pa/m", "pb/m"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New()
|
||||
gw := httptest.NewServer(lanGuard(s.Handler()))
|
||||
t.Cleanup(gw.Close)
|
||||
ask := func() {
|
||||
req, _ := http.NewRequest("POST", gw.URL+"/v1/chat/completions", strings.NewReader(`{"model":"group/duo","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("%d %s", res.StatusCode, b)
|
||||
}
|
||||
}
|
||||
ask()
|
||||
if len(a.times()) != 1 || len(b.times()) != 0 {
|
||||
t.Fatalf("first: A saw %d, B %d; want A, first in order", len(a.times()), len(b.times()))
|
||||
}
|
||||
began := time.Now()
|
||||
ask()
|
||||
if len(a.times()) != 1 || len(b.times()) != 1 {
|
||||
t.Fatalf("second: A saw %d, B %d; want B, A having no room", len(a.times()), len(b.times()))
|
||||
}
|
||||
if d := time.Since(began); d > time.Second {
|
||||
t.Fatalf("the second waited %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The wait for room never holds the lock a request giving its place back
|
||||
// needs: many waiting and many giving up at once all end.
|
||||
func TestRPMWaitsAndGiveBacksTogether(t *testing.T) {
|
||||
shortMinute(t, 200*time.Millisecond, time.Minute)
|
||||
var l rpms
|
||||
var wg sync.WaitGroup
|
||||
for i := range 40 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(i%4)*100*time.Millisecond+50*time.Millisecond)
|
||||
defer cancel()
|
||||
l.wait(ctx, "x", 3, 0)
|
||||
}()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("waits didn't end")
|
||||
}
|
||||
}
|
||||
|
||||
// The minute's arithmetic: room while fewer than the limit fall in the
|
||||
// last minute, then a minute after the limit-th from the end, in order;
|
||||
// one given back leaves room, and one past the longest wait is refused
|
||||
// with when it could go.
|
||||
func TestRPMReserve(t *testing.T) {
|
||||
shortMinute(t, time.Minute, 2*time.Minute)
|
||||
var l rpms
|
||||
t0 := time.Now()
|
||||
at := func(d time.Duration, longest time.Duration) (time.Duration, error) {
|
||||
got, err := l.reserve("x", 2, longest, t0.Add(d))
|
||||
return got.Sub(t0), err
|
||||
}
|
||||
for _, c := range []struct{ now, want time.Duration }{
|
||||
{0, 0}, {10 * time.Second, 10 * time.Second}, // room
|
||||
{20 * time.Second, 60 * time.Second}, // a minute after the first
|
||||
{20 * time.Second, 70 * time.Second}, // after the second
|
||||
{30 * time.Second, 120 * time.Second}, // after the third, to come
|
||||
} {
|
||||
if got, err := at(c.now, 2*time.Minute); err != nil || got != c.want {
|
||||
t.Fatalf("at %s: %s %v, want %s", c.now, got, err, c.want)
|
||||
}
|
||||
}
|
||||
_, err := at(30*time.Second, 2*time.Minute) // would go at 130s: 100s away
|
||||
if err != nil {
|
||||
t.Fatalf("100s away refused: %v", err)
|
||||
}
|
||||
_, err = at(30*time.Second, 90*time.Second) // at 180s: 150s away
|
||||
e, ok := err.(*errRPM)
|
||||
if !ok || e.retryAfter() != "150" {
|
||||
t.Fatalf("past the longest: %v, want an errRPM in 150s", err)
|
||||
}
|
||||
l.giveBack("x", t0.Add(130*time.Second))
|
||||
l.giveBack("x", t0.Add(120*time.Second))
|
||||
if got, err := at(31*time.Second, 2*time.Minute); err != nil || got != 120*time.Second {
|
||||
t.Fatalf("after two given back: %s %v, want 120s", got, err)
|
||||
}
|
||||
if l.free("x", 0) != true {
|
||||
t.Fatal("no limit isn't free")
|
||||
}
|
||||
}
|
||||
|
||||
// A try's own requests are counted once, against the key or account the
|
||||
// try is of, however the provider names its account; a request to another
|
||||
// provider made on the way is counted against that one's.
|
||||
func TestRPMTicketOfATry(t *testing.T) {
|
||||
s := &Server{}
|
||||
p := provider.Provider{ID: "codex", MaxRPM: 3, Account: &provider.Account{}}
|
||||
ctx := s.paidFor(context.Background(), p, "codex@someone")
|
||||
if got := s.metered(ctx, p, ""); got != ctx {
|
||||
t.Fatal("a try's own request got a ticket of its own: it would be counted twice")
|
||||
}
|
||||
other := provider.Provider{ID: "kimi", Key: "k", MaxRPM: 3}
|
||||
got := s.metered(ctx, other, "")
|
||||
tk, _ := got.Value(rpmKey{}).(*rpmTicket)
|
||||
if tk == nil || tk.who != meterWho(other) || tk.paid.Load() {
|
||||
t.Fatalf("another provider's request on the way: %+v", tk)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func (s *Server) searchBy(ctx context.Context, p provider.Provider, model, query
|
||||
// kimiSearch asks a Kimi Code plan's search service.
|
||||
func (s *Server) kimiSearch(ctx context.Context, p provider.Provider, query string) (string, []Hit, error) {
|
||||
// the service gives itself 30 s, as kimi-cli asks
|
||||
ctx, cancel := context.WithTimeout(ctx, searchTimeout/2)
|
||||
ctx, cancel := context.WithTimeout(s.metered(ctx, p, ""), searchTimeout/2)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(map[string]any{"text_query": query, "limit": searchHits, "enable_page_crawling": false, "timeout_seconds": 30})
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.KimiCodeSearch(p), bytes.NewReader(body))
|
||||
|
||||
@@ -6894,7 +6894,12 @@ function concurrencyField(p) {
|
||||
queue.classList.add("queue-limit");
|
||||
const wait = input(draft.queueWait ?? "", t("As long as it takes"), "number");
|
||||
wait.classList.add("queue-wait");
|
||||
for (const [b, max, k] of [[queue, 10000, "queueLimit"], [wait, 3600, "queueWait"]]) {
|
||||
// how many requests each key or account sends the vendor in any minute
|
||||
// (coeo91 on Discord: OpenRouter's free models take 20); one more waits
|
||||
// for room in the minute
|
||||
const rpm = input(draft.maxRPM ?? "", t("No limit"), "number");
|
||||
rpm.classList.add("rpm");
|
||||
for (const [b, max, k] of [[queue, 10000, "queueLimit"], [wait, 3600, "queueWait"], [rpm, 10000, "maxRPM"]]) {
|
||||
b.min = "0";
|
||||
b.max = String(max);
|
||||
b.step = "1";
|
||||
@@ -6903,6 +6908,7 @@ function concurrencyField(p) {
|
||||
}
|
||||
return [
|
||||
...field(t("Concurrency"), box, (plugin ? t("Over it, requests queue and go out in order; empty takes the plugin's {n}, 0 is no limit", { n: plugin }) : t("Over it, requests queue and go out in order; 0 or empty is no limit"))),
|
||||
...field(t("Requests per minute"), rpm, t("How many requests each key or account sends in any minute, retries included; one more waits for room, up to 2 minutes. 0 or empty is no limit")),
|
||||
...field(t("Queue size"), queue, t("How many requests may wait for each key or account; one more is turned away at once. 0 or empty is no bound")),
|
||||
...field(t("Queue wait"), wait, t("Seconds a request waits for a free slot before it is turned away. 0 or empty waits as long as it takes")),
|
||||
];
|
||||
@@ -6933,13 +6939,15 @@ function priceRateError(ed) {
|
||||
}
|
||||
function concurrencyDraft(p) {
|
||||
return { concurrency: p?.maxConcurrency == null ? "" : String(p.maxConcurrency), priceRate: p?.priceRate ? String(p.priceRate) : "",
|
||||
queueLimit: p?.queueLimit ? String(p.queueLimit) : "", queueWait: p?.queueWait ? String(p.queueWait) : "" };
|
||||
queueLimit: p?.queueLimit ? String(p.queueLimit) : "", queueWait: p?.queueWait ? String(p.queueWait) : "",
|
||||
maxRPM: p?.maxRPM ? String(p.maxRPM) : "" };
|
||||
}
|
||||
// queueOfDraft is the draft's queue as it is saved, { queueLimit,
|
||||
// queueWait } with 0 for none, or { bad } naming the field typed wrong.
|
||||
// queueOfDraft is the draft's queue and limit a minute as they are saved,
|
||||
// { queueLimit, queueWait, maxRPM } with 0 for none, or { bad } naming the
|
||||
// field typed wrong.
|
||||
function queueOfDraft() {
|
||||
const out = {};
|
||||
for (const [k, max] of [["queueLimit", 10000], ["queueWait", 3600]]) {
|
||||
for (const [k, max] of [["queueLimit", 10000], ["queueWait", 3600], ["maxRPM", 10000]]) {
|
||||
const v = String(draft[k] ?? "").trim();
|
||||
if (!v) { out[k] = 0; continue; }
|
||||
if (!/^\d+$/.test(v) || +v > max) return { bad: k };
|
||||
@@ -6948,6 +6956,10 @@ function queueOfDraft() {
|
||||
return out;
|
||||
}
|
||||
function queueError(ed, bad) {
|
||||
if (bad === "maxRPM") {
|
||||
ed.querySelector("input.rpm")?.focus({ preventScroll: true });
|
||||
return editorError(t("Requests per minute: a whole number from 0 to 10000"), "warn");
|
||||
}
|
||||
ed.querySelector(bad === "queueLimit" ? "input.queue-limit" : "input.queue-wait")?.focus({ preventScroll: true });
|
||||
return editorError(bad === "queueLimit" ? t("Queue size: a whole number from 0 to 10000") : t("Queue wait: a whole number of seconds from 0 to 3600"), "warn");
|
||||
}
|
||||
|
||||
@@ -1554,6 +1554,9 @@ const I18N = {
|
||||
"Seconds a request waits for a free slot before it is turned away. 0 or empty waits as long as it takes": "请求等待空位的最长秒数,超时即被拒绝。0 或留空为一直等",
|
||||
"Queue size: a whole number from 0 to 10000": "排队上限:0 到 10000 的整数",
|
||||
"Queue wait: a whole number of seconds from 0 to 3600": "排队等待:0 到 3600 的整数秒",
|
||||
"Requests per minute": "每分钟请求数",
|
||||
"How many requests each key or account sends in any minute, retries included; one more waits for room, up to 2 minutes. 0 or empty is no limit": "每个密钥或账号在任意一分钟内最多发出的请求数,重试也计入;超出的请求等待空位,最多等 2 分钟。0 或留空为不限制",
|
||||
"Requests per minute: a whole number from 0 to 10000": "每分钟请求数:0 到 10000 的整数",
|
||||
"{n}/{max} running · {q} queued": "{n}/{max} 运行中 · {q} 排队",
|
||||
"{n}/{max} running": "{n}/{max} 运行中",
|
||||
"{n} at once": "并发 {n}",
|
||||
@@ -5308,6 +5311,9 @@ const I18N = {
|
||||
"Seconds a request waits for a free slot before it is turned away. 0 or empty waits as long as it takes": "請求等待空位的最長秒數,超時即被拒絕。0 或留空為一直等",
|
||||
"Queue size: a whole number from 0 to 10000": "排隊上限:0 到 10000 的整數",
|
||||
"Queue wait: a whole number of seconds from 0 to 3600": "排隊等待:0 到 3600 的整數秒",
|
||||
"Requests per minute": "每分鐘請求數",
|
||||
"How many requests each key or account sends in any minute, retries included; one more waits for room, up to 2 minutes. 0 or empty is no limit": "每個金鑰或帳號在任意一分鐘內最多發出的請求數,重試也計入;超出的請求等待空位,最多等 2 分鐘。0 或留空為不限制",
|
||||
"Requests per minute: a whole number from 0 to 10000": "每分鐘請求數:0 到 10000 的整數",
|
||||
"{n}/{max} running · {q} queued": "{n}/{max} 執行中 · {q} 排隊",
|
||||
"{n}/{max} running": "{n}/{max} 執行中",
|
||||
"{n} at once": "並行 {n}",
|
||||
@@ -9040,6 +9046,9 @@ const I18N = {
|
||||
"Seconds a request waits for a free slot before it is turned away. 0 or empty waits as long as it takes": "空きを待つ最大秒数。過ぎると断られます。0 または空欄でいつまでも待ちます",
|
||||
"Queue size: a whole number from 0 to 10000": "キューの長さ:0〜10000 の整数",
|
||||
"Queue wait: a whole number of seconds from 0 to 3600": "キューの待ち時間:0〜3600 の整数(秒)",
|
||||
"Requests per minute": "1 分間のリクエスト数",
|
||||
"How many requests each key or account sends in any minute, retries included; one more waits for room, up to 2 minutes. 0 or empty is no limit": "各キーまたはアカウントが任意の 1 分間に送るリクエストの上限(リトライも含む)。超えたものは空きを最大 2 分待ちます。0 または空欄で上限なし",
|
||||
"Requests per minute: a whole number from 0 to 10000": "1 分間のリクエスト数:0〜10000 の整数",
|
||||
"{n}/{max} running · {q} queued": "{n}/{max} 実行中 · {q} 待ち",
|
||||
"{n}/{max} running": "{n}/{max} 実行中",
|
||||
"{n} at once": "同時 {n}",
|
||||
@@ -12791,6 +12800,9 @@ const I18N = {
|
||||
"Seconds a request waits for a free slot before it is turned away. 0 or empty waits as long as it takes": "Sekunden, die eine Anfrage auf einen freien Platz wartet, bevor sie abgewiesen wird. 0 oder leer: beliebig lange",
|
||||
"Queue size: a whole number from 0 to 10000": "Warteschlange: eine ganze Zahl von 0 bis 10000",
|
||||
"Queue wait: a whole number of seconds from 0 to 3600": "Wartezeit: eine ganze Zahl Sekunden von 0 bis 3600",
|
||||
"Requests per minute": "Anfragen pro Minute",
|
||||
"How many requests each key or account sends in any minute, retries included; one more waits for room, up to 2 minutes. 0 or empty is no limit": "Wie viele Anfragen jeder Schlüssel oder jedes Konto in einer beliebigen Minute sendet, Wiederholungen eingeschlossen; eine weitere wartet bis zu 2 Minuten auf Platz. 0 oder leer heißt kein Limit",
|
||||
"Requests per minute: a whole number from 0 to 10000": "Anfragen pro Minute: eine ganze Zahl von 0 bis 10000",
|
||||
"{n}/{max} running · {q} queued": "{n}/{max} laufen · {q} warten",
|
||||
"{n}/{max} running": "{n}/{max} laufen",
|
||||
"{n} at once": "{n} gleichzeitig",
|
||||
|
||||
@@ -116,3 +116,67 @@ func TestLaneLimitRoutes(t *testing.T) {
|
||||
t.Fatalf("lanes %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The editor's save sets a provider's requests a minute (coeo91 on
|
||||
// Discord), shows it in the providers' state, keeps it when a save leaves
|
||||
// it out, takes 0 or null for no limit, and refuses one out of range.
|
||||
func TestRPMSave(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
if err := provider.Save(provider.Provider{ID: "or", Name: "OpenRouter", Chat: "https://example.invalid/v1", Key: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
providerRoutes(mux, nil)
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/provider/save", strings.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
rpm := func() int {
|
||||
got, err := provider.Find("or")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got.MaxRPM
|
||||
}
|
||||
const base = `{"id":"or","name":"OpenRouter","chat":"https://example.invalid/v1"`
|
||||
if w := post(base + `,"maxRPM":20}`); w.Code != 200 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body)
|
||||
}
|
||||
if n := rpm(); n != 20 {
|
||||
t.Fatalf("saved %d", n)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/providers", nil))
|
||||
var state providersJSON
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shown := -1
|
||||
for _, q := range state.Providers {
|
||||
if q.ID == "or" {
|
||||
shown = q.MaxRPM
|
||||
}
|
||||
}
|
||||
if shown != 20 {
|
||||
t.Fatalf("shown %d", shown)
|
||||
}
|
||||
if w := post(base + `}`); w.Code != 200 || rpm() != 20 {
|
||||
t.Fatalf("an older form: %d %s, limit %d", w.Code, w.Body, rpm())
|
||||
}
|
||||
for _, bad := range []string{`"maxRPM":-1`, `"maxRPM":10001`, `"maxRPM":"x"`, `"maxRPM":2.5`} {
|
||||
if w := post(base + `,` + bad + `}`); w.Code == 200 {
|
||||
t.Fatalf("%s taken", bad)
|
||||
}
|
||||
}
|
||||
if rpm() != 20 {
|
||||
t.Fatalf("a refused save changed it: %d", rpm())
|
||||
}
|
||||
if w := post(base + `,"maxRPM":null}`); w.Code != 200 || rpm() != 0 {
|
||||
t.Fatalf("null: %d %s, limit %d", w.Code, w.Body, rpm())
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,9 @@ type providerJSON struct {
|
||||
// (0: no bound)
|
||||
QueueLimit int `json:"queueLimit,omitempty"`
|
||||
QueueWait int `json:"queueWait,omitempty"`
|
||||
// how many requests each key or account sends the vendor in any
|
||||
// minute, 0 for no limit (coeo91 on Discord)
|
||||
MaxRPM int `json:"maxRPM,omitempty"`
|
||||
// what it charges against the official price, 0 for that (#819)
|
||||
PriceRate float64 `json:"priceRate,omitempty"`
|
||||
Models []modelJSON `json:"models"` // everything the vendor lists, exposed ones flagged
|
||||
@@ -411,7 +414,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
|
||||
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
|
||||
Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
|
||||
MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(), PriceRate: p.PriceRate,
|
||||
AccountConcurrency: p.AccountConcurrency, QueueLimit: p.QueueLimit, QueueWait: p.QueueWait,
|
||||
AccountConcurrency: p.AccountConcurrency, QueueLimit: p.QueueLimit, QueueWait: p.QueueWait, MaxRPM: p.MaxRPM,
|
||||
Outputs: provider.OutputsOf(p.ID), Compacts: provider.CompactsOf(p.ID),
|
||||
}
|
||||
if out.Fallback == nil {
|
||||
@@ -826,6 +829,10 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
// null for no bound; a save that leaves them out keeps them
|
||||
QueueLimit json.RawMessage `json:"queueLimit"`
|
||||
QueueWait json.RawMessage `json:"queueWait"`
|
||||
// MaxRPM is how many requests each key or account sends the
|
||||
// vendor in any minute: a number, 0 or null for no limit; a
|
||||
// save that leaves it out keeps it
|
||||
MaxRPM json.RawMessage `json:"maxRPM"`
|
||||
// Limit, for accountconcurrency: the account's or key's own
|
||||
// limit, 0 for none, null for the provider's (#892)
|
||||
Limit *int `json:"limit"`
|
||||
@@ -1010,6 +1017,16 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
return
|
||||
}
|
||||
in.QueueLimit, in.QueueWait = ql, qw
|
||||
rpm, keepRPM, err := queueOf(req.MaxRPM, "requests a minute")
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
if err := provider.CheckRPM(rpm); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
in.MaxRPM = rpm
|
||||
rate, keepRate, err := priceRateOf(req.PriceRate)
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
@@ -1072,6 +1089,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
if keepQW && old != nil {
|
||||
in.QueueWait = old.QueueWait
|
||||
}
|
||||
if keepRPM && old != nil {
|
||||
in.MaxRPM = old.MaxRPM
|
||||
}
|
||||
if keepRate && old != nil {
|
||||
in.PriceRate = old.PriceRate
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A provider's Requests per minute (coeo91 on Discord: OpenRouter's free
|
||||
// models take 20 a minute, which Concurrency can't keep to): its editor
|
||||
// has the field under Concurrency, empty for no limit; Save posts the
|
||||
// number typed, 0 when it is empty, and refuses one that isn't a whole
|
||||
// number from 0 to 10000 before anything is posted. A signed-in account
|
||||
// (Codex) opens on what it has and saves it with its picks. At the
|
||||
// narrowest window the label stays on one line and the field keeps its
|
||||
// placeholder readable. In English, Chinese, Japanese and German.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const openrouter = {
|
||||
id: "openrouter", name: "OpenRouter", icon: "generic", chat: "https://openrouter.ai/api/v1", responses: "", anthropic: "", catalog: "",
|
||||
models: [{ id: "x/free", name: "Free", on: true }], agents: [], fallback: [], headers: {},
|
||||
key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", maxConcurrency: null,
|
||||
};
|
||||
const codex = {
|
||||
id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "",
|
||||
models: [{ id: "gpt-6", name: "GPT-6", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
|
||||
account: { agent: "codex", agentName: "Codex", user: "me@example.com", plan: "PLUS", logins: [{ user: "me@example.com", plan: "PLUS", active: true, on: true }] },
|
||||
proxy: "", maxConcurrency: null, maxRPM: 20,
|
||||
};
|
||||
|
||||
function serve(lang, posts) {
|
||||
const providers = { providers: [openrouter, codex], presets: [], excluded: [], gateway: { running: true, window: true } };
|
||||
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/providers") return json(providers);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname.startsWith("/api/provider/") && route.request().method() === "POST") {
|
||||
posts.push({ action: url.pathname.slice("/api/provider/".length), body: route.request().postDataJSON() });
|
||||
return json(providers);
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { label: "Requests per minute", save: "Save", none: "No limit", bad: "Requests per minute: a whole number from 0 to 10000" },
|
||||
zh: { label: "每分钟请求数", save: "保存", none: "不限制", bad: "每分钟请求数:0 到 10000 的整数" },
|
||||
ja: { label: "1 分間のリクエスト数", bad: "1 分間のリクエスト数:0〜10000 の整数" },
|
||||
de: { label: "Anfragen pro Minute", bad: "Anfragen pro Minute: eine ganze Zahl von 0 bis 10000" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh", "ja", "de"]) {
|
||||
const w = words[lang];
|
||||
const open = async (t, name, width = 900) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${name}-${width}-rpm.png`) });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
const page = await (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const posts = [];
|
||||
await page.route("**/*", serve(lang, posts));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator(".row.provider", { hasText: name }).click();
|
||||
await page.locator(".editor input.rpm").waitFor();
|
||||
await page.waitForTimeout(300); // the editor drawn again as what it asked for comes in
|
||||
return { page, errors, posts };
|
||||
};
|
||||
const box = (page) => page.locator(".editor input.rpm");
|
||||
const saveName = async (page) => page.evaluate(() => t("Save"));
|
||||
const save = async (page, posts) => {
|
||||
const n = posts.length;
|
||||
await page.locator(".editor .bar").getByRole("button", { name: await saveName(page), exact: true }).click();
|
||||
for (let i = 0; i < 50 && posts.length === n; i++) await page.waitForTimeout(50);
|
||||
return posts.at(-1);
|
||||
};
|
||||
|
||||
test(`${engine} ${lang}: the field saves a whole number, 0 when empty, and refuses others`, async (t) => {
|
||||
const { page, errors, posts } = await open(t, "OpenRouter");
|
||||
const label = await box(page).evaluate((e) => e.closest(".editor").querySelector(`label[for="${e.id}"]`)?.textContent || e.parentElement.previousElementSibling?.textContent || "");
|
||||
assert.equal(label.trim(), w.label);
|
||||
assert.equal(await box(page).inputValue(), "");
|
||||
assert.equal(await box(page).getAttribute("placeholder"), await page.evaluate(() => t("No limit")));
|
||||
// under Concurrency
|
||||
const order = await page.evaluate(() => {
|
||||
const c = document.querySelector(".editor input.concurrency"), r = document.querySelector(".editor input.rpm");
|
||||
return !!(c.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
assert(order, "after Concurrency");
|
||||
|
||||
for (const bad of ["2.5", "10001"]) {
|
||||
await box(page).fill(bad);
|
||||
await page.locator(".editor .bar").getByRole("button", { name: await saveName(page), exact: true }).click();
|
||||
await page.locator(".editor .editor-error").waitFor();
|
||||
assert.equal(await page.locator(".editor .editor-error").textContent(), w.bad);
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.classList.contains("rpm")), true, "the field is focused");
|
||||
assert.equal(posts.length, 0);
|
||||
}
|
||||
|
||||
await box(page).fill("20");
|
||||
let saved = await save(page, posts);
|
||||
assert.equal(saved.action, "save");
|
||||
assert.equal(saved.body.id, "openrouter");
|
||||
assert.equal(saved.body.maxRPM, 20);
|
||||
assert.equal(saved.body.queueLimit, 0, "the queue goes with it as before");
|
||||
|
||||
await page.locator(".row.provider", { hasText: "OpenRouter" }).click();
|
||||
await box(page).fill("");
|
||||
saved = await save(page, posts);
|
||||
assert(Object.hasOwn(saved.body, "maxRPM"), "an empty field is saved as no limit, not left out");
|
||||
assert.equal(saved.body.maxRPM, 0);
|
||||
|
||||
const missing = await page.evaluate(() => [
|
||||
"Requests per minute", "Requests per minute: a whole number from 0 to 10000",
|
||||
"How many requests each key or account sends in any minute, retries included; one more waits for room, up to 2 minutes. 0 or empty is no limit",
|
||||
].flatMap((k) => ["zh", "zh-TW", "ja", "de"].filter((l) => !I18N[l][k]).map((l) => `${l}: ${k}`)));
|
||||
assert.deepEqual(missing, [], "every string has its translations");
|
||||
const stripes = await page.evaluate(() => [...document.querySelectorAll(".editor input.rpm, .editor input.rpm ~ *")].map((e) => getComputedStyle(e)).filter((s) => parseFloat(s.borderLeftWidth) > parseFloat(s.borderRightWidth) || (parseFloat(s.borderLeftWidth) > 0 && s.borderLeftColor !== s.borderRightColor)).length);
|
||||
assert.equal(stripes, 0, "no border stripes");
|
||||
assert.equal(await page.locator(".editor select").count(), 0, "no native select");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${engine} ${lang}: a signed-in account's limit opens as it is and is saved with its picks`, async (t) => {
|
||||
const { page, errors, posts } = await open(t, "Codex");
|
||||
assert.equal(await box(page).inputValue(), "20");
|
||||
await box(page).fill("12");
|
||||
const saved = await save(page, posts);
|
||||
assert.equal(saved.body.id, "codex");
|
||||
assert.equal(saved.body.maxRPM, 12);
|
||||
assert.deepEqual(saved.body.models, ["gpt-6"], "its picks go with it");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
for (const width of [360, 440]) {
|
||||
test(`${engine} ${lang} ${width}px: the label is on one line, styled like Concurrency, nothing runs off the page`, async (t) => {
|
||||
const { page, errors } = await open(t, "OpenRouter", width);
|
||||
const look = (e) => { const s = getComputedStyle(e); return { bg: s.backgroundColor, border: s.borderTopColor, radius: s.borderTopLeftRadius, height: s.height }; };
|
||||
assert.deepEqual(await box(page).evaluate(look), await page.locator(".editor input.concurrency").evaluate(look), "styled as Concurrency");
|
||||
const rpmLabel = page.locator(".editor label", { hasText: w.label });
|
||||
const ccLabel = page.locator(".editor label", { hasText: await page.evaluate(() => t("Concurrency")) });
|
||||
const [h, one] = [await rpmLabel.evaluate((e) => e.getBoundingClientRect().height), await ccLabel.evaluate((e) => e.getBoundingClientRect().height)];
|
||||
assert(h <= one + 1, `the label takes ${h}px, Concurrency's ${one}px`);
|
||||
const fit = await box(page).evaluate((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return { right: r.right, width: r.width, page: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth };
|
||||
});
|
||||
assert(fit.right <= fit.page + 0.5, `the field runs to ${fit.right}, the page is ${fit.page}`);
|
||||
assert(fit.width >= 80, `the field is ${fit.width}px`);
|
||||
assert(fit.scroll <= fit.page + 0.5, `the page scrolls sideways: ${fit.scroll} > ${fit.page}`);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,3 +179,43 @@ func normalAccountConcurrency(m map[string]int) map[string]int {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MaxRPMLimit bounds a limit on requests a minute.
|
||||
const MaxRPMLimit = 10000
|
||||
|
||||
// RPMLimit is how many requests a minute each of p's keys or accounts may
|
||||
// send the vendor, 0 for no limit: the provider's MaxRPM. An account made
|
||||
// afresh beside the agent's own (AlsoOn) carries none of the provider's
|
||||
// settings: it is read from providers.json then.
|
||||
func (p Provider) RPMLimit() int {
|
||||
n := p.MaxRPM
|
||||
if n == 0 && p.Account != nil {
|
||||
if s, ok := storedPicks(p.ID); ok {
|
||||
n = s.MaxRPM
|
||||
}
|
||||
}
|
||||
return min(max(n, 0), MaxRPMLimit)
|
||||
}
|
||||
|
||||
// CheckRPM says what is wrong with a limit on requests a minute, nil when
|
||||
// nothing is.
|
||||
func CheckRPM(n int) error {
|
||||
if n < 0 || n > MaxRPMLimit {
|
||||
return fmt.Errorf("a limit on requests a minute is from 0 (none) to %d, not %d", MaxRPMLimit, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRPM sets how many requests a minute each of the provider's keys or
|
||||
// accounts may send, 0 for no limit.
|
||||
func SetRPM(id string, n int) error {
|
||||
if err := CheckRPM(n); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := Find(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.MaxRPM = n
|
||||
return Save(*p)
|
||||
}
|
||||
@@ -141,6 +141,12 @@ type Provider struct {
|
||||
// it is turned away as having waited too long (#892). 0 waits as long
|
||||
// as it takes.
|
||||
QueueWait int `json:"queueWait,omitempty"`
|
||||
// MaxRPM is how many requests a minute each of its keys or accounts
|
||||
// sends the vendor, over a rolling 60 seconds (coeo91 on Discord:
|
||||
// OpenRouter's free models take 20 a minute, which a limit at once
|
||||
// can't keep to); one more waits for room (see RPMLimit). 0 is no
|
||||
// limit.
|
||||
MaxRPM int `json:"maxRPM,omitempty"`
|
||||
|
||||
// PriceRate is what the provider charges against the official price
|
||||
// (ITea312, #819): a relay that bills 0.8× or 1.5× of it. It scales the
|
||||
@@ -438,6 +444,7 @@ func allProviders() []Provider {
|
||||
a.AccountCaps = pk.AccountCaps
|
||||
a.MaxConcurrency, a.PinUpstream = pk.MaxConcurrency, pk.PinUpstream
|
||||
a.AccountConcurrency, a.QueueLimit, a.QueueWait = pk.AccountConcurrency, pk.QueueLimit, pk.QueueWait
|
||||
a.MaxRPM = pk.MaxRPM
|
||||
if a.ID == "cursor" { // picked before its efforts were one model
|
||||
a.Models = cursorPicks(a.Models)
|
||||
}
|
||||
@@ -555,7 +562,7 @@ func Save(p Provider) error {
|
||||
if p.ID == "kiro" {
|
||||
key = p.Key
|
||||
}
|
||||
p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, MaxConcurrency: p.MaxConcurrency, AccountConcurrency: p.AccountConcurrency, QueueLimit: p.QueueLimit, QueueWait: p.QueueWait, PinUpstream: p.PinUpstream, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID), Tucked: tuckedAccount(p.ID)}
|
||||
p = Provider{ID: p.ID, Key: key, Models: p.Models, Unlisted: p.Unlisted, Off: p.Off, Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Contexts: p.Contexts, Family: p.Family, Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, MaxConcurrency: p.MaxConcurrency, AccountConcurrency: p.AccountConcurrency, QueueLimit: p.QueueLimit, QueueWait: p.QueueWait, MaxRPM: p.MaxRPM, PinUpstream: p.PinUpstream, Hidden: hiddenAccount(p.ID), Quiet: quietAccount(p.ID), Tucked: tuckedAccount(p.ID)}
|
||||
} else {
|
||||
p.AccountProxies = keyProxies(p) // a provider of keys proxies each key apart
|
||||
if subscriptionID(p.ID) && !stored(p.ID) {
|
||||
@@ -906,6 +913,7 @@ func normalize(p Provider) Provider {
|
||||
}
|
||||
p.AccountConcurrency = normalAccountConcurrency(p.AccountConcurrency)
|
||||
p.QueueLimit, p.QueueWait = min(max(p.QueueLimit, 0), MaxQueueLimit), min(max(p.QueueWait, 0), MaxQueueWait)
|
||||
p.MaxRPM = min(max(p.MaxRPM, 0), MaxRPMLimit)
|
||||
p.Catalog = strings.Join(p.Catalogs(), ", ")
|
||||
// a Bedrock provider saved before the preset had its Responses API
|
||||
// (#176) gets it where its chat completions are: the runtime serves both
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package provider
|
||||
|
||||
import "testing"
|
||||
|
||||
// A limit on requests a minute (coeo91 on Discord) is kept with the
|
||||
// provider, within 0–MaxRPMLimit, and 0 takes it off; a subscription's is
|
||||
// kept with its picks and is every account's of it.
|
||||
func TestSetRPM(t *testing.T) {
|
||||
signIn(t)
|
||||
if err := Save(Provider{ID: "or", Name: "OpenRouter", Key: "k", Models: []string{"m"}, Chat: "http://127.0.0.1:9/v1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetRPM("or", 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := Find("or"); err != nil || p.MaxRPM != 20 || p.RPMLimit() != 20 {
|
||||
t.Fatalf("after SetRPM 20: %+v %v", p, err)
|
||||
}
|
||||
for _, n := range []int{-1, MaxRPMLimit + 1} {
|
||||
if err := SetRPM("or", n); err == nil {
|
||||
t.Fatalf("SetRPM %d was taken", n)
|
||||
}
|
||||
}
|
||||
if p, _ := Find("or"); p.MaxRPM != 20 {
|
||||
t.Fatalf("a refused limit changed it: %d", p.MaxRPM)
|
||||
}
|
||||
p, _ := Find("or")
|
||||
p.MaxRPM = 50000
|
||||
if err := Save(*p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, _ := Find("or"); p.MaxRPM != MaxRPMLimit {
|
||||
t.Fatalf("saved past the bound: %d", p.MaxRPM)
|
||||
}
|
||||
if err := SetRPM("or", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, _ := Find("or"); p.MaxRPM != 0 || p.RPMLimit() != 0 {
|
||||
t.Fatalf("off: %d", p.MaxRPM)
|
||||
}
|
||||
|
||||
if err := SetRPM("codex", 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, ok := storedPicks("codex"); !ok || s.MaxRPM != 30 {
|
||||
t.Fatalf("codex's picks: %+v", s)
|
||||
}
|
||||
n := 0
|
||||
for _, a := range All() {
|
||||
if a.ID == "codex" && a.Account != nil {
|
||||
n++
|
||||
if a.RPMLimit() != 30 {
|
||||
t.Fatalf("codex account %s: limit %d", a.Account.User, a.RPMLimit())
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("no codex account listed")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ const providerUsage = `usage:
|
||||
magpie provider queue <id> [length [seconds]]
|
||||
how many may wait for each account or key past its limit, and how long;
|
||||
past either a request is turned away with a 429 (0: no bound)
|
||||
magpie provider rpm <id> [n|off] how many requests each account or key sends the vendor in any minute;
|
||||
one more waits for room, up to 2 minutes, then is turned away with a 429
|
||||
magpie provider listed <id> yes|no no: its models serve only through routing groups, not in the list
|
||||
magpie provider off|on <id> switch it off (kept, but no agent or request uses it), or on again
|
||||
magpie provider test <id> [model…] send a tiny request through each endpoint, or to each model
|
||||
@@ -480,6 +482,8 @@ func providerCmd(args []string) error {
|
||||
return accountConcurrencyCmd(rest)
|
||||
case "queue":
|
||||
return queueCmd(rest)
|
||||
case "rpm":
|
||||
return rpmCmd(rest)
|
||||
case "listed":
|
||||
// no: the provider's models leave the list agents see and serve
|
||||
// only through the routing groups they are in
|
||||
@@ -1273,6 +1277,41 @@ func queueCmd(rest []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rpmCmd shows, or sets, how many requests each of a provider's accounts
|
||||
// or keys sends the vendor in any minute (coeo91 on Discord: OpenRouter's
|
||||
// free models take 20).
|
||||
func rpmCmd(rest []string) error {
|
||||
if len(rest) < 1 || len(rest) > 2 {
|
||||
return fmt.Errorf("magpie provider rpm <id> [n|off]")
|
||||
}
|
||||
p, err := provider.Find(rest[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rest) > 1 {
|
||||
n := 0
|
||||
switch s := strings.ToLower(strings.TrimSpace(rest[1])); s {
|
||||
case "off", "none", "no", "-":
|
||||
default:
|
||||
if n, err = strconv.Atoi(s); err != nil {
|
||||
return fmt.Errorf("requests a minute is a whole number, or off, not %q", rest[1])
|
||||
}
|
||||
}
|
||||
if err := provider.SetRPM(p.ID, n); err != nil {
|
||||
return err
|
||||
}
|
||||
if p, err = provider.Find(p.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if n := p.RPMLimit(); n > 0 {
|
||||
fmt.Printf("%s · each account or key: at most %d requests a minute\n", p.Name, n)
|
||||
} else {
|
||||
fmt.Printf("%s · each account or key: no limit on requests a minute\n", p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printMoved says which agents a change moved off models it stopped
|
||||
// serving (agent.Reseat).
|
||||
func printMoved(moved []agent.Move) {
|
||||
|
||||
Reference in new issue
Block a user