@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.
- package/index.mjs +164 -8
- 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
|
-
|
|
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:
|
|
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 ?
|
|
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: "
|
|
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: "
|
|
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: `
|
|
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:
|
|
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 }
|