@magpie-community/opencode-qoder-auth 0.1.1 → 0.1.3

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 +104 -41
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  // from magpie's internal/qoder, which ported it from CLIProxyAPI's qoder
17
17
  // support (https://github.com/ufec/CLIProxyAPI, MIT).
18
18
  import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID, constants } from "node:crypto"
19
+ import { STATUS_CODES } from "node:http"
19
20
 
20
21
  const ID = "qoder"
21
22
  const CLIENT_ID = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa"
@@ -213,14 +214,24 @@ async function deviceSignIn() {
213
214
  }
214
215
  }
215
216
 
216
- class SignInGone extends Error {}
217
+ // SignInGone is a sign-in that can't sign a request: a 401. Only Qoder
218
+ // refusing the refresh token marks the account lapsed (expired), as the
219
+ // built-in's qoderRefreshFailed did; no refresh token, or no account,
220
+ // answered 401 there without marking one.
221
+ class SignInGone extends Error {
222
+ constructor(message, expired = false) {
223
+ super(message)
224
+ this.expired = expired
225
+ }
226
+ }
217
227
 
218
228
  // refreshJob trades the job token's refresh token for a new pair; the old
219
229
  // one is spent.
220
- async function refreshJob(refresh) {
230
+ async function refreshJob(refresh, user) {
221
231
  if (!String(refresh ?? "").trim()) throw new SignInGone("Qoder: the sign-in lapsed; sign in again")
222
232
  const r = await openapi("/api/v1/jobToken/refresh", { method: "POST", body: { refresh_token: refresh } })
223
- if (r.status === 401 || r.status === 403) throw new SignInGone("Qoder: the sign-in has expired — sign in again")
233
+ if (r.status === 401 || r.status === 403)
234
+ throw new SignInGone(`${user}'s Qoder sign-in has expired — sign in again (qoder job token refresh: status ${r.status})`, true)
224
235
  if (r.status !== 200) throw new Error(`Qoder job token refresh: status ${r.status}`)
225
236
  if (!String(r.json?.token ?? "").trim() || !String(r.json?.refresh_token ?? "").trim())
226
237
  throw new Error("Qoder job token refresh: incomplete token pair")
@@ -252,6 +263,15 @@ const MODELS = [
252
263
  { id: "mmodel", name: "MiniMax-M3", context: 180_000 },
253
264
  ].map((m) => ({ images: true, ...m }))
254
265
 
266
+ // freeOf reads whether the listing marks a model free, as Qoder's client
267
+ // reads it (internal/qoder/models.go): is_free true, or a price_factor (the
268
+ // credits a request costs, as a multiple) of 0, either in snake or camel case.
269
+ function freeOf(raw) {
270
+ const is = raw.is_free ?? raw.isFree
271
+ const price = raw.price_factor ?? raw.priceFactor
272
+ return is === true || (typeof price === "number" && price === 0)
273
+ }
274
+
255
275
  // modelInfo reads one entry of the listing's "chat" array: its efforts and
256
276
  // default from thinking_config (is_reasoning alone when it has none).
257
277
  function modelInfo(raw) {
@@ -265,6 +285,7 @@ function modelInfo(raw) {
265
285
  alwaysThinks: false,
266
286
  efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
267
287
  defaultEffort: "",
288
+ free: freeOf(raw),
268
289
  config: raw,
269
290
  }
270
291
  const tc = raw.thinking_config
@@ -331,6 +352,8 @@ function runtimeModel(m) {
331
352
  },
332
353
  release_date: "",
333
354
  variants: Object.fromEntries((m.efforts ?? []).map((e) => [e, { reasoningEffort: e }])),
355
+ // magpie's own: served at no cost to the plan's credits
356
+ free: !!m.free,
334
357
  }
335
358
  }
336
359
 
@@ -474,17 +497,26 @@ function failure(status, text) {
474
497
  try {
475
498
  const j = JSON.parse(text)
476
499
  if (j?.message) msg = j.message
477
- const d = typeof j?.details === "string" ? JSON.parse(j.details) : null
500
+ // details is a JSON string, or the object itself (gjson reads either)
501
+ const d = typeof j?.details === "string" ? JSON.parse(j.details) : j?.details
478
502
  if (d?.error?.message) msg += ": " + d.error.message
479
503
  } catch {}
480
- msg ||= `HTTP ${status}`
481
- if (status === 401 || status === 403) return { status: 401, message: "Qoder: the sign-in lapsed — sign in again" }
504
+ msg ||= STATUS_CODES[status] ?? `HTTP ${status}`
505
+ // a refused chat is the built-in's 401, which never marked the account
506
+ // lapsed: only a refused refresh did (errorResponse says kept)
507
+ if (status === 401 || status === 403) return { status: 401, message: "the sign-in lapsed — sign in again" }
482
508
  if (status === 429 || msg.toLowerCase().includes("quota")) return { status: 429, message: "usage limit reached: " + msg }
483
- return { status, message: "Qoder: " + msg }
509
+ return { status, message: msg }
484
510
  }
485
511
 
486
- const errorResponse = ({ status, message }) =>
487
- new Response(JSON.stringify({ error: { message, type: "qoder_error", code: status } }), { status, headers: { "Content-Type": "application/json" } })
512
+ // errorResponse is a failure as OpenAI's API gives it. signIn is what it
513
+ // means for the account (magpie's X-Magpie-Sign-In): a 401 keeps it unless
514
+ // told expired, since the built-in marked only a refused refresh.
515
+ const errorResponse = ({ status, message, signIn = status === 401 ? "kept" : undefined }) =>
516
+ new Response(JSON.stringify({ error: { message, type: "qoder_error", code: status } }), {
517
+ status,
518
+ headers: { "Content-Type": "application/json", ...(signIn ? { "X-Magpie-Sign-In": signIn } : {}) },
519
+ })
488
520
 
489
521
  // ---- the reply ----------------------------------------------------------------
490
522
 
@@ -727,6 +759,17 @@ async function bodyText(input, init) {
727
759
  return typeof b === "string" ? b : new TextDecoder().decode(b)
728
760
  }
729
761
 
762
+ // signed is res saying what it means for the sign-in, as the built-in's
763
+ // answer did: a renewed job token cleared the mark whatever came of the
764
+ // request; without one, a success left the mark as it was.
765
+ function signed(res, renewed) {
766
+ const said = renewed ? "renewed" : res.ok ? "kept" : null
767
+ if (!said) return res
768
+ const headers = new Headers(res.headers)
769
+ headers.set("X-Magpie-Sign-In", said)
770
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
771
+ }
772
+
730
773
  // ---- usage --------------------------------------------------------------------
731
774
  //
732
775
  // The account's allowance as magpie's built-in Qoder account shows it
@@ -848,6 +891,9 @@ export async function QoderAuthPlugin({ client }) {
848
891
  }
849
892
  // each account's listing, the model configs a request carries
850
893
  const listings = new Map()
894
+ // the accounts fresh renewed: the built-in took the lapse mark off on a
895
+ // renewed job token (qoderPersist), whatever the request then met
896
+ const renewals = new WeakSet()
851
897
 
852
898
  // fresh is the account with a live job token, refreshed and saved near
853
899
  // its end.
@@ -856,9 +902,10 @@ export async function QoderAuthPlugin({ client }) {
856
902
  const a = await getAuth()
857
903
  if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone("Qoder: not signed in")
858
904
  if (a.expires - Date.now() > REFRESH_LEAD) return a
859
- const jt = await refreshJob(a.refresh)
905
+ const jt = await refreshJob(a.refresh, a.accountId || a.email || a.uid)
860
906
  const next = { ...a, access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) }
861
907
  await client.auth.set({ path: { id: ID }, body: next })
908
+ renewals.add(next)
862
909
  return next
863
910
  })
864
911
 
@@ -882,10 +929,15 @@ export async function QoderAuthPlugin({ client }) {
882
929
  return dt.token
883
930
  })
884
931
 
885
- // usage is the account's allowance, magpie's own hook
932
+ // usage is the account's allowance, magpie's own hook. signIn is what the read means for the sign-in, as the built-in's did:
933
+ // a refused job refresh marks it, a renewed one clears it, and nothing
934
+ // else does — not a clean read, nor a refused device token, which serves
935
+ // only the account pages
886
936
  const usage = async (getAuth) => {
937
+ let signIn = "kept"
887
938
  try {
888
939
  const cred = await fresh(getAuth)
940
+ if (renewals.has(cred)) signIn = "renewed"
889
941
  let env
890
942
  try {
891
943
  env = await fetchUsage(cred.deviceToken)
@@ -893,14 +945,47 @@ export async function QoderAuthPlugin({ client }) {
893
945
  if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
894
946
  env = await fetchUsage(await deviceToken(getAuth, cred.deviceToken))
895
947
  }
896
- return parseUsage(env)
948
+ return { ...parseUsage(env), signIn }
897
949
  } catch (e) {
898
- return { error: e?.message ?? String(e) }
950
+ return { error: e?.message ?? String(e), signIn: e?.expired ? "expired" : signIn }
899
951
  }
900
952
  }
901
953
 
902
954
  const signedInError = (e) =>
903
- errorResponse(e instanceof SignInGone ? { status: 401, message: e.message } : { status: 502, message: "Qoder: " + (e?.message ?? e) })
955
+ errorResponse({
956
+ status: e instanceof SignInGone ? 401 : 502,
957
+ message: String(e?.message ?? e).replace(/^Qoder: /, ""),
958
+ signIn: e?.expired ? "expired" : undefined,
959
+ })
960
+
961
+ // ask is the answer to a chat completion on the live account cred.
962
+ const ask = async (chat, cred, init) => {
963
+ let m
964
+ try {
965
+ m = (await models(cred)).find((x) => x.key === chat.model) ?? (await models(cred, true)).find((x) => x.key === chat.model)
966
+ } catch (e) {
967
+ // the built-in answered any failure to read the list 400
968
+ // (QoderModelOf), a refused one included, marking nothing
969
+ return errorResponse({ status: 400, message: e.message })
970
+ }
971
+ if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
972
+ const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
973
+ const headers = {
974
+ ...cosyHeaders(CHAT_URL, cred, wire),
975
+ Accept: "text/event-stream",
976
+ "Cache-Control": "no-cache",
977
+ "X-Model-Key": m.key,
978
+ "X-Model-Source": m.source,
979
+ }
980
+ let res
981
+ try {
982
+ res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
983
+ } catch (e) {
984
+ return errorResponse({ status: 502, message: e.message })
985
+ }
986
+ if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
987
+ return answer(res, chat)
988
+ }
904
989
 
905
990
  return {
906
991
  auth: {
@@ -915,12 +1000,12 @@ export async function QoderAuthPlugin({ client }) {
915
1000
  async fetch(input, init = {}) {
916
1001
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
917
1002
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
918
- return errorResponse({ status: 404, message: "Qoder: only chat completions are served" })
1003
+ return errorResponse({ status: 404, message: "only chat completions are served" })
919
1004
  let chat
920
1005
  try {
921
1006
  chat = JSON.parse(await bodyText(input, init))
922
1007
  } catch {
923
- return errorResponse({ status: 400, message: "Qoder: a request that isn't JSON" })
1008
+ return errorResponse({ status: 400, message: "a request that isn't JSON" })
924
1009
  }
925
1010
  let cred
926
1011
  try {
@@ -928,29 +1013,7 @@ export async function QoderAuthPlugin({ client }) {
928
1013
  } catch (e) {
929
1014
  return signedInError(e)
930
1015
  }
931
- let m
932
- try {
933
- m = (await models(cred)).find((x) => x.key === chat.model) ?? (await models(cred, true)).find((x) => x.key === chat.model)
934
- } catch (e) {
935
- return errorResponse({ status: 502, message: e.message })
936
- }
937
- if (!m) return errorResponse({ status: 400, message: `Qoder: unknown or disabled model "${chat.model}"` })
938
- const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
939
- const headers = {
940
- ...cosyHeaders(CHAT_URL, cred, wire),
941
- Accept: "text/event-stream",
942
- "Cache-Control": "no-cache",
943
- "X-Model-Key": m.key,
944
- "X-Model-Source": m.source,
945
- }
946
- let res
947
- try {
948
- res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
949
- } catch (e) {
950
- return errorResponse({ status: 502, message: "Qoder: " + e.message })
951
- }
952
- if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
953
- return answer(res, chat)
1016
+ return signed(await ask(chat, cred, init), renewals.has(cred))
954
1017
  },
955
1018
  }
956
1019
  },
@@ -978,7 +1041,7 @@ export async function QoderAuthPlugin({ client }) {
978
1041
  const ms = await models(cred, true)
979
1042
  if (!ms.length) return provider.models
980
1043
  return Object.fromEntries(
981
- ms.map((m) => [m.key, runtimeModel({ id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [] })]),
1044
+ 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 })]),
982
1045
  )
983
1046
  } catch {
984
1047
  return provider.models
@@ -989,4 +1052,4 @@ export async function QoderAuthPlugin({ client }) {
989
1052
  }
990
1053
 
991
1054
  // for tests
992
- export const _internal = { parseUsage, gfmt, when }
1055
+ 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.1",
3
+ "version": "0.1.3",
4
4
  "description": "Qoder subscription in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",