@magpie-community/opencode-qoder-auth 0.2.15 → 0.2.17

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 (3) hide show
  1. package/README.md +10 -1
  2. package/index.mjs +130 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -78,6 +78,12 @@ itself.
78
78
  reasoning and usage.
79
79
  - Tool calls Qoder writes as XML or JSON in its text become tool calls.
80
80
  Tool calls it sends the OpenAI way keep Qoder's id.
81
+ - A call written in the text that can't be read, or that the stream ends
82
+ inside, is never sent on as the answer (since 0.2.16, #132): the turn
83
+ ends as an error, a 502 or the stream's error frame with no finish, so
84
+ the agent or magpie can try again. A call whose JSON is whole when the
85
+ stream ends without its close is still the call; a tag in code or prose
86
+ stays text.
81
87
 
82
88
  **Errors**
83
89
 
@@ -195,7 +201,10 @@ The hosts and reply shapes come from a real VPC account's report
195
201
  Qoder gives credits for a daily claim (the Qoder client's campaign
196
202
  `CLAIM_BENEFIT`, 100 Credits a day as reported), on both sites. magpie can
197
203
  claim it for each account when its Qoder check-in switch is on
198
- (Settings). It asks these pages through this plugin's fetch (since 0.2.7),
204
+ (Settings), through `auth.checkin`: an already claimed campaign is `done`,
205
+ a campaign not available is `inactive`, and a claimable one is `claimed`
206
+ with its benefit amount. It asks these pages through this plugin's own
207
+ campaigns path (since 0.2.7),
199
208
  which sends them as the account, on the device token (`Bearer`,
200
209
  `Cosy-ClientType: 10`) as usage is read; a refused device token is
201
210
  rotated once and the page asked again.
package/index.mjs CHANGED
@@ -884,13 +884,37 @@ function parseCall(v) {
884
884
  return { name: m[1].trim(), args: JSON.stringify(args) }
885
885
  }
886
886
 
887
+ // quoted says text ends inside a code fence or an inline code span: a
888
+ // <tool_call> there is prose about one, not a call
889
+ function quoted(text) {
890
+ if ((text.match(/```/g)?.length ?? 0) % 2) return true
891
+ const line = text.slice(text.lastIndexOf("\n") + 1).replace(/```/g, "")
892
+ return (line.match(/`/g)?.length ?? 0) % 2 === 1
893
+ }
894
+
895
+ // callish says a block was on its way to being a call: its JSON began, or
896
+ // the XML function form
897
+ const callish = (v) => v.trimStart().startsWith("{") || v.includes("\x3cfunction=")
898
+
899
+ const CUT_SHORT = "the model's tool call, written as text, was cut short; it is not sent on as the answer"
900
+ const UNREADABLE = "the model's tool call, written as text, can't be read; it is not sent on as the answer"
901
+
887
902
  // Splitter holds text until a whole tool-call block has come, then gives
888
- // it as a call.
903
+ // it as a call. A block on its way to being a call that can't be read, or
904
+ // that the stream ended inside, is an error, never the answer (plugins#132,
905
+ // as trae's since plugins#38): the turn fails so it can be tried again. A
906
+ // block whose JSON is whole when the stream ends without its close is still
907
+ // the call; one that is no call at all, or one in code, stays text.
889
908
  class Splitter {
890
909
  textBuf = ""
891
910
  callBuf = ""
892
911
  inCall = false
893
912
  sawTool = false
913
+ sent = "" // the text given out so far, to tell a block in code from a call
914
+ text(out, t) {
915
+ this.sent += t
916
+ out.push({ text: t })
917
+ }
894
918
  feed(input) {
895
919
  const out = []
896
920
  while (input) {
@@ -905,11 +929,11 @@ class Splitter {
905
929
  keep = n
906
930
  break
907
931
  }
908
- if (keep < comb.length) out.push({ text: comb.slice(0, comb.length - keep) })
932
+ if (keep < comb.length) this.text(out, comb.slice(0, comb.length - keep))
909
933
  this.textBuf = comb.slice(comb.length - keep)
910
934
  break
911
935
  }
912
- if (i > 0) out.push({ text: comb.slice(0, i) })
936
+ if (i > 0) this.text(out, comb.slice(0, i))
913
937
  input = comb.slice(i + CALL_OPEN.length)
914
938
  this.inCall = true
915
939
  continue
@@ -921,11 +945,16 @@ class Splitter {
921
945
  this.callBuf = comb
922
946
  break
923
947
  }
924
- const c = parseCall(comb.slice(0, j))
948
+ const body = comb.slice(0, j)
949
+ const c = parseCall(body)
925
950
  if (c) {
926
951
  this.sawTool = true
927
952
  out.push({ call: c })
928
- } else out.push({ text: CALL_OPEN + comb.slice(0, j) + CALL_CLOSE })
953
+ } else if (callish(body) && !quoted(this.sent)) {
954
+ this.inCall = false
955
+ out.push({ error: UNREADABLE })
956
+ return out
957
+ } else this.text(out, CALL_OPEN + body + CALL_CLOSE)
929
958
  input = comb.slice(j + CALL_CLOSE.length)
930
959
  this.inCall = false
931
960
  }
@@ -933,14 +962,23 @@ class Splitter {
933
962
  }
934
963
  flush() {
935
964
  if (this.inCall) {
936
- const t = CALL_OPEN + this.callBuf
965
+ const body = this.callBuf
937
966
  this.inCall = false
938
967
  this.callBuf = ""
968
+ if (callish(body) && !quoted(this.sent)) {
969
+ const c = parseCall(body)
970
+ if (!c) return [{ error: CUT_SHORT }]
971
+ this.sawTool = true
972
+ return [{ call: c }]
973
+ }
974
+ const t = CALL_OPEN + body
975
+ this.sent += t
939
976
  return [{ text: t }]
940
977
  }
941
978
  if (this.textBuf) {
942
979
  const t = this.textBuf
943
980
  this.textBuf = ""
981
+ this.sent += t
944
982
  return [{ text: t }]
945
983
  }
946
984
  return []
@@ -968,9 +1006,10 @@ async function* events(body) {
968
1006
  const a = new Splitter()
969
1007
  let index = -1 // the tool call being given
970
1008
  let native = -1 // the native tool call being assembled
971
- const flushed = function* () {
972
- for (const f of a.flush()) if (f.text) yield { text: f.text }
973
- }
1009
+ // one piece of the split text as an event: a call, text, or a call that
1010
+ // can't be read, which ends the turn as a 502
1011
+ const piece = (f) =>
1012
+ f.call ? { tool: { index: ++index, id: "call_" + hexID(), name: f.call.name, args: f.call.args } } : f.error ? { error: { status: 502, message: f.error } } : { text: f.text }
974
1013
  let usage
975
1014
  let fr = ""
976
1015
  for await (const line of sseLines(body)) {
@@ -998,15 +1037,21 @@ async function* events(body) {
998
1037
  if (delta.reasoning_content) yield { reasoning: delta.reasoning_content }
999
1038
  if (typeof delta.content === "string")
1000
1039
  for (const f of a.feed(delta.content)) {
1001
- if (f.call) yield { tool: { index: ++index, id: "call_" + hexID(), name: f.call.name, args: f.call.args } }
1002
- else if (f.text) yield { text: f.text }
1040
+ if (!f.call && !f.error && !f.text) continue
1041
+ const e = piece(f)
1042
+ yield e
1043
+ if (e.error) return
1003
1044
  }
1004
1045
  // tool calls served the OpenAI way keep Qoder's id: a result answers it
1005
1046
  for (const tc of delta.tool_calls ?? []) {
1006
1047
  const i = Number(tc.index ?? 0)
1007
1048
  if (i !== native) {
1008
1049
  native = i
1009
- yield* flushed()
1050
+ for (const f of a.flush()) {
1051
+ const e = piece(f)
1052
+ yield e
1053
+ if (e.error) return
1054
+ }
1010
1055
  a.sawTool = true
1011
1056
  yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
1012
1057
  } else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
@@ -1025,7 +1070,11 @@ async function* events(body) {
1025
1070
  ...(u.completion_tokens_details?.reasoning_tokens ? { completion_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}),
1026
1071
  }
1027
1072
  }
1028
- yield* flushed()
1073
+ for (const f of a.flush()) {
1074
+ const e = piece(f)
1075
+ yield e
1076
+ if (e.error) return
1077
+ }
1029
1078
  yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
1030
1079
  }
1031
1080
 
@@ -1569,6 +1618,73 @@ const makePlugin = (site) => async ({ client }) => {
1569
1618
  }
1570
1619
  }
1571
1620
 
1621
+ // checkin claims the daily credits through the same campaigns page as
1622
+ // Qoder's client. A campaign already claimed is done; one not available
1623
+ // is inactive, never an error that magpie would retry all day.
1624
+ const checkin = async (getAuth) => {
1625
+ let cred
1626
+ try {
1627
+ cred = await fresh(getAuth)
1628
+ } catch (e) {
1629
+ return { outcome: "failed", message: e?.message ?? String(e), ...(e?.expired ? { signIn: "expired" } : {}) }
1630
+ }
1631
+ const base = campaignTarget(site, siteOf(site, cred), site.openapi + CAMPAIGNS)
1632
+ if (!base) return { outcome: "inactive", message: "no campaigns page for this account" }
1633
+ try {
1634
+ const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1635
+ const call = async (url, method, body) => {
1636
+ let res = await campaignCall(url, method, body, cred.deviceToken, machine)
1637
+ if (res.status === 401 || res.status === 403) {
1638
+ const token = await deviceToken(getAuth, cred.deviceToken)
1639
+ const current = latest(await getAuth())
1640
+ cred = { ...current, deviceToken: token }
1641
+ res = await campaignCall(url, method, body, token, machine)
1642
+ }
1643
+ return res
1644
+ }
1645
+ const listRes = await call(base, "GET")
1646
+ let list = {}
1647
+ let listRaw = ""
1648
+ try {
1649
+ listRaw = await listRes.text()
1650
+ list = JSON.parse(listRaw) ?? {}
1651
+ } catch {
1652
+ return { outcome: "failed", message: `campaigns: unreadable reply ${listRaw.trim().slice(0, 120) || "(empty)"}` }
1653
+ }
1654
+ if (listRes.status !== 200) return { outcome: "failed", message: `campaigns: HTTP ${listRes.status}` }
1655
+ const now = Date.now()
1656
+ const active = (c) => (!c.startAt || c.startAt * 1000 <= now) && (!c.endAt || c.endAt * 1000 > now)
1657
+ const campaigns = (Array.isArray(list.campaigns) ? list.campaigns : []).filter((x) => x?.actionType === "CLAIM_BENEFIT")
1658
+ const campaign = campaigns.find((x) => x.claimStatus === "CLAIMABLE")
1659
+ if (!campaign) {
1660
+ const done = campaigns.find((x) => x.claimStatus === "CLAIMED" && active(x))
1661
+ if (done) {
1662
+ const amount = Number(done.benefit?.amount)
1663
+ return { outcome: "done", ...(Number.isFinite(amount) && amount > 0 ? { credit: amount } : {}) }
1664
+ }
1665
+ return campaigns.length ? { outcome: "failed", message: "campaigns: today's credits aren't offered yet" } : { outcome: "inactive" }
1666
+ }
1667
+ const claimRes = await call(base.replace(/\/$/, "") + "/" + encodeURIComponent(campaign.campaignId) + "/claim", "POST", "{}")
1668
+ let claim = {}
1669
+ let claimRaw = ""
1670
+ try {
1671
+ claimRaw = await claimRes.text()
1672
+ claim = JSON.parse(claimRaw) ?? {}
1673
+ } catch {
1674
+ return { outcome: "failed", message: `claim: unreadable reply ${claimRaw.trim().slice(0, 120) || "(empty)"}` }
1675
+ }
1676
+ const said = claim?.status ?? claim?.data?.status
1677
+ if (claimRes.status !== 200) return { outcome: "failed", message: `claim: HTTP ${claimRes.status}` }
1678
+ if (said === "CLAIMED") {
1679
+ const amount = Number(claim?.benefit?.amount ?? claim?.data?.benefit?.amount ?? campaign.benefit?.amount)
1680
+ return { outcome: "claimed", ...(Number.isFinite(amount) && amount > 0 ? { credit: amount } : {}) }
1681
+ }
1682
+ return { outcome: "failed", message: `claim: ${said || "unknown answer"}` }
1683
+ } catch (e) {
1684
+ return { outcome: "failed", message: e?.message ?? String(e), ...(e?.expired ? { signIn: "expired" } : {}) }
1685
+ }
1686
+ }
1687
+
1572
1688
  const signedInError = (e) =>
1573
1689
  errorResponse({
1574
1690
  status: e instanceof SignInGone ? 401 : 502,
@@ -1725,6 +1841,7 @@ const makePlugin = (site) => async ({ client }) => {
1725
1841
  : []),
1726
1842
  ],
1727
1843
  usage,
1844
+ checkin,
1728
1845
  },
1729
1846
  async config(config) {
1730
1847
  config.provider ??= {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Qoder subscriptions in OpenCode and magpie: Qoder (qoder.com) and Qoder CN (qoder.cn)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",