@magpie-community/opencode-qoder-auth 0.1.0 → 0.1.2

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 +164 -8
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -252,6 +252,15 @@ const MODELS = [
252
252
  { id: "mmodel", name: "MiniMax-M3", context: 180_000 },
253
253
  ].map((m) => ({ images: true, ...m }))
254
254
 
255
+ // freeOf reads whether the listing marks a model free, as Qoder's client
256
+ // reads it (internal/qoder/models.go): is_free true, or a price_factor (the
257
+ // credits a request costs, as a multiple) of 0, either in snake or camel case.
258
+ function freeOf(raw) {
259
+ const is = raw.is_free ?? raw.isFree
260
+ const price = raw.price_factor ?? raw.priceFactor
261
+ return is === true || (typeof price === "number" && price === 0)
262
+ }
263
+
255
264
  // modelInfo reads one entry of the listing's "chat" array: its efforts and
256
265
  // default from thinking_config (is_reasoning alone when it has none).
257
266
  function modelInfo(raw) {
@@ -265,6 +274,7 @@ function modelInfo(raw) {
265
274
  alwaysThinks: false,
266
275
  efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
267
276
  defaultEffort: "",
277
+ free: freeOf(raw),
268
278
  config: raw,
269
279
  }
270
280
  const tc = raw.thinking_config
@@ -331,6 +341,8 @@ function runtimeModel(m) {
331
341
  },
332
342
  release_date: "",
333
343
  variants: Object.fromEntries((m.efforts ?? []).map((e) => [e, { reasoningEffort: e }])),
344
+ // magpie's own: served at no cost to the plan's credits
345
+ free: !!m.free,
334
346
  }
335
347
  }
336
348
 
@@ -478,9 +490,11 @@ function failure(status, text) {
478
490
  if (d?.error?.message) msg += ": " + d.error.message
479
491
  } catch {}
480
492
  msg ||= `HTTP ${status}`
481
- if (status === 401 || status === 403) return { status: 401, message: "Qoder: the sign-in lapsed — sign in again" }
493
+ // only Qoder's 401 says the job token itself is refused; a 403 is kept as
494
+ // what it is, as magpie marks the account lapsed on a 401
495
+ if (status === 401 || status === 403) return { status, message: "the sign-in lapsed — sign in again" }
482
496
  if (status === 429 || msg.toLowerCase().includes("quota")) return { status: 429, message: "usage limit reached: " + msg }
483
- return { status, message: "Qoder: " + msg }
497
+ return { status, message: msg }
484
498
  }
485
499
 
486
500
  const errorResponse = ({ status, message }) =>
@@ -727,6 +741,116 @@ async function bodyText(input, init) {
727
741
  return typeof b === "string" ? b : new TextDecoder().decode(b)
728
742
  }
729
743
 
744
+ // ---- usage --------------------------------------------------------------------
745
+ //
746
+ // The account's allowance as magpie's built-in Qoder account shows it
747
+ // (internal/provider/qoder_usage.go): the account pages' usage, asked with
748
+ // the device token (not the job token the chat runs on), which is rotated
749
+ // with its own refresh token once Qoder refuses it.
750
+
751
+ class UsageStatus extends Error {
752
+ constructor(status) {
753
+ super(`qoder usage: upstream HTTP ${status}`)
754
+ this.status = status
755
+ }
756
+ }
757
+
758
+ // fetchUsage is the usage envelope, {displayMode, qoderUsage}.
759
+ async function fetchUsage(deviceToken) {
760
+ if (!String(deviceToken ?? "").trim()) throw new Error("qoder usage: missing device token")
761
+ let res
762
+ try {
763
+ res = await fetch(OPENAPI + "/sash/api/v2/me/usage", {
764
+ headers: { Accept: "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
765
+ signal: AbortSignal.timeout(20_000),
766
+ })
767
+ } catch (e) {
768
+ throw new Error(`qoder usage: request failed: ${e?.message ?? e}`)
769
+ }
770
+ if (res.status !== 200) throw new UsageStatus(res.status)
771
+ let env
772
+ try {
773
+ env = JSON.parse(await res.text())
774
+ } catch (e) {
775
+ throw new Error(`qoder usage: decode response: ${e?.message ?? e}`)
776
+ }
777
+ if (env?.displayMode !== "qoder" && env?.displayMode !== "enterprise") throw new Error("qoder usage: unknown display mode")
778
+ if (env.displayMode === "qoder" && (env.qoderUsage === undefined || env.qoderUsage === null)) throw new Error("qoder usage: missing quota data")
779
+ return env
780
+ }
781
+
782
+ // refreshDevice trades the device refresh token for a new pair; it rotates
783
+ // too, so the caller saves it.
784
+ async function refreshDevice(refresh) {
785
+ if (!String(refresh ?? "").trim()) throw new Error("qoder device token refresh: missing refresh token; sign in again")
786
+ let r
787
+ try {
788
+ r = await openapi("/api/v1/deviceToken/refresh", { method: "POST", body: { refresh_token: refresh } })
789
+ } catch (e) {
790
+ throw new Error(`qoder device token refresh: request failed: ${e?.message ?? e}`)
791
+ }
792
+ if (r.status !== 200) {
793
+ const err = `qoder device token refresh: upstream HTTP ${r.status}`
794
+ // the device token serves only the account pages (usage); chat runs on
795
+ // the job token, so a refused one doesn't lapse the account
796
+ if (r.status === 401 || r.status === 403)
797
+ throw new Error(`Qoder usage is unavailable: Qoder refused the account-page sign-in (chat still works) — sign in again to see usage (${err})`)
798
+ throw new Error(err)
799
+ }
800
+ const token = r.json?.token || r.json?.device_token || ""
801
+ if (!String(token).trim() || !String(r.json?.refresh_token ?? "").trim()) throw new Error("qoder device token refresh: incomplete token pair")
802
+ return { token, refresh: r.json.refresh_token }
803
+ }
804
+
805
+ // gfmt is a number as Go's %g writes it.
806
+ function gfmt(n) {
807
+ const [m, e] = n.toExponential().split("e")
808
+ const x = Number(e)
809
+ if (x < -4 || x >= 6) return `${m}e${x < 0 ? "-" : "+"}${String(Math.abs(x)).padStart(2, "0")}`
810
+ return String(n)
811
+ }
812
+
813
+ const RFC3339 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/i
814
+
815
+ // when is a time Qoder gives: an RFC 3339 string, else seconds or
816
+ // milliseconds, as a number or its text.
817
+ function when(v) {
818
+ if (typeof v === "string" && RFC3339.test(v) && !isNaN(Date.parse(v))) return new Date(v).toISOString()
819
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v.trim()) : NaN
820
+ if (!isFinite(n) || n <= 0) return undefined
821
+ return new Date(Math.trunc(n < 1e12 ? n * 1000 : n)).toISOString()
822
+ }
823
+
824
+ // parseUsage is the plan and each pool of credits the envelope tells.
825
+ function parseUsage(env) {
826
+ if (env.displayMode === "enterprise") return { plan: "Enterprise" }
827
+ const u = env.qoderUsage
828
+ if (env.displayMode !== "qoder" || !u || typeof u !== "object" || Array.isArray(u)) return { error: "Qoder: missing quota data" }
829
+ const field = (a, b) => (u[a] !== undefined ? u[a] : u[b])
830
+ const out = { windows: [] }
831
+ const plan = field("userType", "user_type")
832
+ if (typeof plan === "string") out.plan = plan
833
+ const until = when(field("expiresAt", "expires_at"))
834
+ if (until) out.until = until
835
+ const num = (v) => v === undefined || v === null || typeof v === "number"
836
+ const str = (v) => v === undefined || v === null || typeof v === "string"
837
+ const add = (b, name) => {
838
+ if (b === null || typeof b !== "object" || Array.isArray(b)) return
839
+ if (![b.total, b.cap, b.used, b.remaining].every(num) || !str(b.name) || !str(b.unit)) return
840
+ const total = b.total ?? b.cap
841
+ if (total == null || total <= 0 || (b.used == null && b.remaining == null)) return
842
+ const used = b.used != null ? b.used : total - b.remaining
843
+ if (used < 0) return
844
+ out.windows.push({ name: b.name || name, used: Math.min(100, (100 * used) / total), display: `${gfmt(used)} / ${gfmt(total)} ${b.unit || "credits"}` })
845
+ }
846
+ add(field("userQuota", "user_quota"), "Credits")
847
+ add(field("addOnQuota", "add_on_quota"), "Add-on credits")
848
+ add(field("orgResourcePackage", "org_resource_package"), "Shared credits")
849
+ const dedicated = field("dedicatedResourcePackages", "dedicated_resource_packages")
850
+ for (const b of Array.isArray(dedicated) ? dedicated : []) add(b, "Dedicated credits")
851
+ return out
852
+ }
853
+
730
854
  export async function QoderAuthPlugin({ client }) {
731
855
  // serializes checking, rotating and saving tokens: a refresh token is
732
856
  // spent once, so two refreshes would spend it twice
@@ -761,8 +885,36 @@ export async function QoderAuthPlugin({ client }) {
761
885
  return l
762
886
  }
763
887
 
888
+ // deviceToken is a device token newer than the one Qoder just refused:
889
+ // the one saved since, else a rotated one, saved.
890
+ const deviceToken = (getAuth, attempted) =>
891
+ locked(async () => {
892
+ const a = await getAuth()
893
+ if (a?.deviceToken !== attempted) return a?.deviceToken
894
+ const dt = await refreshDevice(a.deviceRefresh)
895
+ await client.auth.set({ path: { id: ID }, body: { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh } })
896
+ return dt.token
897
+ })
898
+
899
+ // usage is the account's allowance, magpie's own hook
900
+ const usage = async (getAuth) => {
901
+ try {
902
+ const cred = await fresh(getAuth)
903
+ let env
904
+ try {
905
+ env = await fetchUsage(cred.deviceToken)
906
+ } catch (e) {
907
+ if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
908
+ env = await fetchUsage(await deviceToken(getAuth, cred.deviceToken))
909
+ }
910
+ return parseUsage(env)
911
+ } catch (e) {
912
+ return { error: e?.message ?? String(e) }
913
+ }
914
+ }
915
+
764
916
  const signedInError = (e) =>
765
- errorResponse(e instanceof SignInGone ? { status: 401, message: e.message } : { status: 502, message: "Qoder: " + (e?.message ?? e) })
917
+ errorResponse({ status: e instanceof SignInGone ? 401 : 502, message: String(e?.message ?? e).replace(/^Qoder: /, "") })
766
918
 
767
919
  return {
768
920
  auth: {
@@ -777,12 +929,12 @@ export async function QoderAuthPlugin({ client }) {
777
929
  async fetch(input, init = {}) {
778
930
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
779
931
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
780
- return errorResponse({ status: 404, message: "Qoder: only chat completions are served" })
932
+ return errorResponse({ status: 404, message: "only chat completions are served" })
781
933
  let chat
782
934
  try {
783
935
  chat = JSON.parse(await bodyText(input, init))
784
936
  } catch {
785
- return errorResponse({ status: 400, message: "Qoder: a request that isn't JSON" })
937
+ return errorResponse({ status: 400, message: "a request that isn't JSON" })
786
938
  }
787
939
  let cred
788
940
  try {
@@ -796,7 +948,7 @@ export async function QoderAuthPlugin({ client }) {
796
948
  } catch (e) {
797
949
  return errorResponse({ status: 502, message: e.message })
798
950
  }
799
- if (!m) return errorResponse({ status: 400, message: `Qoder: unknown or disabled model "${chat.model}"` })
951
+ if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
800
952
  const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
801
953
  const headers = {
802
954
  ...cosyHeaders(CHAT_URL, cred, wire),
@@ -809,7 +961,7 @@ export async function QoderAuthPlugin({ client }) {
809
961
  try {
810
962
  res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
811
963
  } catch (e) {
812
- return errorResponse({ status: 502, message: "Qoder: " + e.message })
964
+ return errorResponse({ status: 502, message: e.message })
813
965
  }
814
966
  if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
815
967
  return answer(res, chat)
@@ -817,6 +969,7 @@ export async function QoderAuthPlugin({ client }) {
817
969
  }
818
970
  },
819
971
  methods: [{ type: "oauth", label: "Sign in with Qoder", authorize: deviceSignIn }],
972
+ usage,
820
973
  },
821
974
  async config(config) {
822
975
  config.provider ??= {}
@@ -839,7 +992,7 @@ export async function QoderAuthPlugin({ client }) {
839
992
  const ms = await models(cred, true)
840
993
  if (!ms.length) return provider.models
841
994
  return Object.fromEntries(
842
- ms.map((m) => [m.key, runtimeModel({ id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [] })]),
995
+ ms.map((m) => [m.key, runtimeModel({ id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [], free: m.free })]),
843
996
  )
844
997
  } catch {
845
998
  return provider.models
@@ -848,3 +1001,6 @@ export async function QoderAuthPlugin({ client }) {
848
1001
  },
849
1002
  }
850
1003
  }
1004
+
1005
+ // for tests
1006
+ export const _internal = { parseUsage, gfmt, when, failure, modelInfo }
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.2",
4
4
  "description": "Qoder subscription in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",