@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.
Files changed (2) hide show
  1. package/index.mjs +142 -0
  2. 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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Qoder subscription in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",