@magpie-community/opencode-workbuddy-auth 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -2
- package/index.mjs +618 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,8 +137,11 @@ Each chat carries WorkBuddy's headers:
|
|
|
137
137
|
- the conversation's ids: `X-Conversation-ID`, `X-Conversation-Request-ID`,
|
|
138
138
|
`X-Conversation-Message-ID`, `X-Request-ID`. WorkBuddy's backend counts a
|
|
139
139
|
user send as one request by `X-Conversation-Request-ID`, so every step of
|
|
140
|
-
one send — a tool result sent back, a retry
|
|
141
|
-
|
|
140
|
+
one send — a tool result sent back, a retry — carries the same one, and
|
|
141
|
+
the user's next message carries another. Both ids are one account's own,
|
|
142
|
+
as WorkBuddy's client is signed in as one: a conversation sent as another
|
|
143
|
+
account (another of a group, or a sign-in saved since) carries that
|
|
144
|
+
account's ids, never the same ones under two `X-User-Id`s. The
|
|
142
145
|
conversation's is the session magpie (or OpenCode) names the request by,
|
|
143
146
|
else the chat's first user message; nothing is made up when neither names
|
|
144
147
|
one, a made-up conversation of the request's own being what splits one
|
|
@@ -286,6 +289,79 @@ credits are read from the enterprise meter
|
|
|
286
289
|
page reads: the cycle's credits used of its limit, and when the cycle
|
|
287
290
|
resets (magpie #1904).
|
|
288
291
|
|
|
292
|
+
## The free model's daily window (WorkBuddy AI)
|
|
293
|
+
|
|
294
|
+
The credits above are not the only limit. `deepseek-v4.1-flash` is served at
|
|
295
|
+
`x0.00` and costs the plan nothing, yet WorkBuddy AI refuses it long before
|
|
296
|
+
160 credits are touched:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
429 usage exceeds frequency limit, but don't worry, your usage will reset at
|
|
300
|
+
2026-10-20 19:47:41 UTC+8, alternatively, you can switch to the other models
|
|
301
|
+
to continue using it.
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
That is a token allowance of its own — per account, per 24 hours, about 1e8
|
|
305
|
+
tokens, counted as prompt + cache-read + completion. Nothing the vendor
|
|
306
|
+
answers with reports it, so the plugin rebuilds the current window from
|
|
307
|
+
magpie's `usage.jsonl` and gives it to the card as **Free model tokens**
|
|
308
|
+
beside **Credits**. `quota alert` and `magpie quota` show it too; before,
|
|
309
|
+
the card read 0% on an account that was already refused.
|
|
310
|
+
|
|
311
|
+
What is measured, and what is easy to read wrongly:
|
|
312
|
+
|
|
313
|
+
- **`cache_read` counts.** Prompt and completion alone are 20× smaller and
|
|
314
|
+
never approach the cap, so a window read that way looks empty while full.
|
|
315
|
+
- **The window is fixed, not sliding.** It runs 24 hours from the first
|
|
316
|
+
request answered after the previous window ended, not from the reset
|
|
317
|
+
instant; a gap between the two means the new window starts later.
|
|
318
|
+
- **`deepseek-v4.1-flash-sg` shares the window.** It is the paid id of the
|
|
319
|
+
same model (`ModelRateLimitCap` in the product config), not a second
|
|
320
|
+
allowance — switching to it continues the same window, at `x0.03`.
|
|
321
|
+
- **1e8 is an estimate within 0.2%.** The largest value seen to still be
|
|
322
|
+
answered is 100,183,315, and the next request is refused, so it cannot be
|
|
323
|
+
probed higher.
|
|
324
|
+
- **A refusal is exact, and the sum is not.** The ledger is this machine's
|
|
325
|
+
only: one window was refused at 92,792,962 because the rest of that
|
|
326
|
+
account's spending happened on another machine, which read as 93%. So a
|
|
327
|
+
429 whose named reset is still ahead makes the window full — `used: 100`,
|
|
328
|
+
`resetsAt` the reset the vendor named — whatever the rows add up to. The
|
|
329
|
+
sum carries the window only until the first refusal.
|
|
330
|
+
- **The card names both ids**, `deepseek-v4.1-flash` and
|
|
331
|
+
`deepseek-v4.1-flash-sg`: magpie matches a window's models by exact id
|
|
332
|
+
(only an `@size` suffix folds into the bare id), so one alone would leave
|
|
333
|
+
the other unrestricted.
|
|
334
|
+
- **The ledger is read in magpie's own folder** (`appdir.Config()`: the
|
|
335
|
+
portable data folder, `$XDG_CONFIG_HOME/magpie` when that is set (an
|
|
336
|
+
absolute path, as `appdir.rooted` has it), or `~/.config/magpie`), from its
|
|
337
|
+
end backwards — a heavy ledger runs to hundreds of MB.
|
|
338
|
+
- The window is a chain, so where it now began depends on every window
|
|
339
|
+
before it: read a slice of the ledger that starts mid-chain and every
|
|
340
|
+
window after it is shifted (4% on an account two hours from its cap, or
|
|
341
|
+
100% on one still served). The read therefore goes back until it reaches
|
|
342
|
+
an edge this account's own rows spell out — a refusal whose reset can be
|
|
343
|
+
read, or a gap of a day between two of its answered rows — plus a day
|
|
344
|
+
below that, and with neither it reads to the file's start, where there is
|
|
345
|
+
no earlier window to be shifted by.
|
|
346
|
+
- Nothing bounds that by age, so two things keep a heavy ledger cheap: a
|
|
347
|
+
line's time is read before the line is decoded, so a line under the edge
|
|
348
|
+
(of any provider or account) ends the read at once; and a read past 24h
|
|
349
|
+
with no answered free-model row of this account stops, since the window
|
|
350
|
+
has lapsed and nothing would be reported. Both bounds leave out a line
|
|
351
|
+
a client reported (`"source":"reported"`, #1874): magpie appends those
|
|
352
|
+
dated where the client's call happened, so a backfilled row says nothing
|
|
353
|
+
about where the file has got to.
|
|
354
|
+
- `providerAccount` is the account's label. Two accounts sharing a label
|
|
355
|
+
get its slot added (`pluginLabels`), the label no longer matches what
|
|
356
|
+
their rows were written under, and the card reports no free window.
|
|
357
|
+
- **The offset comes from the refusal's words** (`UTC+8`, `GMT-5`,
|
|
358
|
+
`UTC+05:30`). A reset whose offset cannot be read is no edge at all,
|
|
359
|
+
rather than +08:00 guessed.
|
|
360
|
+
|
|
361
|
+
A window that has lapsed reports nothing rather than 0%: nothing is
|
|
362
|
+
exhausted, and the next request opens a fresh one. The China build is not
|
|
363
|
+
covered; its limit has not been measured.
|
|
364
|
+
|
|
289
365
|
## When free models work but a credit model does not
|
|
290
366
|
|
|
291
367
|
Check the failing account and model separately before concluding that the
|
package/index.mjs
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
// those of its current desktop app (see SITES' app, cli and appName).
|
|
11
11
|
|
|
12
12
|
import { createHash, randomBytes } from "node:crypto"
|
|
13
|
-
import { readFileSync } from "node:fs"
|
|
13
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs"
|
|
14
14
|
import { homedir } from "node:os"
|
|
15
|
-
import { join } from "node:path"
|
|
15
|
+
import { isAbsolute, join } from "node:path"
|
|
16
16
|
import { gzipSync } from "node:zlib"
|
|
17
17
|
|
|
18
18
|
const APP_VERSION = "2.0.0" // the version WorkBuddy's sign-in page is opened with
|
|
@@ -446,7 +446,7 @@ async function enterpriseOf(site, a, who) {
|
|
|
446
446
|
// itself, and the request that carried it. Its backend counts a user send as
|
|
447
447
|
// one request by X-Conversation-Request-ID: a chat carrying a new one every
|
|
448
448
|
// time is counted on its own, so one send that takes several steps — a tool
|
|
449
|
-
// call answered and sent again, a retry
|
|
449
|
+
// call answered and sent again, a retry — shows up
|
|
450
450
|
// in the usage detail as many requests. The ids below are made from what
|
|
451
451
|
// names the conversation and the turn, so every step of one send carries the
|
|
452
452
|
// same turn id and the user's next message carries another, as WorkBuddy's
|
|
@@ -719,22 +719,64 @@ function sessionOf(headers) {
|
|
|
719
719
|
// turn's (one user send, whatever it takes), and the conversation's. A header
|
|
720
720
|
// already there — the caller's own, or another plugin's — is kept, and the
|
|
721
721
|
// message and request ids are the same one, as WorkBuddy's client has them.
|
|
722
|
-
|
|
723
|
-
|
|
722
|
+
//
|
|
723
|
+
// who is the account the chat goes as (scopeOf). The ids are that account's
|
|
724
|
+
// own: WorkBuddy's client runs as one account, so one conversation or turn
|
|
725
|
+
// never shows up under two. Without it, one magpie session spread over a
|
|
726
|
+
// group of accounts sent the same X-Conversation-ID and
|
|
727
|
+
// X-Conversation-Request-ID under every account's X-User-Id.
|
|
728
|
+
function attend(headers, body, session, who = "") {
|
|
729
|
+
return attendChat(headers, chatOf(body), session, who)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// attendChat is attend with the chat read already. It returns what reattend
|
|
733
|
+
// needs to set the ids again for another account once the chat itself is
|
|
734
|
+
// let go: the ids it set itself (not the caller's), and the digests of what
|
|
735
|
+
// names the turn and the conversation, which are never sent as they are.
|
|
736
|
+
function attendChat(headers, chat, session, who = "") {
|
|
737
|
+
const conv = conversationParts(chat, session)
|
|
738
|
+
const turn = turnParts(chat)
|
|
739
|
+
const ids = {
|
|
740
|
+
set: [],
|
|
741
|
+
// a turn is a conversation's own: two conversations whose user message
|
|
742
|
+
// reads the same are still two sends, not one
|
|
743
|
+
turn: turn.length ? idFor("turn", [...conv, "|", ...turn]) : "",
|
|
744
|
+
conv: conv.length ? idFor("conversation", conv) : "",
|
|
745
|
+
}
|
|
746
|
+
return setIds(headers, ids, who)
|
|
724
747
|
}
|
|
725
748
|
|
|
726
|
-
|
|
727
|
-
|
|
749
|
+
// setIds sets the message's id, and the turn's and conversation's for the
|
|
750
|
+
// account who, where the caller set none.
|
|
751
|
+
function setIds(headers, ids, who) {
|
|
728
752
|
const msg = hex()
|
|
729
753
|
headers.set("X-Conversation-Message-ID", msg)
|
|
730
754
|
headers.set("X-Request-ID", msg)
|
|
755
|
+
const set = []
|
|
756
|
+
const own = (kind, base) => (who ? idFor(kind, [`account:${who}`, "|", base]) : base)
|
|
731
757
|
if (!headers.has("X-Conversation-Request-ID")) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
758
|
+
headers.set("X-Conversation-Request-ID", ids.turn ? own("turn", ids.turn) : hex())
|
|
759
|
+
set.push("X-Conversation-Request-ID")
|
|
760
|
+
}
|
|
761
|
+
if (ids.conv && !headers.has("X-Conversation-ID")) {
|
|
762
|
+
headers.set("X-Conversation-ID", own("conversation", ids.conv))
|
|
763
|
+
set.push("X-Conversation-ID")
|
|
736
764
|
}
|
|
737
|
-
|
|
765
|
+
return { ...ids, set }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// scopeOf names the account a chat goes as for attend: its uid, its
|
|
769
|
+
// enterprise, and for a key account the key (only ever hashed).
|
|
770
|
+
function scopeOf(a) {
|
|
771
|
+
return `${a?.uid ?? ""}|${a?.enterpriseId ?? ""}|${a?.source === "key" ? a.key ?? "" : ""}`
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// reattend sets a chat's ids again for the account it is now sent as, when
|
|
775
|
+
// that is another account than the one they were set for.
|
|
776
|
+
function reattend(headers, ids, was, now) {
|
|
777
|
+
if (was === now) return ids
|
|
778
|
+
for (const h of ids.set) headers.delete(h)
|
|
779
|
+
return setIds(headers, ids, now)
|
|
738
780
|
}
|
|
739
781
|
|
|
740
782
|
// FLAGGED are the words WorkBuddy refuses a chat for, "Illegal API
|
|
@@ -1857,6 +1899,556 @@ function capacity(v) {
|
|
|
1857
1899
|
// compact is a count as magpie says one: whole, else to two places.
|
|
1858
1900
|
const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""))
|
|
1859
1901
|
|
|
1902
|
+
// The window WorkBuddy AI rate limits its free models by, which its credit
|
|
1903
|
+
// meter does not show.
|
|
1904
|
+
//
|
|
1905
|
+
// A free account's usage() reports only the credits window, so a plan with
|
|
1906
|
+
// 160 credits untouched reads 0% while chats to deepseek-v4.1-flash are
|
|
1907
|
+
// already refused: the model has a token allowance of its own, counted
|
|
1908
|
+
// apart from credits, and nothing the vendor answers with reports it. The
|
|
1909
|
+
// refusal is the only place it appears, and only as a time:
|
|
1910
|
+
//
|
|
1911
|
+
// 429 usage exceeds frequency limit, but don't worry, your usage will
|
|
1912
|
+
// reset at 2026-10-20 19:47:41 UTC+8, alternatively, you can switch to
|
|
1913
|
+
// the other models to continue using it.
|
|
1914
|
+
//
|
|
1915
|
+
// Measured on a free plan over ten days (one account, deepseek-v4.1-flash
|
|
1916
|
+
// only), counting in + cache_read + out:
|
|
1917
|
+
//
|
|
1918
|
+
// 10-15 15:40 → 100,183,315 429 10-18 19:52 → 92,792,962 429
|
|
1919
|
+
// 10-16 16:48 → 99,971,831 429 10-19 19:47 → 98,201,537 429
|
|
1920
|
+
// 10-14 15:38 → 71,687,717 lapsed
|
|
1921
|
+
//
|
|
1922
|
+
// so the cap is per account per 24 hours at about 1e8 tokens, the largest
|
|
1923
|
+
// value that still answered being 100,183,315. Three things about it are
|
|
1924
|
+
// easy to get wrong and were all checked:
|
|
1925
|
+
//
|
|
1926
|
+
// - cache_read counts. In + out alone is 20× smaller (0.75M–3.9M a
|
|
1927
|
+
// window) and never comes near the cap.
|
|
1928
|
+
// - the window is fixed, not sliding: peak trailing-24h over the same
|
|
1929
|
+
// data is 172.85M, which a sliding window would have held to 1e8.
|
|
1930
|
+
// - the reset a refusal names is the window's start + 24h exactly, and
|
|
1931
|
+
// the start is the first request answered after the previous window
|
|
1932
|
+
// ended — not the reset instant. Gaps of 6, 68, 80 and 182 minutes
|
|
1933
|
+
// were seen between a reset and the next request, each opening the new
|
|
1934
|
+
// window.
|
|
1935
|
+
//
|
|
1936
|
+
// deepseek-v4.1-flash-sg is the same model under its paid id (the product
|
|
1937
|
+
// config's ModelRateLimitCap.lines[0] names it as paidId to this freeId,
|
|
1938
|
+
// allowPaidSwitch: true) and draws on the same window: it was refused in
|
|
1939
|
+
// the same windows, to the second, with about 0.01M tokens of its own.
|
|
1940
|
+
// Both ids are named in models: magpie matches a plugin window's models
|
|
1941
|
+
// against a model id exactly, folding only an @size suffix
|
|
1942
|
+
// (internal/provider/plugin_usage.go, idSetOf), so one id alone leaves the
|
|
1943
|
+
// other unrestricted.
|
|
1944
|
+
//
|
|
1945
|
+
// The window is read two ways, and a refusal wins:
|
|
1946
|
+
//
|
|
1947
|
+
// - the sum (in + cache_read + out) of this machine's rows, which holds
|
|
1948
|
+
// until the account is first refused. The ledger is this machine's
|
|
1949
|
+
// only, so an account also used on another machine or through another
|
|
1950
|
+
// client reads short: 92.8M read as "93%" on one refused at 92,792,962,
|
|
1951
|
+
// the rest of its spending being elsewhere.
|
|
1952
|
+
// - a refusal whose named reset is still ahead, which makes the window
|
|
1953
|
+
// full whatever the sum says. That is the one exact signal there is,
|
|
1954
|
+
// and routing must not pass over it either.
|
|
1955
|
+
//
|
|
1956
|
+
// The ledger is magpie's own, in magpie's config folder (appdir.Config()):
|
|
1957
|
+
// the folder the plugin host runs in, which is the portable data folder
|
|
1958
|
+
// when magpie runs portable, $XDG_CONFIG_HOME/magpie when that is set, and
|
|
1959
|
+
// ~/.config/magpie otherwise. No endpoint reports the window; a ledger that
|
|
1960
|
+
// cannot be read yields no window rather than a made-up one.
|
|
1961
|
+
|
|
1962
|
+
const FREE_WINDOW_CAP = 1e8
|
|
1963
|
+
const FREE_WINDOW_SPAN = 24 * 3600 * 1000
|
|
1964
|
+
// The lead: how far back of an edge the read goes on. A chain read from a row
|
|
1965
|
+
// that opens a window needs the window the span makes of the rows above it,
|
|
1966
|
+
// so the rows kept reach a day past the edge — the window opening at the
|
|
1967
|
+
// request after it, minutes to hours into that day, whatever the first row
|
|
1968
|
+
// after the edge falls on.
|
|
1969
|
+
const FREE_WINDOW_LEAD = 86400 * 1000
|
|
1970
|
+
// A gap this long between two of this account's answered rows ends every
|
|
1971
|
+
// window before it, so the later row opens one: the window holding the
|
|
1972
|
+
// earlier row began at most 24h before it, and so ended 24h after it at the
|
|
1973
|
+
// latest. This is one of the two edges the ledger itself spells out.
|
|
1974
|
+
const FREE_WINDOW_GAP = FREE_WINDOW_SPAN
|
|
1975
|
+
// the model the window is counted for; -sg shares it
|
|
1976
|
+
const FREE_WINDOW_MODEL = "deepseek-v4.1-flash"
|
|
1977
|
+
// both ids, so magpie's exact match finds the -sg one too
|
|
1978
|
+
const FREE_WINDOW_MODELS = ["deepseek-v4.1-flash", "deepseek-v4.1-flash-sg"]
|
|
1979
|
+
// the provider whose rows count
|
|
1980
|
+
const FREE_WINDOW_PROVIDER = "workbuddy-ai"
|
|
1981
|
+
|
|
1982
|
+
// windowOffset is the UTC offset a refusal spells out, in minutes, from
|
|
1983
|
+
// "UTC+8", "UTC+08:00", "UTC-5" or "GMT+8". null when it spells out none,
|
|
1984
|
+
// which is not the same as +08:00: an offset guessed wrong puts the reset
|
|
1985
|
+
// hours from where it is, so a reset that cannot be placed is not used as
|
|
1986
|
+
// an edge at all.
|
|
1987
|
+
function windowOffset(text) {
|
|
1988
|
+
const m = /\b(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?\b/i.exec(String(text ?? ""))
|
|
1989
|
+
if (!m) return null
|
|
1990
|
+
const min = Number(m[2]) * 60 + Number(m[3] ?? 0)
|
|
1991
|
+
if (min > 14 * 60) return null
|
|
1992
|
+
return m[1] === "-" ? -min : min
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// windowReset is the instant a refusal names, or NaN when it names none or
|
|
1996
|
+
// names it in an offset that cannot be read.
|
|
1997
|
+
function windowReset(text) {
|
|
1998
|
+
const s = String(text ?? "")
|
|
1999
|
+
const at = /reset at (\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s)
|
|
2000
|
+
if (!at) return NaN
|
|
2001
|
+
const off = windowOffset(s)
|
|
2002
|
+
if (off === null) return NaN
|
|
2003
|
+
return Date.UTC(+at[1], +at[2] - 1, +at[3], +at[4], +at[5], +at[6]) - off * 60000
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// freeWindowOf is the window account is in now, from rows (magpie's usage
|
|
2007
|
+
// records), or null when it has lapsed — the next request opens a new one.
|
|
2008
|
+
// now is passed in so the reading is testable.
|
|
2009
|
+
//
|
|
2010
|
+
// The window is a chain: it begins at the first request answered after the
|
|
2011
|
+
// previous one ended, so where this window began depends on every window
|
|
2012
|
+
// before it. A window ends at a refusal's reset when the ledger holds one,
|
|
2013
|
+
// and otherwise a day after it began. readLedgerTail (which see) stops on
|
|
2014
|
+
// both, so that these rows read the same as the whole ledger.
|
|
2015
|
+
function freeWindowOf(rows, account, now = Date.now()) {
|
|
2016
|
+
const mine = (Array.isArray(rows) ? rows : []).filter(
|
|
2017
|
+
(r) =>
|
|
2018
|
+
r?.provider === FREE_WINDOW_PROVIDER &&
|
|
2019
|
+
r.providerAccount === account &&
|
|
2020
|
+
typeof r.model === "string" &&
|
|
2021
|
+
r.model.startsWith(FREE_WINDOW_MODEL) &&
|
|
2022
|
+
r.t,
|
|
2023
|
+
)
|
|
2024
|
+
if (!mine.length) return null
|
|
2025
|
+
|
|
2026
|
+
const at = (r) => {
|
|
2027
|
+
const t = Date.parse(r.t)
|
|
2028
|
+
return Number.isFinite(t) ? t : 0
|
|
2029
|
+
}
|
|
2030
|
+
const counted = (r) => (r.in ?? 0) + (r.cache_read ?? 0) + (r.out ?? 0)
|
|
2031
|
+
// two rows a day apart: the first of them opened a window that has ended by
|
|
2032
|
+
// the second. Written as a function so that no minifier folds the
|
|
2033
|
+
// subtraction into the comparison.
|
|
2034
|
+
const apart = (a, b) => a - b >= FREE_WINDOW_SPAN
|
|
2035
|
+
|
|
2036
|
+
// a refusal names the end of the window it refused in
|
|
2037
|
+
const edges = []
|
|
2038
|
+
for (const r of mine) {
|
|
2039
|
+
if (Number(r.status) !== 429) continue
|
|
2040
|
+
const t = windowReset(r.err)
|
|
2041
|
+
if (Number.isFinite(t)) edges.push(t)
|
|
2042
|
+
}
|
|
2043
|
+
edges.sort((a, b) => a - b)
|
|
2044
|
+
|
|
2045
|
+
const answered = mine.filter((r) => Number(r.status) === 200 && at(r) > 0).sort((a, b) => at(a) - at(b))
|
|
2046
|
+
if (!answered.length) return null
|
|
2047
|
+
|
|
2048
|
+
const head = (from) => {
|
|
2049
|
+
let start = null
|
|
2050
|
+
let used = 0
|
|
2051
|
+
let edge = 0 // the refusals already read
|
|
2052
|
+
for (let i = from; i < answered.length; i++) {
|
|
2053
|
+
const r = answered[i]
|
|
2054
|
+
const t = at(r)
|
|
2055
|
+
// every refusal this row's window ended at closes it
|
|
2056
|
+
while (edge < edges.length && edges[edge] <= t) {
|
|
2057
|
+
start = null
|
|
2058
|
+
used = 0
|
|
2059
|
+
edge++
|
|
2060
|
+
}
|
|
2061
|
+
if (start === null) start = t
|
|
2062
|
+
else if (apart(t, start)) {
|
|
2063
|
+
start = t
|
|
2064
|
+
used = 0
|
|
2065
|
+
}
|
|
2066
|
+
used += counted(r)
|
|
2067
|
+
}
|
|
2068
|
+
return start === null ? null : { start, used }
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// The rows may begin before the window now: readLedgerTail goes a day past
|
|
2072
|
+
// the edge it stops at, so the run of rows that opens a window can start a
|
|
2073
|
+
// day after the first row kept. That window opens at a row with no answered
|
|
2074
|
+
// row of this account's within a day before it — where the chain is read
|
|
2075
|
+
// from. Read from the first row kept, the chain would begin shifted, a day
|
|
2076
|
+
// early: every window after it, the card with it.
|
|
2077
|
+
let w = null
|
|
2078
|
+
for (let i = 0; i < answered.length; i++) {
|
|
2079
|
+
if (i && !apart(at(answered[i]), at(answered[i - 1]))) continue
|
|
2080
|
+
const h = head(i)
|
|
2081
|
+
if (h === null) continue
|
|
2082
|
+
if (h.start + FREE_WINDOW_SPAN <= now) continue // lapsed before now
|
|
2083
|
+
w = h
|
|
2084
|
+
break
|
|
2085
|
+
}
|
|
2086
|
+
const start = w?.start
|
|
2087
|
+
const used = w?.used
|
|
2088
|
+
if (!(start > 0)) return null
|
|
2089
|
+
|
|
2090
|
+
const end = start + FREE_WINDOW_SPAN
|
|
2091
|
+
if (now >= end) return null // lapsed: nothing is exhausted, a new window opens on the next request
|
|
2092
|
+
|
|
2093
|
+
const card = (amount, resetsAt, refused) => ({
|
|
2094
|
+
name: "Free model tokens",
|
|
2095
|
+
used: Math.min(100, (100 * amount) / FREE_WINDOW_CAP),
|
|
2096
|
+
amount,
|
|
2097
|
+
limit: FREE_WINDOW_CAP,
|
|
2098
|
+
unit: "tokens",
|
|
2099
|
+
display: `${tokensShort(amount)} / 100M`,
|
|
2100
|
+
resetsAt: new Date(resetsAt).toISOString(),
|
|
2101
|
+
span: FREE_WINDOW_SPAN / 1000,
|
|
2102
|
+
models: FREE_WINDOW_MODELS.slice(),
|
|
2103
|
+
...(refused ? { refused: true } : {}),
|
|
2104
|
+
})
|
|
2105
|
+
|
|
2106
|
+
// A refusal still in force is exact: the account is spending this window
|
|
2107
|
+
// from elsewhere too, so the sum undercounts it, and the refusal is the
|
|
2108
|
+
// answer the vendor would give again. Its reset is the window's end,
|
|
2109
|
+
// named by the vendor rather than derived from start.
|
|
2110
|
+
let live = null
|
|
2111
|
+
for (const r of mine) {
|
|
2112
|
+
if (Number(r.status) !== 429) continue
|
|
2113
|
+
const t = windowReset(r.err)
|
|
2114
|
+
if (!Number.isFinite(t) || t <= now || t <= start) continue
|
|
2115
|
+
if (live === null || t > live) live = t
|
|
2116
|
+
}
|
|
2117
|
+
if (live !== null) return card(FREE_WINDOW_CAP, live, true)
|
|
2118
|
+
|
|
2119
|
+
return card(used, end, false)
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// tokensShort says a token count small: 11.9M, 812k. The credits card's
|
|
2123
|
+
// compact() (2 decimals, credited to credits) reads a token count badly.
|
|
2124
|
+
function tokensShort(n) {
|
|
2125
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
|
|
2126
|
+
if (n >= 1e3) return `${Math.round(n / 1e3)}k`
|
|
2127
|
+
return String(Math.round(n))
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// ledgerParsed is the rows read last and what they were read for: the file's
|
|
2131
|
+
// fingerprint, the account and the instant. A file that has not changed hands
|
|
2132
|
+
// back the rows already parsed rather than reading and decoding them again.
|
|
2133
|
+
//
|
|
2134
|
+
// Reading the ledger whole was 48 ms on 2026-10-21 (10.3 MiB, 17925 lines:
|
|
2135
|
+
// 6.4 ms to read, 32.8 ms to JSON.parse, 8 ms to find the window), spent
|
|
2136
|
+
// synchronously on the plugin host's thread every time a panel opened or a
|
|
2137
|
+
// card was asked about, which is what made the free-token window feel slow
|
|
2138
|
+
// beside the credits, whose reading is a network call and so blocks nothing.
|
|
2139
|
+
// A heavy ledger runs to hundreds of MiB, so the read starts at the end.
|
|
2140
|
+
//
|
|
2141
|
+
// The key carries the account because the rows are this account's: one
|
|
2142
|
+
// account's read must not answer for another's. A read for an earlier instant
|
|
2143
|
+
// keeps all the rows a read for a later one would — the bounds are only
|
|
2144
|
+
// further back, and an edge is the same time in either — so the rows held
|
|
2145
|
+
// over answer for the later one too: freeWindowOf is what decides with its
|
|
2146
|
+
// own now.
|
|
2147
|
+
let ledgerParsed = null
|
|
2148
|
+
|
|
2149
|
+
// ledgerBad is the number of whole lines the ledger last had that could
|
|
2150
|
+
// not be decoded, and the file it was read from, so that a ledger read
|
|
2151
|
+
// once and read again is complained about once, not on every panel open.
|
|
2152
|
+
let ledgerBad = { key: "", count: 0 }
|
|
2153
|
+
|
|
2154
|
+
// freeWindowLedger is the ledgers looked for, in magpie's own folder order
|
|
2155
|
+
// (appdir.Config()): the plugin host runs in that folder — the portable
|
|
2156
|
+
// data folder when magpie runs portable, $XDG_CONFIG_HOME/magpie when that
|
|
2157
|
+
// is set, ~/.config/magpie otherwise — so its cwd answers first; the other
|
|
2158
|
+
// two are named as well, for a plugin asked outside the host. The first
|
|
2159
|
+
// readable ledger wins: a stale copy in a folder magpie does not use must
|
|
2160
|
+
// not hide the live one.
|
|
2161
|
+
//
|
|
2162
|
+
// $XDG_CONFIG_HOME counts only when it is absolute, as appdir.rooted has it:
|
|
2163
|
+
// an empty or relative value is not where magpie keeps its ledger.
|
|
2164
|
+
function freeWindowLedger(cwd = process.cwd(), env = process.env, home = homedir()) {
|
|
2165
|
+
const dirs = [cwd]
|
|
2166
|
+
if (env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) dirs.push(join(env.XDG_CONFIG_HOME, "magpie"))
|
|
2167
|
+
if (home) dirs.push(join(home, ".config", "magpie"))
|
|
2168
|
+
return dirs.map((d) => join(d, "usage.jsonl"))
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// freeWindowRows reads magpie's usage ledger for account, oldest row first.
|
|
2172
|
+
// null when no ledger can be read: the window is then left unknown rather
|
|
2173
|
+
// than guessed.
|
|
2174
|
+
//
|
|
2175
|
+
// The ledger is appended to a line at a time, so a read that lands while a
|
|
2176
|
+
// call is being written can catch that line half-written. Only the last
|
|
2177
|
+
// line can be torn that way (everything before it was written whole), and
|
|
2178
|
+
// only it is passed over in silence. A whole line in the middle that won't
|
|
2179
|
+
// decode is a real fault — a truncated file, a writer that lost a record —
|
|
2180
|
+
// and is said aloud, because passing it over quietly is how a window goes
|
|
2181
|
+
// missing with nothing to point at: on 2026-10-21 a restart caught the
|
|
2182
|
+
// ledger mid-write, the one row holding the free window's start was
|
|
2183
|
+
// dropped without a word, and the panel showed no window at all until the
|
|
2184
|
+
// next read happened to succeed.
|
|
2185
|
+
function freeWindowRows(account, now = Date.now(), cwd = process.cwd(), env = process.env, home = homedir()) {
|
|
2186
|
+
if (!account) return null
|
|
2187
|
+
let found = null
|
|
2188
|
+
for (const path of freeWindowLedger(cwd, env, home)) {
|
|
2189
|
+
try {
|
|
2190
|
+
const st = statSync(path)
|
|
2191
|
+
if (!st.size) continue
|
|
2192
|
+
found = { path, st }
|
|
2193
|
+
break
|
|
2194
|
+
} catch {}
|
|
2195
|
+
}
|
|
2196
|
+
if (!found) return null
|
|
2197
|
+
const { path, st } = found
|
|
2198
|
+
// size and mtime both move when a call is appended, which is the only
|
|
2199
|
+
// way this ledger is written; one rewritten in place (a rotation or a
|
|
2200
|
+
// compaction) moves mtime even when its size lands the same.
|
|
2201
|
+
const file = path + "|" + st.mtimeMs + ":" + st.size
|
|
2202
|
+
const key = file + "|" + account
|
|
2203
|
+
if (ledgerParsed?.key === key) return ledgerParsed.rows
|
|
2204
|
+
let got
|
|
2205
|
+
try {
|
|
2206
|
+
got = readLedgerTail(path, st, now, undefined, account)
|
|
2207
|
+
} catch {
|
|
2208
|
+
return null
|
|
2209
|
+
}
|
|
2210
|
+
if (got.bad && ledgerBad.key !== file) {
|
|
2211
|
+
ledgerBad = { key: file, count: got.bad }
|
|
2212
|
+
console.error(
|
|
2213
|
+
`workbuddy: ${got.bad} whole line${got.bad === 1 ? "" : "s"} of ${path} could not be decoded ` +
|
|
2214
|
+
`(not the last line, so not a write in progress); the free-token window may read short`,
|
|
2215
|
+
)
|
|
2216
|
+
}
|
|
2217
|
+
ledgerParsed = { key, rows: got.rows }
|
|
2218
|
+
return got.rows
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// LEDGER_T is where a row's time sits: "t" is among the first keys
|
|
2222
|
+
// usage.Record writes, within this many bytes of the line's start, so every
|
|
2223
|
+
// line's time is read without decoding the line — most of a ledger's lines
|
|
2224
|
+
// belong to another provider, or to this account and out of reach.
|
|
2225
|
+
const LEDGER_T = /"t"\s*:\s*"([^"]{10,64})"/
|
|
2226
|
+
|
|
2227
|
+
// LEDGER_ACCOUNT finds the account a line was written under, before the line
|
|
2228
|
+
// is decoded. usage.Record writes providerAccount second.
|
|
2229
|
+
const LEDGER_ACCOUNT = /"providerAccount"\s*:\s*"([^"]{0,256})"/
|
|
2230
|
+
|
|
2231
|
+
// rowTime is the instant a line is written at, from that "t", or NaN.
|
|
2232
|
+
function rowTime(line) {
|
|
2233
|
+
const m = LEDGER_T.exec(line)
|
|
2234
|
+
return m ? Date.parse(m[1]) : NaN
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// LEDGER_REPORTED finds what a client reported: POST /v1/magpie/usage/requests
|
|
2238
|
+
// (#1874) appends a client's own calls to the same ledger, with the time the
|
|
2239
|
+
// client gives them, and a past time is kept on purpose so history can be
|
|
2240
|
+
// backfilled (docs/integrating.md). Such a row is written at the end of the
|
|
2241
|
+
// file and dated anywhere, so it says nothing about where the file's own
|
|
2242
|
+
// records have got to — magpie's index leaves it out of firstOwn for the same
|
|
2243
|
+
// reason (log_index.go). It never carries providerAccount, being not in
|
|
2244
|
+
// ingestFields, so it is never a row of this account either.
|
|
2245
|
+
const LEDGER_REPORTED = /"source"\s*:\s*"reported"/
|
|
2246
|
+
|
|
2247
|
+
// rowReported is whether a line is one a client reported rather than one
|
|
2248
|
+
// magpie wrote when it happened.
|
|
2249
|
+
function rowReported(line) {
|
|
2250
|
+
return LEDGER_REPORTED.test(line)
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// rowAccount is the account a line was written under, or null when the line
|
|
2254
|
+
// names none (another record, or one cut short).
|
|
2255
|
+
function rowAccount(line) {
|
|
2256
|
+
const m = LEDGER_ACCOUNT.exec(line)
|
|
2257
|
+
if (!m) return null
|
|
2258
|
+
try {
|
|
2259
|
+
return JSON.parse(`"${m[1]}"`)
|
|
2260
|
+
} catch {
|
|
2261
|
+
return null
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// ofAccount is whether a row belongs to the chain read for account: this
|
|
2266
|
+
// account's own row of the free model, either id. A refusal of another
|
|
2267
|
+
// account's, or of a paid model, says nothing about where this chain opens.
|
|
2268
|
+
function ofAccount(row, account) {
|
|
2269
|
+
return (
|
|
2270
|
+
row?.provider === FREE_WINDOW_PROVIDER &&
|
|
2271
|
+
row.providerAccount === account &&
|
|
2272
|
+
typeof row.model === "string" &&
|
|
2273
|
+
row.model.startsWith(FREE_WINDOW_MODEL)
|
|
2274
|
+
)
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// readLedgerTail decodes the ledger from its end backwards, and stops where
|
|
2278
|
+
// the chain of account's own rows can still be read from. chunks is a test's
|
|
2279
|
+
// way of counting reads; nothing else passes it.
|
|
2280
|
+
//
|
|
2281
|
+
// Why it cannot stop at 48h: the window is a chain — each one starts at the
|
|
2282
|
+
// first request answered after the previous one ended — so where the window
|
|
2283
|
+
// now began depends on every window before it. A first row that is not an
|
|
2284
|
+
// edge opens the window in the middle of a real one and every window after
|
|
2285
|
+
// it is shifted: 4% on an account two hours from its cap, or 100% on one
|
|
2286
|
+
// still served, which routing would then pass over. So the read goes back
|
|
2287
|
+
// until the rows it keeps read the window now as the whole ledger does:
|
|
2288
|
+
//
|
|
2289
|
+
// - a refusal of this account's, whose reset can be read: the window it
|
|
2290
|
+
// named ended there, and the next request opened another;
|
|
2291
|
+
// - a gap of 24h or more between two of this account's answered rows: the
|
|
2292
|
+
// window holding the earlier one has ended by then, and the later one
|
|
2293
|
+
// opens one;
|
|
2294
|
+
// - and with neither, it reads to the file's start, where the chain has no
|
|
2295
|
+
// earlier window to be shifted by.
|
|
2296
|
+
//
|
|
2297
|
+
// Each edge is passed by a day (FREE_WINDOW_LEAD), the window opening at the
|
|
2298
|
+
// request after it, minutes to hours into the day.
|
|
2299
|
+
//
|
|
2300
|
+
// Two bounds keep that from being a whole file on every poll, and neither
|
|
2301
|
+
// depends on how busy the account is:
|
|
2302
|
+
//
|
|
2303
|
+
// - the ledger is appended in time order, so a line whose time is under the
|
|
2304
|
+
// edge — of whatever provider, of whatever account — ends the read;
|
|
2305
|
+
// - a read that is past now - 24h with no answered free-model row of this
|
|
2306
|
+
// account has nothing to report: the window has lapsed, and freeWindowOf
|
|
2307
|
+
// says null. It stops there rather than going on to the edges.
|
|
2308
|
+
//
|
|
2309
|
+
// Every line's time is read from its "t" before the line is decoded, so a
|
|
2310
|
+
// line of another provider or account costs one match and no JSON.parse.
|
|
2311
|
+
//
|
|
2312
|
+
// What is not covered: magpie's providerAccount is the account's label, and a
|
|
2313
|
+
// label two accounts share gets its slot added to it (pluginLabels,
|
|
2314
|
+
// plugin_accounts.go). The label then no longer equals the accountId rows are
|
|
2315
|
+
// written under, no row is found for this account at all, and the card
|
|
2316
|
+
// reports no free window.
|
|
2317
|
+
function readLedgerTail(path, st, now, chunks, account) {
|
|
2318
|
+
const CHUNK = 1 << 16
|
|
2319
|
+
const lapsed = now - FREE_WINDOW_SPAN // a read past this holds no window now
|
|
2320
|
+
const fd = openSync(path, "r")
|
|
2321
|
+
try {
|
|
2322
|
+
// a ledger whose last byte ends a line has no line being written
|
|
2323
|
+
let tornEnd = false
|
|
2324
|
+
if (st.size > 0) {
|
|
2325
|
+
const one = Buffer.allocUnsafe(1)
|
|
2326
|
+
readSync(fd, one, 0, 1, st.size - 1)
|
|
2327
|
+
tornEnd = one[0] !== 0x0a
|
|
2328
|
+
}
|
|
2329
|
+
const rev = [] // the rows kept, latest first
|
|
2330
|
+
let bad = 0
|
|
2331
|
+
let pos = st.size
|
|
2332
|
+
let carry = ""
|
|
2333
|
+
let firstPass = true
|
|
2334
|
+
let stop = false
|
|
2335
|
+
let chain = [] // this account's own rows read, latest first
|
|
2336
|
+
let reach = 0 // the row below which nothing is kept: 0 until an edge is found
|
|
2337
|
+
let answered = false // whether an answered free-model row of it is read
|
|
2338
|
+
|
|
2339
|
+
while (pos > 0 && !stop) {
|
|
2340
|
+
const len = Math.min(CHUNK, pos)
|
|
2341
|
+
pos -= len
|
|
2342
|
+
const buf = Buffer.allocUnsafe(len)
|
|
2343
|
+
readSync(fd, buf, 0, len, pos)
|
|
2344
|
+
if (chunks) chunks(1)
|
|
2345
|
+
const lines = (buf.toString("utf8") + carry).split("\n")
|
|
2346
|
+
carry = lines.shift() // its first line is begun in an earlier chunk
|
|
2347
|
+
for (let i = lines.length - 1; i >= 0 && !stop; i--) {
|
|
2348
|
+
const line = lines[i]
|
|
2349
|
+
if (!line.trim()) continue
|
|
2350
|
+
const t = rowTime(line)
|
|
2351
|
+
// Under an edge or the bound, by time alone: nothing below it can be
|
|
2352
|
+
// kept, whatever wrote it — but a line a client reported is dated
|
|
2353
|
+
// where the client's call happened, not where the file has got to.
|
|
2354
|
+
const dated = Number.isFinite(t) && !rowReported(line)
|
|
2355
|
+
if (dated && reach && t < reach) {
|
|
2356
|
+
stop = true
|
|
2357
|
+
break
|
|
2358
|
+
}
|
|
2359
|
+
// A day back with nothing of this account's answered and no edge in
|
|
2360
|
+
// reach: no window is being spent now, and nothing below can open one
|
|
2361
|
+
if (dated && !reach && !answered && t < lapsed) {
|
|
2362
|
+
stop = true
|
|
2363
|
+
break
|
|
2364
|
+
}
|
|
2365
|
+
// Another provider's or another account's line: nothing else to see
|
|
2366
|
+
if (!line.includes(account) || rowAccount(line) !== account) continue
|
|
2367
|
+
let row
|
|
2368
|
+
try {
|
|
2369
|
+
row = JSON.parse(line)
|
|
2370
|
+
} catch {
|
|
2371
|
+
// the last line may be a call still being written, and is passed
|
|
2372
|
+
// over; anything before it is a fault worth saying
|
|
2373
|
+
if (!(firstPass && tornEnd && i === lines.length - 1)) bad++
|
|
2374
|
+
continue
|
|
2375
|
+
}
|
|
2376
|
+
if (!ofAccount(row, account)) continue
|
|
2377
|
+
rev.push(row)
|
|
2378
|
+
chain.push(row)
|
|
2379
|
+
if (Number(row.status) === 200) answered = true
|
|
2380
|
+
if (!reach && Number(row.status) !== 429) reach = pinned(chain, account)
|
|
2381
|
+
}
|
|
2382
|
+
firstPass = false
|
|
2383
|
+
}
|
|
2384
|
+
// the file's first line, with no newline before it: kept only when the
|
|
2385
|
+
// read ran to the file's start
|
|
2386
|
+
if (!stop && pos === 0 && carry.trim() && carry.includes(account) && rowAccount(carry) === account) {
|
|
2387
|
+
const t = rowTime(carry)
|
|
2388
|
+
if (!reach || !Number.isFinite(t) || t >= reach) {
|
|
2389
|
+
try {
|
|
2390
|
+
const row = JSON.parse(carry)
|
|
2391
|
+
if (ofAccount(row, account)) rev.push(row)
|
|
2392
|
+
} catch {
|
|
2393
|
+
bad++
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return { rows: rev.reverse(), bad }
|
|
2398
|
+
} finally {
|
|
2399
|
+
closeSync(fd)
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// pinned is where the chain of account's own rows (latest first) is read
|
|
2404
|
+
// from: the time below which nothing is kept. 0 while the rows say nothing
|
|
2405
|
+
// about it yet, and the read then goes to the file's start.
|
|
2406
|
+
//
|
|
2407
|
+
// A row whose time cannot be read is passed over rather than ending the
|
|
2408
|
+
// chain there: one corrupt line used to cut it wherever it sat.
|
|
2409
|
+
function pinned(rows, account) {
|
|
2410
|
+
const at = (r) => Date.parse(r?.t)
|
|
2411
|
+
const apart = (a, b) => a - b >= FREE_WINDOW_GAP
|
|
2412
|
+
// A refusal names where a window ended, and the next request opened
|
|
2413
|
+
// another. The loop walks oldest to newest and overwrites `denied`, so the
|
|
2414
|
+
// one kept is the *newest* readable refusal in the chain, not the earliest:
|
|
2415
|
+
// a later refusal is nearer to now, and its reset is the tighter bound on
|
|
2416
|
+
// where the chain can be read from. A refusal whose reset has already been
|
|
2417
|
+
// passed by a newer one therefore loses, which is what we want — the old
|
|
2418
|
+
// `reset < newest` test that used to guard this is gone, the overwrite does
|
|
2419
|
+
// the same job and keeps the latest.
|
|
2420
|
+
let denied = null
|
|
2421
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
2422
|
+
const reset = Number(rows[i]?.status) === 429 ? windowReset(rows[i].err) : NaN
|
|
2423
|
+
if (Number.isFinite(reset)) denied = reset
|
|
2424
|
+
}
|
|
2425
|
+
if (denied !== null) return denied - FREE_WINDOW_LEAD
|
|
2426
|
+
// A day's gap between two answered rows ended every window before it, and
|
|
2427
|
+
// the day before the later row is where the span's reading of the rows
|
|
2428
|
+
// above it begins. The rows come latest first, so each is compared with the
|
|
2429
|
+
// one read before it — the row after it in time, `later`.
|
|
2430
|
+
let later = null
|
|
2431
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
2432
|
+
const r = rows[i]
|
|
2433
|
+
const t = at(r)
|
|
2434
|
+
if (!Number.isFinite(t)) continue
|
|
2435
|
+
if (Number(r.status) !== 200) continue
|
|
2436
|
+
if (later === null) {
|
|
2437
|
+
later = t
|
|
2438
|
+
continue
|
|
2439
|
+
}
|
|
2440
|
+
if (apart(t, later)) return later - FREE_WINDOW_LEAD
|
|
2441
|
+
later = t
|
|
2442
|
+
}
|
|
2443
|
+
// no edge among these rows: the read goes on to the file's start, which is
|
|
2444
|
+
// the whole ledger — the only place a chain needs nothing before it
|
|
2445
|
+
return 0
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// _internal is where these are handed to a test: magpie's host calls every
|
|
2449
|
+
// exported function of a plugin as one of its own factories (host.js,
|
|
2450
|
+
// servers()), so freeWindowRows et al. must not be exports of the plugin.
|
|
2451
|
+
|
|
1860
2452
|
// usageOf is the account's credits, from its resource summary: the plan's
|
|
1861
2453
|
// credits used against what the cycle grants, as magpie's built-in said —
|
|
1862
2454
|
// the count itself too (amount of limit, in credits), for magpie to say it
|
|
@@ -2510,7 +3102,8 @@ function makePlugin(site) {
|
|
|
2510
3102
|
const completion = !native && isCompletion(req ? req.url : input, chat)
|
|
2511
3103
|
if (completion && chat && !namesEffort(chat) && withDefaultEffort(chat, await effortList(a))) changed = true
|
|
2512
3104
|
chatHeaders(site, headers)
|
|
2513
|
-
|
|
3105
|
+
let who = scopeOf(a)
|
|
3106
|
+
let ids = attendChat(headers, chat, session, who)
|
|
2514
3107
|
const post = (b) => {
|
|
2515
3108
|
// a chat goes gzipped, as WorkBuddy's client sends it
|
|
2516
3109
|
// (gzipped); its size on the wire is what unanswered weighs
|
|
@@ -2557,6 +3150,7 @@ function makePlugin(site) {
|
|
|
2557
3150
|
renewed = true
|
|
2558
3151
|
a = r.a
|
|
2559
3152
|
sign(site, a, headers)
|
|
3153
|
+
ids = reattend(headers, ids, who, (who = scopeOf(a)))
|
|
2560
3154
|
const again = await post(b)
|
|
2561
3155
|
if (again.status !== 401) return again
|
|
2562
3156
|
return out("it refused the token renewed for it too", invalidToken(again), again)
|
|
@@ -2674,6 +3268,16 @@ function makePlugin(site) {
|
|
|
2674
3268
|
} catch {}
|
|
2675
3269
|
}
|
|
2676
3270
|
await spareFree(site, a, out.windows)
|
|
3271
|
+
// the free model's own window, which the credit meter above does
|
|
3272
|
+
// not show: what actually refuses a chat long before 160 credits
|
|
3273
|
+
// are spent. WorkBuddy AI alone; the China build has no such cap
|
|
3274
|
+
// measured (see freeWindowOf)
|
|
3275
|
+
if (site.ai) {
|
|
3276
|
+
const account = a.accountId || a.user || a.uid || ""
|
|
3277
|
+
const rows = freeWindowRows(account)
|
|
3278
|
+
const w = rows && freeWindowOf(rows, account)
|
|
3279
|
+
if (w) out.windows.push(w)
|
|
3280
|
+
}
|
|
2677
3281
|
return { ...out, signIn: said }
|
|
2678
3282
|
} catch (e) {
|
|
2679
3283
|
return { error: e?.message ?? String(e), signIn: "kept" }
|
|
@@ -2805,4 +3409,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
|
|
|
2805
3409
|
export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
|
|
2806
3410
|
|
|
2807
3411
|
// for tests
|
|
2808
|
-
export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
|
|
3412
|
+
export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, freeWindowOf, tokensShort, freeWindowRows, freeWindowLedger, readLedgerTail, pinned, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpie-community/opencode-workbuddy-auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.48",
|
|
4
4
|
"description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|