@magpie-community/opencode-qoder-auth 0.1.0 → 0.1.1
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/index.mjs +142 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -727,6 +727,116 @@ async function bodyText(input, init) {
|
|
|
727
727
|
return typeof b === "string" ? b : new TextDecoder().decode(b)
|
|
728
728
|
}
|
|
729
729
|
|
|
730
|
+
// ---- usage --------------------------------------------------------------------
|
|
731
|
+
//
|
|
732
|
+
// The account's allowance as magpie's built-in Qoder account shows it
|
|
733
|
+
// (internal/provider/qoder_usage.go): the account pages' usage, asked with
|
|
734
|
+
// the device token (not the job token the chat runs on), which is rotated
|
|
735
|
+
// with its own refresh token once Qoder refuses it.
|
|
736
|
+
|
|
737
|
+
class UsageStatus extends Error {
|
|
738
|
+
constructor(status) {
|
|
739
|
+
super(`qoder usage: upstream HTTP ${status}`)
|
|
740
|
+
this.status = status
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// fetchUsage is the usage envelope, {displayMode, qoderUsage}.
|
|
745
|
+
async function fetchUsage(deviceToken) {
|
|
746
|
+
if (!String(deviceToken ?? "").trim()) throw new Error("qoder usage: missing device token")
|
|
747
|
+
let res
|
|
748
|
+
try {
|
|
749
|
+
res = await fetch(OPENAPI + "/sash/api/v2/me/usage", {
|
|
750
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
|
|
751
|
+
signal: AbortSignal.timeout(20_000),
|
|
752
|
+
})
|
|
753
|
+
} catch (e) {
|
|
754
|
+
throw new Error(`qoder usage: request failed: ${e?.message ?? e}`)
|
|
755
|
+
}
|
|
756
|
+
if (res.status !== 200) throw new UsageStatus(res.status)
|
|
757
|
+
let env
|
|
758
|
+
try {
|
|
759
|
+
env = JSON.parse(await res.text())
|
|
760
|
+
} catch (e) {
|
|
761
|
+
throw new Error(`qoder usage: decode response: ${e?.message ?? e}`)
|
|
762
|
+
}
|
|
763
|
+
if (env?.displayMode !== "qoder" && env?.displayMode !== "enterprise") throw new Error("qoder usage: unknown display mode")
|
|
764
|
+
if (env.displayMode === "qoder" && (env.qoderUsage === undefined || env.qoderUsage === null)) throw new Error("qoder usage: missing quota data")
|
|
765
|
+
return env
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// refreshDevice trades the device refresh token for a new pair; it rotates
|
|
769
|
+
// too, so the caller saves it.
|
|
770
|
+
async function refreshDevice(refresh) {
|
|
771
|
+
if (!String(refresh ?? "").trim()) throw new Error("qoder device token refresh: missing refresh token; sign in again")
|
|
772
|
+
let r
|
|
773
|
+
try {
|
|
774
|
+
r = await openapi("/api/v1/deviceToken/refresh", { method: "POST", body: { refresh_token: refresh } })
|
|
775
|
+
} catch (e) {
|
|
776
|
+
throw new Error(`qoder device token refresh: request failed: ${e?.message ?? e}`)
|
|
777
|
+
}
|
|
778
|
+
if (r.status !== 200) {
|
|
779
|
+
const err = `qoder device token refresh: upstream HTTP ${r.status}`
|
|
780
|
+
// the device token serves only the account pages (usage); chat runs on
|
|
781
|
+
// the job token, so a refused one doesn't lapse the account
|
|
782
|
+
if (r.status === 401 || r.status === 403)
|
|
783
|
+
throw new Error(`Qoder usage is unavailable: Qoder refused the account-page sign-in (chat still works) — sign in again to see usage (${err})`)
|
|
784
|
+
throw new Error(err)
|
|
785
|
+
}
|
|
786
|
+
const token = r.json?.token || r.json?.device_token || ""
|
|
787
|
+
if (!String(token).trim() || !String(r.json?.refresh_token ?? "").trim()) throw new Error("qoder device token refresh: incomplete token pair")
|
|
788
|
+
return { token, refresh: r.json.refresh_token }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// gfmt is a number as Go's %g writes it.
|
|
792
|
+
function gfmt(n) {
|
|
793
|
+
const [m, e] = n.toExponential().split("e")
|
|
794
|
+
const x = Number(e)
|
|
795
|
+
if (x < -4 || x >= 6) return `${m}e${x < 0 ? "-" : "+"}${String(Math.abs(x)).padStart(2, "0")}`
|
|
796
|
+
return String(n)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const RFC3339 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/i
|
|
800
|
+
|
|
801
|
+
// when is a time Qoder gives: an RFC 3339 string, else seconds or
|
|
802
|
+
// milliseconds, as a number or its text.
|
|
803
|
+
function when(v) {
|
|
804
|
+
if (typeof v === "string" && RFC3339.test(v) && !isNaN(Date.parse(v))) return new Date(v).toISOString()
|
|
805
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v.trim()) : NaN
|
|
806
|
+
if (!isFinite(n) || n <= 0) return undefined
|
|
807
|
+
return new Date(Math.trunc(n < 1e12 ? n * 1000 : n)).toISOString()
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// parseUsage is the plan and each pool of credits the envelope tells.
|
|
811
|
+
function parseUsage(env) {
|
|
812
|
+
if (env.displayMode === "enterprise") return { plan: "Enterprise" }
|
|
813
|
+
const u = env.qoderUsage
|
|
814
|
+
if (env.displayMode !== "qoder" || !u || typeof u !== "object" || Array.isArray(u)) return { error: "Qoder: missing quota data" }
|
|
815
|
+
const field = (a, b) => (u[a] !== undefined ? u[a] : u[b])
|
|
816
|
+
const out = { windows: [] }
|
|
817
|
+
const plan = field("userType", "user_type")
|
|
818
|
+
if (typeof plan === "string") out.plan = plan
|
|
819
|
+
const until = when(field("expiresAt", "expires_at"))
|
|
820
|
+
if (until) out.until = until
|
|
821
|
+
const num = (v) => v === undefined || v === null || typeof v === "number"
|
|
822
|
+
const str = (v) => v === undefined || v === null || typeof v === "string"
|
|
823
|
+
const add = (b, name) => {
|
|
824
|
+
if (b === null || typeof b !== "object" || Array.isArray(b)) return
|
|
825
|
+
if (![b.total, b.cap, b.used, b.remaining].every(num) || !str(b.name) || !str(b.unit)) return
|
|
826
|
+
const total = b.total ?? b.cap
|
|
827
|
+
if (total == null || total <= 0 || (b.used == null && b.remaining == null)) return
|
|
828
|
+
const used = b.used != null ? b.used : total - b.remaining
|
|
829
|
+
if (used < 0) return
|
|
830
|
+
out.windows.push({ name: b.name || name, used: Math.min(100, (100 * used) / total), display: `${gfmt(used)} / ${gfmt(total)} ${b.unit || "credits"}` })
|
|
831
|
+
}
|
|
832
|
+
add(field("userQuota", "user_quota"), "Credits")
|
|
833
|
+
add(field("addOnQuota", "add_on_quota"), "Add-on credits")
|
|
834
|
+
add(field("orgResourcePackage", "org_resource_package"), "Shared credits")
|
|
835
|
+
const dedicated = field("dedicatedResourcePackages", "dedicated_resource_packages")
|
|
836
|
+
for (const b of Array.isArray(dedicated) ? dedicated : []) add(b, "Dedicated credits")
|
|
837
|
+
return out
|
|
838
|
+
}
|
|
839
|
+
|
|
730
840
|
export async function QoderAuthPlugin({ client }) {
|
|
731
841
|
// serializes checking, rotating and saving tokens: a refresh token is
|
|
732
842
|
// spent once, so two refreshes would spend it twice
|
|
@@ -761,6 +871,34 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
761
871
|
return l
|
|
762
872
|
}
|
|
763
873
|
|
|
874
|
+
// deviceToken is a device token newer than the one Qoder just refused:
|
|
875
|
+
// the one saved since, else a rotated one, saved.
|
|
876
|
+
const deviceToken = (getAuth, attempted) =>
|
|
877
|
+
locked(async () => {
|
|
878
|
+
const a = await getAuth()
|
|
879
|
+
if (a?.deviceToken !== attempted) return a?.deviceToken
|
|
880
|
+
const dt = await refreshDevice(a.deviceRefresh)
|
|
881
|
+
await client.auth.set({ path: { id: ID }, body: { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh } })
|
|
882
|
+
return dt.token
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
// usage is the account's allowance, magpie's own hook
|
|
886
|
+
const usage = async (getAuth) => {
|
|
887
|
+
try {
|
|
888
|
+
const cred = await fresh(getAuth)
|
|
889
|
+
let env
|
|
890
|
+
try {
|
|
891
|
+
env = await fetchUsage(cred.deviceToken)
|
|
892
|
+
} catch (e) {
|
|
893
|
+
if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
|
|
894
|
+
env = await fetchUsage(await deviceToken(getAuth, cred.deviceToken))
|
|
895
|
+
}
|
|
896
|
+
return parseUsage(env)
|
|
897
|
+
} catch (e) {
|
|
898
|
+
return { error: e?.message ?? String(e) }
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
764
902
|
const signedInError = (e) =>
|
|
765
903
|
errorResponse(e instanceof SignInGone ? { status: 401, message: e.message } : { status: 502, message: "Qoder: " + (e?.message ?? e) })
|
|
766
904
|
|
|
@@ -817,6 +955,7 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
817
955
|
}
|
|
818
956
|
},
|
|
819
957
|
methods: [{ type: "oauth", label: "Sign in with Qoder", authorize: deviceSignIn }],
|
|
958
|
+
usage,
|
|
820
959
|
},
|
|
821
960
|
async config(config) {
|
|
822
961
|
config.provider ??= {}
|
|
@@ -848,3 +987,6 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
848
987
|
},
|
|
849
988
|
}
|
|
850
989
|
}
|
|
990
|
+
|
|
991
|
+
// for tests
|
|
992
|
+
export const _internal = { parseUsage, gfmt, when }
|