gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it.
This commit is contained in:
1 parent
3bcf71b003
commit
555f570660
11 files changed
+390
-35
No files matched your search
+4
-1
@@ -691,7 +691,10 @@ a 5-hour, day or 7-day limit: with the provider's Balance URL set to the
|
||||
relay's `/v1/usage`, its card shows those windows and routing weighs it by
|
||||
them as it does a subscription, resting it till the window renews once the
|
||||
relay says `api key 7天限额已用完` — or till its windows are next read short
|
||||
of full, its limit raised or its usage reset. `stays=` is how long a conversation
|
||||
of full, its limit raised or its usage reset. A key bought as a plan — Kimi Code, GLM Coding Plan
|
||||
or Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code — is weighed by its
|
||||
plan's windows the same way. With one key or account, the Routing page still
|
||||
shows what it has left. `stays=` is how long a conversation
|
||||
stays with the key or account that answered it: `auto` (the default, while
|
||||
the vendor's cache of it is worth keeping), `session`, `turn` or `off`.
|
||||
`models=` replaces the whole list, in order; a bare model id works when only
|
||||
|
||||
@@ -19,7 +19,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
| HTTP surface | `Handler` routes `/v1/chat/completions`, `/v1/responses`, `/v1/messages` (each also without `/v1`), `/v1/models`, Gemini's `/v1beta/models/…`, the Codex backend path, `/mcp/{name}` and the `/v1/magpie/*` status endpoints. A GET on `/v1/responses` (a client opening Responses over a WebSocket) is answered 426, a plain one 405: Responses is relayed over HTTP only. Every route is wrapped in `counted`, `callerGuard`, `withCaller` and `keyLimited`. | [`gateway.go`](../../internal/gateway/gateway.go) |
|
||||
| Model resolution | `serve` resolves the model the agent asked for: Claude tier stand-ins, a routing group (`provider.GroupFor`), 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); any other key has none. 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) |
|
||||
| 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) |
|
||||
| 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 provider's proxy (`p.Via`). 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) |
|
||||
@@ -48,7 +48,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
- In turn (`rotate`), a conversation nobody has answered yet goes to the key, account or model the fewest other conversations of the provider's model or the group answered in the last `heldFor` (30 minutes) are on (`leastHeld`, #946). Ties, resting ones and a group with a group in it keep routing's order. With session affinity, two sessions of one group so stay on different models.
|
||||
- A ChatGPT account that holds credits answers past its windows on them; the vendor spends them. That is the default. Set not to spend them (Usage card's *Use credits*, `magpie quota credits <account> off`), the account is held at 100% of a window till it renews, by magpie's latest reading of its windows (`provider.Allowances`, refreshed about every minute), so the other accounts, groups and fallbacks take the request. A cap set on the account (1–99%) takes precedence. Unlike a cap, the hold is at the vendor's own 100%, so an account allowed to spend its resets spends one there, both when every candidate is held and after the last one is refused. A reset made forgets the account's cached allowance (`renewedNow`), so neither hold outlives the windows it started again.
|
||||
- Different failures are classified separately: out of credit, out of quota, rate limit and other errors rest for different lengths. A fix must not merge them into one bucket (see #873).
|
||||
- A sub2api key out of its own limit answers 429 `api key 7天限额已用完` (or `日限额`, `5小时限额`) as `rate_limit_exceeded`. It is out of quota (`plannedWords`), not rate limited: it rests till the window it filled renews when its windows as last read say so, else `quotaRest`, and its windows are read again (`staleKeyAllowance`). A reading that finds that window full no more (the limit raised in the relay's panel, or the key's usage reset) lifts the rest (`renewed`, told through `provider.OnRenewed`), as a Codex reset spent does an account's; the key's other rests stay. While it rests out of its windows, they are read again each time it is planned (`restingOf`, once a minute at most), so this holds where nothing weighs it by them: an ordered group, or a provider with one key. The relay out of accounts for everyone (503 `No available accounts`, or a later version's 429 `All available accounts are currently rate-limited`) says nothing of the key's windows: it backs off as any failure or rate limit does, never to the window. A key's windows count only its own spend, so other users draining the relay's accounts show up as those errors, not in the windows.
|
||||
- A sub2api key out of its own limit answers 429 `api key 7天限额已用完` (or `日限额`, `5小时限额`) as `rate_limit_exceeded`. It is out of quota (`plannedWords`), not rate limited: it rests till the window it filled renews when its windows as last read say so, else `quotaRest`, and its windows are read again (`staleKeyAllowance`). A reading that finds that window full no more (the limit raised in the relay's panel, or the key's usage reset) lifts the rest (`renewed`, told through `provider.OnRenewed`), as a Codex reset spent does an account's; the key's other rests stay. While it rests out of its windows, they are read again each time it is planned (`restingOf`, once a minute at most), so this holds where nothing weighs it by them: an ordered group. The relay out of accounts for everyone (503 `No available accounts`, or a later version's 429 `All available accounts are currently rate-limited`) says nothing of the key's windows: it backs off as any failure or rate limit does, never to the window. A key's windows count only its own spend, so other users draining the relay's accounts show up as those errors, not in the windows.
|
||||
- Only an account is held at a usage cap (`capHeld`) or rested out of one model's pool (`pooled`); a key's windows order it and rest it, nothing more.
|
||||
- Codex offers its image tool (the `image_gen` namespace, #870) to every magpie provider. When a provider other than a ChatGPT sign-in or openai.com answers a bare 400, `attempt` asks it once more without the tool (`image_tool.go`). This happens both passed through and translated. If that works, the provider is marked unfit for the tool (`tool:image_gen`) and isn't offered it again. If it doesn't, the tool is restored and the 400 stands (#949).
|
||||
- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008).
|
||||
@@ -59,7 +59,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
|
||||
```sh
|
||||
go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain'
|
||||
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance'
|
||||
go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance'
|
||||
```
|
||||
|
||||
The gateway's `TestMain` gives the package a home of its own, so these tests
|
||||
|
||||
@@ -28,7 +28,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
|
||||
| Used-up switch | When the account Codex or Claude Code is signed in to has used its allowance, the agent is signed in to the next account that is on and has room (#208). | [`codex_switch.go`](../../internal/provider/codex_switch.go) |
|
||||
| Routing groups | User-made groups from the Routing view, and auto-found groups of the same model across providers (derived, never stored). Members are `provider/model[:effort]`. A group can nest another (`group/<id>`); `SaveGroup` refuses loops. `Match` takes a glob or `re:` pattern (#766). | [`group.go`](../../internal/provider/group.go) |
|
||||
| Allowance | `Quotas` asks every vendor at once: subscription windows (`SubscriptionUsage`), key-bought plans (`PlanQuotas`) and key balances (`KeyBalances`). The Usage page, `magpie quota --json` and `GET /v1/magpie/quotas` read it. A single card can be read again on its own (#840). A remote magpie's cards come from what it has kept (`CachedCards`, `GET /v1/magpie/quotas/cards`) and are never read there for this computer except by a card's refresh (`POST /v1/magpie/quotas/refresh`, throttled per card). | [`quotas.go`](../../internal/provider/quotas.go), [`subscription_usage.go`](../../internal/provider/subscription_usage.go), [`planquota.go`](../../internal/provider/planquota.go), [`balance.go`](../../internal/provider/balance.go), [`quota_refresh.go`](../../internal/provider/quota_refresh.go), [`remote_quotas.go`](../../internal/provider/remote_quotas.go) |
|
||||
| A key's own windows | A sub2api key given a spending limit per 5 hours, day or 7 days answers its `/v1/usage` with `rate_limits` and no `remaining` (`readSub2APIKeyLimits`). With the Balance URL set to it, the key's card shows them as the windows *5 hours*, *1 day*, *7 days*, in USD, beside a balance field the user wrote; *Check balance* and `magpie provider show` give what its fullest window has left (`Balance`). `KeyAllowance` gives routing the same windows: never waited for, read in the background at most once a minute, a failed read keeping the last, and seeded from the card's last reading (`quotas.json`) after a restart. | [`sub2api_usage.go`](../../internal/provider/sub2api_usage.go), [`key_allowance.go`](../../internal/provider/key_allowance.go) |
|
||||
| A key's own windows | A sub2api key given a spending limit per 5 hours, day or 7 days answers its `/v1/usage` with `rate_limits` and no `remaining` (`readSub2APIKeyLimits`). With the Balance URL set to it, the key's card shows them as the windows *5 hours*, *1 day*, *7 days*, in USD, beside a balance field the user wrote; *Check balance* and `magpie provider show` give what its fullest window has left (`Balance`). `KeyAllowance` gives routing the same windows: never waited for, read in the background at most once a minute, a failed read keeping the last, and seeded from the card's last reading (`quotas.json`) after a restart. A key bought as a plan (Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code: `planQuotaSourceOf`) gives routing its card's windows the same way (`planKeyWindows`, Zhipu's team quota behind a key with none of its own); the Usage page's read of its card is taken at once (`noteKeyAllowance`), and a key there that turns out pay-as-you-go, with no windows, is asked again only every 30 minutes (`keyNoWindowsAge`). StepFun's Step Plan is read through a sign-in, not the key, and isn't one of them. | [`sub2api_usage.go`](../../internal/provider/sub2api_usage.go), [`key_allowance.go`](../../internal/provider/key_allowance.go) |
|
||||
| Last served | `served.json` records when each account, plan or key last answered, shown as `lastServedAt` (#570). | [`served.go`](../../internal/provider/served.go) |
|
||||
| Protocol detection | Works out which APIs a base URL speaks when a provider is added. | [`detect.go`](../../internal/provider/detect.go) |
|
||||
|
||||
@@ -54,7 +54,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
go test -tags nogui ./internal/provider -run 'TestAccount|TestCodex|TestGroup|TestQuota|TestLogin|TestSignIn|TestSeveralKeys|TestKeyProtocol|TestSetKeyWeight|TestReadSub2APIKeyLimits|TestSub2APIKeyLimitsOnItsCard|TestKeyAllowance'
|
||||
go test -tags nogui ./internal/provider -run 'TestAccount|TestCodex|TestGroup|TestQuota|TestLogin|TestSignIn|TestSeveralKeys|TestKeyProtocol|TestSetKeyWeight|TestReadSub2APIKeyLimits|TestSub2APIKeyLimitsOnItsCard|TestKeyAllowance|TestPlanKeyAllowance|TestPayAsYouGoKeyAskedSeldom'
|
||||
go test -tags nogui ./internal/provider
|
||||
```
|
||||
|
||||
|
||||
@@ -263,7 +263,10 @@ func affine(scope, mode string, rotate bool, in http.Header, from provider.Proto
|
||||
a.Why = "gone"
|
||||
case pl.order[at].Rest != nil:
|
||||
a.Why = "resting"
|
||||
case pl.order[at].Known && pl.order[at].Used >= provider.SpentShareOf(cs[at].p.Routing):
|
||||
case at > 0 && pl.order[at].Known && pl.order[at].Used >= provider.SpentShareOf(cs[at].p.Routing):
|
||||
// all but used up, and put behind another: first still — the
|
||||
// one key or account it has (#1016), or every one used up — it
|
||||
// doesn't move
|
||||
a.Why = "spent"
|
||||
case mode == provider.AffinitySession:
|
||||
a.Why = "session"
|
||||
|
||||
@@ -649,8 +649,8 @@ func restLast(out []candidate, pl planned) ([]candidate, planned) {
|
||||
// restingOf is why c rests, by its own key or else its provider's, while
|
||||
// it does. A key resting out of its own windows has them read again as it
|
||||
// is planned, behind the request and once a minute at most: an ordered
|
||||
// group, or a provider with one key, weighs nothing by them, and a
|
||||
// reading that finds them full no more is what brings it back (renewed).
|
||||
// group weighs nothing by them, and a reading that finds them full no
|
||||
// more is what brings it back (renewed).
|
||||
func restingOf(c candidate) (Rest, bool) {
|
||||
r, ok := restOf(c.restKey())
|
||||
if !ok && c.restID() != c.restKey() {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// #1016: a provider with one key on — Kimi Code (China), its plan's 5
|
||||
// hours 49% used and its week 45% — is weighed against nobody, but the
|
||||
// routing trace still tells what it has left, so the Routing page shows
|
||||
// it as it does for several; a lone subscription account the same. A key
|
||||
// whose windows aren't read stays not known.
|
||||
func TestLoneOneTellsWhatItHasLeft(t *testing.T) {
|
||||
now := time.Now()
|
||||
five, week := now.Add(2*time.Hour), now.Add(26*time.Hour)
|
||||
kimi := provider.Allowance{
|
||||
{Used: 45, Resets: week, Span: 7 * 24 * time.Hour},
|
||||
{Used: 49, Resets: five, Span: 5 * time.Hour},
|
||||
}
|
||||
keyWindows(t, map[string]provider.Allowance{"sk-kimi-a": kimi})
|
||||
old := allowances
|
||||
t.Cleanup(func() { allowances = old })
|
||||
allowances = func(string) map[string]provider.Allowance {
|
||||
return map[string]provider.Allowance{"me@example.com": kimi}
|
||||
}
|
||||
|
||||
p := provider.Provider{ID: "kimi-code-cn", Name: "Kimi Code (China)", Key: "sk-kimi-a", Chat: "https://api.kimi.com/coding/v1"}
|
||||
cs := []candidate{{p: p, model: "kimi-for-coding", rest: "kimi-code-cn"}}
|
||||
got, wg := weigh(p, cs, "kimi-for-coding", provider.Chat)
|
||||
if len(got) != 1 || got[0].rest != "kimi-code-cn" {
|
||||
t.Fatalf("the one key: %s", restsOf(got))
|
||||
}
|
||||
w := weighed(got[0], p, wg, false, provider.Chat)
|
||||
if w.Kind != "provider" || !w.Known || w.Used != 49 || len(w.Renews) != 2 || !w.Renews[0].Equal(week) {
|
||||
t.Fatalf("the one key's trace: %+v", w)
|
||||
}
|
||||
|
||||
codex := provider.Provider{ID: "codex", Account: &provider.Account{Agent: "codex", User: "me@example.com"}}
|
||||
ac := []candidate{{p: codex, model: "gpt-6-astra", rest: "codex"}}
|
||||
got, wg = weigh(codex, ac, "gpt-6-astra", provider.Responses)
|
||||
if w := weighed(got[0], codex, wg, false, provider.Responses); w.Kind != "account" || !w.Known || w.Used != 49 {
|
||||
t.Fatalf("the one account's trace: %+v", w)
|
||||
}
|
||||
|
||||
plain := provider.Provider{ID: "deepseek", Key: "sk-ds", Chat: "https://api.deepseek.com/v1"}
|
||||
pc := []candidate{{p: plain, model: "deepseek-chat", rest: "deepseek"}}
|
||||
got, wg = weigh(plain, pc, "deepseek-chat", provider.Chat)
|
||||
if w := weighed(got[0], plain, wg, false, provider.Chat); w.Known || w.Learns {
|
||||
t.Fatalf("a key whose windows aren't read: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// The one key, all but used up, keeps the conversation it answered: there
|
||||
// is nobody it could move to, and the routing story says it was kept,
|
||||
// not that it moved.
|
||||
func TestLoneSpentKeyKeepsItsConversation(t *testing.T) {
|
||||
now := time.Now()
|
||||
keyWindows(t, map[string]provider.Allowance{"sk-kimi-a": {{Used: 99, Resets: now.Add(2 * time.Hour), Span: 5 * time.Hour}}})
|
||||
p := provider.Provider{ID: "kimi-code-cn", Key: "sk-kimi-a", Chat: "https://api.kimi.com/coding/v1"}
|
||||
c := candidate{p: p, model: "kimi-for-coding", rest: "kimi-code-cn"}
|
||||
cs, wg := weigh(p, []candidate{c}, "kimi-for-coding", provider.Chat)
|
||||
pl := planned{order: []Weighed{weighed(cs[0], p, wg, false, provider.Chat)}}
|
||||
if !pl.order[0].Known || pl.order[0].Used != 99 {
|
||||
t.Fatalf("the trace: %+v", pl.order[0])
|
||||
}
|
||||
h := http.Header{}
|
||||
h.Set("x-claude-code-session-id", "lone-spent-1016")
|
||||
body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
_, _, _, key := affine("t", provider.AffinitySession, false, h, provider.Chat, body, cs, pl)
|
||||
answered(key, c, 1, 0)
|
||||
t.Cleanup(func() { sticks.Lock(); delete(sticks.m, key); sticks.Unlock() })
|
||||
got, _, aff, _ := affine("t", provider.AffinitySession, false, h, provider.Chat, body, cs, pl)
|
||||
if got[0].rest != "kimi-code-cn" || aff.Why == "spent" || !aff.Kept {
|
||||
t.Fatalf("kept %v, why %s", aff.Kept, aff.Why)
|
||||
}
|
||||
}
|
||||
@@ -656,10 +656,12 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
|
||||
// weighRouted is weigh by the routing alone.
|
||||
func weighRouted(p provider.Provider, cs []candidate, model string, from provider.Protocol) ([]candidate, weighing) {
|
||||
var wg weighing
|
||||
if len(cs) < 2 {
|
||||
if len(cs) == 0 {
|
||||
return cs, wg
|
||||
}
|
||||
// each account's own agent's: a group weighs accounts of several
|
||||
// each account's own agent's: a group weighs accounts of several. One
|
||||
// alone is weighed against nobody, but what it has left is still
|
||||
// told: the Routing page shows it (#1016)
|
||||
known := map[string]map[string]provider.Allowance{}
|
||||
now := time.Now()
|
||||
wg.lefts = map[allowanceKey]left{}
|
||||
@@ -686,6 +688,9 @@ func weighRouted(p provider.Provider, cs []candidate, model string, from provide
|
||||
restarts: rs, dueRestart: !rs.IsZero() && due.Equal(rs)} // one not known counts as unused
|
||||
}
|
||||
}
|
||||
if len(cs) < 2 {
|
||||
return cs, wg
|
||||
}
|
||||
lefts := wg.lefts
|
||||
shareOf := func(c candidate) float64 { return lefts[c.allowanceKey()].used }
|
||||
switch p.Routing {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// row says how much of its window is used and when it renews, with a bar,
|
||||
// and the story says why it went first by its allowance — beside an
|
||||
// account in a group, or among a provider's keys, all at 90% or more. A
|
||||
// key not known is told as before. In English, Chinese, Japanese and
|
||||
// German.
|
||||
// key not known is told as before. A lone key bought as a plan (#1016:
|
||||
// Kimi Code's, 49% of its 5 hours used) says what it has left too, at
|
||||
// 440px as well. In English, Chinese, Japanese and German.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -47,6 +48,12 @@ const scenes = {
|
||||
{ id: "relay#a", provider: "relay", name: "Relay", who: "relay-a", kind: "key", model: "gpt-6-astra", routing: "usage", tokens: 100, ...week(95, 30) },
|
||||
],
|
||||
},
|
||||
// #1016: Kimi Code (China) with one key on, its 5 hours 49% used
|
||||
lone: {
|
||||
order: [
|
||||
{ id: "kimi-code-cn", provider: "kimi-code-cn", name: "Kimi Code (China)", kind: "provider", model: "kimi-for-coding", known: true, used: 49, renews: [ahead(26), ahead(2)] },
|
||||
],
|
||||
},
|
||||
// keys whose windows aren't read: as before
|
||||
plain: {
|
||||
order: [
|
||||
@@ -83,16 +90,20 @@ function serve(lang, scene) {
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, engine, lang, scene) {
|
||||
async function open(t, engine, lang, scene, width = 1100) {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/*", serve(lang, scene));
|
||||
await page.goto("http://magpie.test/?view=routing");
|
||||
await page.locator(".rt-req").first().click();
|
||||
// the one request is opened already; below the fold at 440px, WebKit
|
||||
// never finds it still enough to click
|
||||
const req = page.locator(".rt-req").first();
|
||||
await req.waitFor();
|
||||
if (await req.getAttribute("aria-pressed") !== "true") await req.click();
|
||||
await page.locator(".rt-steps li.why").first().waitFor();
|
||||
// each row's state is filled on the next frame, after the story
|
||||
await page.waitForFunction(() => [...document.querySelectorAll("li")].filter((li) => li.querySelector(".who")).every((li) => li.querySelector("em")?.textContent.trim()));
|
||||
@@ -138,6 +149,27 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await say("Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. The week decides; an account with five hours and no week goes by its five hours. One at 90% or more waits until the others can't answer; one resting after a failure goes last."));
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
for (const width of [1100, 440]) {
|
||||
test(`${engine} ${lang} ${width}px: one key on still says what it has left`, async (t) => {
|
||||
const { page, errors, say, row, why } = await open(t, engine, lang, "lone", width);
|
||||
assert.equal((await why()).trim(), await say("{name} has one key on — nothing to choose between.", { name: "Kimi Code (China)" }));
|
||||
const used = (await say("{n} used · renews in {d}", { n: "49%" })).split("{d}")[0];
|
||||
// its row in the story has a bar; it answered, so it says that
|
||||
const key = row("Kimi Code (China)");
|
||||
assert.equal(await key.locator(".bar i").evaluate((i) => i.style.width), "49%");
|
||||
assert.equal(await key.evaluate((li) => li.classList.contains("nobar")), false);
|
||||
// its own row under its name says what is used and when it renews
|
||||
const act = page.locator(".rt-prov", { hasText: "Kimi Code (China)" }).locator("xpath=following-sibling::div[contains(@class, 'rt-act')][1]");
|
||||
await act.locator(".st", { hasText: "49%" }).waitFor();
|
||||
const st = (await act.locator(".st").textContent()).trim();
|
||||
assert.ok(st.startsWith(used), `${st}\nwant ${used}…`);
|
||||
assert.equal(await act.locator(".bar i").evaluate((i) => i.style.width), "49%");
|
||||
const fits = await act.locator(".st").evaluate((e) => e.scrollWidth <= e.clientWidth + 1);
|
||||
assert.ok(fits, `${st} is cut at this width`);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, "the page scrolls sideways");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`${engine} ${lang}: a key not read yet beside one read is told as weighed with it`, async (t) => {
|
||||
|
||||
@@ -2,8 +2,10 @@ package provider
|
||||
|
||||
// A key's own usage windows, for routing, as Allowances is a subscription
|
||||
// account's: a sub2api key its owner gave a 5-hour, day or 7-day limit
|
||||
// tells what it has spent of each, and routing weighs and rests it by
|
||||
// them. They are read behind the request, never waited for, and read
|
||||
// tells what it has spent of each, as a plan bought with a key (Kimi
|
||||
// Code, the GLM Coding Plan, MiniMax's, OpenCode Go, Command Code's:
|
||||
// planquota.go) tells its plan's windows, and routing weighs and rests it
|
||||
// by them (#1016). They are read behind the request, never waited for, and read
|
||||
// again once a minute has passed; until the first reading is back, the
|
||||
// one last shown on the key's card stands in for it. A reading that finds
|
||||
// a window the key was out of no longer full — its limit raised in the
|
||||
@@ -31,14 +33,28 @@ type keyAllowance struct {
|
||||
loading bool
|
||||
}
|
||||
|
||||
// keyAllowanceAge is how long a key's windows as read go unasked again.
|
||||
const keyAllowanceAge = time.Minute
|
||||
// keyAllowanceAge is how long a key's windows as read go unasked again;
|
||||
// keyNoWindowsAge one's that was read and has none — a pay-as-you-go key
|
||||
// at a vendor that sells plans too — which is asked again only now and
|
||||
// then, in case a plan was bought since.
|
||||
const (
|
||||
keyAllowanceAge = time.Minute
|
||||
keyNoWindowsAge = 30 * time.Minute
|
||||
)
|
||||
|
||||
// readsKeyWindows says p is a key whose own usage windows magpie reads:
|
||||
// one whose Balance URL is a sub2api panel's query for a key. Said
|
||||
// one whose Balance URL is a sub2api panel's query for a key, or one at a
|
||||
// vendor whose keys tell their plan's windows (planQuotaSourceOf). Said
|
||||
// without asking anything, so any other key costs nothing here.
|
||||
func readsKeyWindows(p Provider) bool {
|
||||
return p.Account == nil && p.Key != "" && sub2APIKeyLimits(p)
|
||||
if p.Account != nil || p.Key == "" {
|
||||
return false
|
||||
}
|
||||
if sub2APIKeyLimits(p) {
|
||||
return true
|
||||
}
|
||||
_, ok := planQuotaSourceOf(p)
|
||||
return ok
|
||||
}
|
||||
|
||||
func keyAllowanceID(p Provider) string { return p.ID + "#" + keyID(p.Key) }
|
||||
@@ -80,7 +96,11 @@ func KeyAllowance(p Provider) (Allowance, bool) {
|
||||
e = &keyAllowance{a: seed}
|
||||
c.m[id] = e
|
||||
}
|
||||
if !e.loading && time.Since(e.at) > keyAllowanceAge {
|
||||
age := keyAllowanceAge
|
||||
if len(e.a) == 0 && !e.at.IsZero() {
|
||||
age = keyNoWindowsAge
|
||||
}
|
||||
if !e.loading && time.Since(e.at) > age {
|
||||
e.loading = true
|
||||
go readKeyAllowance(p, id, c.gen)
|
||||
}
|
||||
@@ -121,8 +141,8 @@ func (e *keyAllowance) keep(a Allowance, share float64, now time.Time) (renewed
|
||||
}
|
||||
|
||||
// keyWindows asks the vendor for p's key's windows, as the key's card
|
||||
// is asked (KeyBalances): the provider as saved, with the key, rather
|
||||
// than as a request has it, which leaves out the base URLs of the
|
||||
// is asked (KeyBalances, PlanQuotas): the provider as saved, with the key,
|
||||
// rather than as a request has it, which leaves out the base URLs of the
|
||||
// protocols a key isn't made for.
|
||||
func keyWindows(ctx context.Context, p Provider) ([]QuotaWindow, error) {
|
||||
q := p
|
||||
@@ -130,10 +150,23 @@ func keyWindows(ctx context.Context, p Provider) ([]QuotaWindow, error) {
|
||||
q = *saved
|
||||
q.Key = p.Key
|
||||
}
|
||||
if src, ok := planQuotaSourceOf(q); ok && !sub2APIKeyLimits(q) {
|
||||
_, ws, _, err := planKeyWindows(q.Via(ctx), q, src, q.Key)
|
||||
return ws, err
|
||||
}
|
||||
_, _, ws, _, err := balanceRead(ctx, q)
|
||||
return ws, err
|
||||
}
|
||||
|
||||
// keyCardTag is what p's key's card is kept on disk by (keepLast): its
|
||||
// plan's card, or its balance's.
|
||||
func keyCardTag(p Provider) string {
|
||||
if _, ok := planQuotaSourceOf(p); ok && !sub2APIKeyLimits(p) {
|
||||
return keyTag("plan", p.Key)
|
||||
}
|
||||
return keyTag("balance", p.Key)
|
||||
}
|
||||
|
||||
// lastKeyAllowance is p's key's windows as its card last read them, kept
|
||||
// on disk (keepLast), each started again whose reset has passed.
|
||||
func lastKeyAllowance(p Provider) Allowance {
|
||||
@@ -141,7 +174,7 @@ func lastKeyAllowance(p Provider) Allowance {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.load()
|
||||
q, ok := c.reading(p.ID + "/" + strings.ToLower(keyTag("balance", p.Key)))
|
||||
q, ok := c.reading(p.ID + "/" + strings.ToLower(keyCardTag(p)))
|
||||
if !ok || len(q.Windows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// planKeyServer answers Kimi Code's /usages as #1016's key had them (5
|
||||
// hours 49 used, 51 left; the week 45 used), and Zhipu's quota for a
|
||||
// pay-as-you-go GLM key: no plan, no windows. It counts the asks of each.
|
||||
func planKeyServer(t *testing.T, down *atomic.Bool) (kimi, glm *atomic.Int32) {
|
||||
t.Helper()
|
||||
kimi, glm = &atomic.Int32{}, &atomic.Int32{}
|
||||
five := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339Nano)
|
||||
week := time.Now().Add(26 * time.Hour).UTC().Format(time.RFC3339Nano)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Host") + r.URL.Path {
|
||||
case "api.kimi.com/coding/v1/usages":
|
||||
kimi.Add(1)
|
||||
if down.Load() || r.Header.Get("Authorization") != "Bearer sk-kimi-a" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"usage":{"limit":"100","used":"45","resetTime":"` + week + `"},
|
||||
"limits":[{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"},"detail":{"limit":"100","remaining":"51","resetTime":"` + five + `"}}]}`))
|
||||
case "open.bigmodel.cn/api/monitor/usage/quota/limit":
|
||||
if r.URL.RawQuery == "" { // not the team's ask behind it (type=2)
|
||||
glm.Add(1)
|
||||
}
|
||||
w.Write([]byte(`{"success":true,"data":{"limits":[]}}`))
|
||||
default:
|
||||
// a team's quota asked of a key with no plan of its own
|
||||
if !strings.Contains(r.URL.Path, "quota") {
|
||||
t.Errorf("asked %s%s", r.Header.Get("X-Host"), r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
old := http.DefaultClient.Transport
|
||||
http.DefaultClient.Transport = rewrite{srv}
|
||||
t.Cleanup(func() { http.DefaultClient.Transport = old })
|
||||
return kimi, glm
|
||||
}
|
||||
|
||||
var kimiKey = Provider{ID: "kimi-code-cn", Name: "Kimi Code (China)", Preset: "kimi-code-cn", Key: "sk-kimi-a",
|
||||
Chat: "https://api.kimi.com/coding/v1", Anthropic: "https://api.kimi.com/coding"}
|
||||
|
||||
// #1016: a plan bought with a key (Kimi Code's) tells routing its
|
||||
// windows as a sub2api key's limits do — read behind the request, never
|
||||
// waited for — so the Routing page can show what it has left, as the
|
||||
// Usage page does; a key at another vendor is never asked.
|
||||
func TestPlanKeyAllowance(t *testing.T) {
|
||||
keyLimitsHome(t)
|
||||
var down atomic.Bool
|
||||
kimi, _ := planKeyServer(t, &down)
|
||||
if err := Save(kimiKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a, ok := KeyAllowance(kimiKey); ok || a != nil {
|
||||
t.Fatalf("known before it was read: %+v", a)
|
||||
}
|
||||
a := waitAllowance(t, kimiKey, func(Allowance) bool { return true })
|
||||
used, renews := a.For("kimi-for-coding", time.Now())
|
||||
if used != 49 || len(renews) != 2 || !renews[0].After(renews[1]) {
|
||||
t.Fatalf("used %v, renews %v of %+v", used, renews, a)
|
||||
}
|
||||
if id := KeyAllowanceID(kimiKey); id == "" {
|
||||
t.Fatal("a plan key has no id for its windows renewed")
|
||||
}
|
||||
if n := kimi.Load(); n != 1 {
|
||||
t.Fatalf("asked %d times", n)
|
||||
}
|
||||
// a key at a vendor that sells no plan to a key isn't asked, nor known
|
||||
for _, p := range []Provider{
|
||||
{ID: "deepseek", Key: "sk-ds", Chat: "https://api.deepseek.com/v1"},
|
||||
{ID: "kimi-api", Key: "sk-moon", Chat: "https://api.moonshot.cn/v1"},
|
||||
} {
|
||||
if _, ok := KeyAllowance(p); ok || KeyAllowanceID(p) != "" {
|
||||
t.Errorf("%s reads key windows", p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Usage page's read of a plan's card is what routing goes by at
|
||||
// once, and the card kept on disk stands in for it after a restart while
|
||||
// the vendor fails.
|
||||
func TestPlanKeyAllowanceFromItsCard(t *testing.T) {
|
||||
keyLimitsHome(t)
|
||||
forgetPlanQuotas()
|
||||
t.Cleanup(forgetPlanQuotas)
|
||||
var down atomic.Bool
|
||||
kimi, _ := planKeyServer(t, &down)
|
||||
if err := Save(kimiKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qs := PlanQuotas(context.Background())
|
||||
if len(qs) != 1 || len(qs[0].Windows) != 2 {
|
||||
t.Fatalf("cards: %+v", qs)
|
||||
}
|
||||
a, ok := KeyAllowance(kimiKey)
|
||||
if used, _ := a.For("", time.Now()); !ok || used != 49 {
|
||||
t.Fatalf("after the card's read: %v %+v", ok, a)
|
||||
}
|
||||
if n := kimi.Load(); n != 1 {
|
||||
t.Fatalf("asked %d times: routing asked again what the card had just read", n)
|
||||
}
|
||||
|
||||
// restarted, the vendor down: the card on disk stands in
|
||||
down.Store(true)
|
||||
keyAllowances.Lock()
|
||||
keyAllowances.m = nil
|
||||
keyAllowances.Unlock()
|
||||
lastQuotas.Lock()
|
||||
lastQuotas.m, lastQuotas.loaded = nil, false
|
||||
lastQuotas.Unlock()
|
||||
a, ok = KeyAllowance(kimiKey)
|
||||
if used, _ := a.For("", time.Now()); !ok || used != 49 {
|
||||
t.Fatalf("from the card on disk: %v %+v", ok, a)
|
||||
}
|
||||
// and the failed read behind it leaves it known
|
||||
waitRead(t)
|
||||
if a, ok := KeyAllowance(kimiKey); !ok || len(a) != 2 {
|
||||
t.Fatalf("after a failed read: %v %+v", ok, a)
|
||||
}
|
||||
}
|
||||
|
||||
// A pay-as-you-go key at a vendor that sells plans too (a GLM key at
|
||||
// BigModel) has no windows: it is asked again only every half hour, not
|
||||
// every minute it is used, in case a plan is bought.
|
||||
func TestPayAsYouGoKeyAskedSeldom(t *testing.T) {
|
||||
keyLimitsHome(t)
|
||||
var down atomic.Bool
|
||||
_, glm := planKeyServer(t, &down)
|
||||
p := Provider{ID: "zhipu", Key: "glm-a", Chat: "https://open.bigmodel.cn/api/paas/v4"}
|
||||
if err := Save(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
KeyAllowance(p)
|
||||
waitRead(t)
|
||||
if n := glm.Load(); n != 1 {
|
||||
t.Fatalf("asked %d times", n)
|
||||
}
|
||||
back := func(d time.Duration) {
|
||||
keyAllowances.Lock()
|
||||
for _, e := range keyAllowances.m {
|
||||
e.at = time.Now().Add(-d)
|
||||
}
|
||||
keyAllowances.Unlock()
|
||||
}
|
||||
back(2 * time.Minute)
|
||||
if _, ok := KeyAllowance(p); ok {
|
||||
t.Fatal("a key with no windows is known")
|
||||
}
|
||||
waitRead(t)
|
||||
if n := glm.Load(); n != 1 {
|
||||
t.Fatalf("asked again %d times two minutes on", n-1)
|
||||
}
|
||||
back(31 * time.Minute)
|
||||
KeyAllowance(p)
|
||||
waitRead(t)
|
||||
if n := glm.Load(); n != 2 {
|
||||
t.Fatalf("asked %d times half an hour on", n)
|
||||
}
|
||||
}
|
||||
|
||||
// waitRead waits for the reads behind KeyAllowance to end.
|
||||
func waitRead(t *testing.T) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
busy := false
|
||||
keyAllowances.Lock()
|
||||
for _, e := range keyAllowances.m {
|
||||
busy = busy || e.loading
|
||||
}
|
||||
keyAllowances.Unlock()
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("a read never ended")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,19 @@ func planWindows(ctx context.Context, src planQuotaSource, key string) (plan str
|
||||
return src.read(b)
|
||||
}
|
||||
|
||||
// planKeyWindows asks the vendor for the plan key is on and its windows:
|
||||
// a Zhipu or Z.ai key with no plan of its own is a team's, whose windows
|
||||
// are asked with type=2 (zcode_team.go), and team says so.
|
||||
func planKeyWindows(ctx context.Context, p Provider, src planQuotaSource, key string) (plan string, ws []QuotaWindow, team bool, err error) {
|
||||
plan, ws, err = planWindows(ctx, src, key)
|
||||
if zhipu := strings.HasSuffix(src.url, "/api/monitor/usage/quota/limit"); zhipu && (err != nil || len(ws) == 0) {
|
||||
if tplan, tws, terr := zhipuKeyTeamWindows(ctx, src.url, key, p.ZhipuTeam); terr == nil && len(tws) > 0 {
|
||||
return tplan, tws, true, nil
|
||||
}
|
||||
}
|
||||
return plan, ws, false, err
|
||||
}
|
||||
|
||||
var planQuotaCache struct {
|
||||
sync.Mutex
|
||||
at time.Time
|
||||
@@ -489,15 +502,7 @@ func PlanQuotas(ctx context.Context) []SubscriptionQuota {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
q := SubscriptionQuota{Provider: j.p.ID, Name: j.p.Name, Icon: j.p.Icon, User: j.user, Windows: []QuotaWindow{}}
|
||||
plan, ws, err := planWindows(j.p.Via(ctx), j.src, j.key)
|
||||
team := false
|
||||
if zhipu := strings.HasSuffix(j.src.url, "/api/monitor/usage/quota/limit"); zhipu && (err != nil || len(ws) == 0) {
|
||||
// no plan of the key's own: a team's key, whose windows are
|
||||
// asked with type=2 (zcode_team.go)
|
||||
if tplan, tws, terr := zhipuKeyTeamWindows(j.p.Via(ctx), j.src.url, j.key, j.p.ZhipuTeam); terr == nil && len(tws) > 0 {
|
||||
plan, ws, err, team = tplan, tws, nil, true
|
||||
}
|
||||
}
|
||||
plan, ws, team, err := planKeyWindows(j.p.Via(ctx), j.p, j.src, j.key)
|
||||
// a vendor failing a while (Command Code answers billing/credits
|
||||
// 503 at times) shows what was last read, as a subscription's
|
||||
// card does, rather than no card or "Usage unavailable"
|
||||
@@ -516,6 +521,10 @@ func PlanQuotas(ctx context.Context) []SubscriptionQuota {
|
||||
q.Error = err.Error()
|
||||
default:
|
||||
q.Plan, q.Windows = plan, ws
|
||||
// what routing goes by, read just now (KeyAllowance)
|
||||
k := j.p
|
||||
k.Key = j.key
|
||||
noteKeyAllowance(k, ws, time.Now())
|
||||
if strings.HasSuffix(j.src.url, "/api/monitor/usage/quota/limit") && !team { // Zhipu, Z.ai
|
||||
q.Until, q.Renew = zhipuTerm(ctx, zcodeRoot(j.src.url), j.key)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user