@magpie-community/opencode-qoder-auth 0.2.25 → 0.2.27

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 +23 -5
  2. package/index.mjs +84 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -91,6 +91,17 @@ itself.
91
91
  answers no call before it. A result with no call left to answer goes
92
92
  as text in the user's turn.
93
93
  - Your tools become Qoder's native function tools.
94
+ - A call the client sent without an id gets one made from its turn, its
95
+ place and its arguments, so it goes out the same on every request.
96
+ - It marks the prompt for Qoder's cache as Qoder's own client does (since
97
+ 0.2.26, #290): the last block of the last user turn carries
98
+ `cache_control: ephemeral` (nothing is marked when the last turn is a
99
+ tool's result), user turns carry their blocks as `contents` too, and each
100
+ request names its conversation in `session_id`. That id is a hash of
101
+ Codex's thread (`prompt_cache_key`) when magpie sends one, else of the
102
+ conversation's start, so every turn of one conversation names the same
103
+ session; the key itself never goes out. Each request has its own
104
+ `request_id`.
94
105
  - The model's own configuration from Qoder's list goes with it.
95
106
  - It picks the reasoning effort from the model's own levels:
96
107
  - The nearest level to the one asked, the higher one on a tie.
@@ -257,8 +268,8 @@ the account's credentials.
257
268
 
258
269
  - Install Qoder desktop, or sign in to Qoder CLI once, to make its native
259
270
  runtime available. The plugin finds the desktop's `resources/umid`
260
- (`Qoder.app` on macOS, `%LOCALAPPDATA%\Programs\Qoder` on Windows,
261
- `/opt/Qoder` on Linux) or the runtime the CLI caches in `~/.qoder/.bin`
271
+ (`Qoder.app` on macOS, `%LOCALAPPDATA%\Programs\Qoder` or, since
272
+ 0.2.26, `%ProgramFiles%\Qoder` on Windows, `/opt/Qoder` on Linux) or the runtime the CLI caches in `~/.qoder/.bin`
262
273
  (`runtime-info-<platform>-<arch>-*` on macOS and Linux,
263
274
  `umid-win32-x64-*\runtime-info.exe` on Windows; `QODER_CONFIG_DIR`,
264
275
  `QODER_CLI_HOME` and `QODER_CONFIG_DIR_NAME` move it as they move the
@@ -266,9 +277,16 @@ the account's credentials.
266
277
  - For another installation location, set `QODER_RUNTIME_INFO` to the
267
278
  absolute path of Qoder's installed `runtime-info` executable
268
279
  (`runtime-info.exe` on Windows), with its accompanying SDK files.
269
- - A missing helper or invalid identity is an error, rather than a false
270
- "no activity" result. Qoder CN keeps its existing requests and does
271
- not need this local helper.
280
+ - With no helper anywhere (a machine that never had Qoder desktop or
281
+ Qoder CLI), the check-in is `ineligible` for the day, with a message
282
+ saying to install one (or set `QODER_RUNTIME_INFO`); magpie asks again
283
+ the next day rather than every 30 minutes (#304). The identity comes
284
+ only from Qoder's own helper: the plugin never makes one up, so such an
285
+ international account gets no daily claim until Qoder is installed.
286
+ - A helper that is there but can't be read, or gives an invalid identity,
287
+ is `failed` and asked again later, rather than a false "no activity"
288
+ result. Qoder CN keeps its existing requests and does not need this
289
+ local helper.
272
290
 
273
291
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
274
292
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
package/index.mjs CHANGED
@@ -705,6 +705,16 @@ const textOf = (c) => (typeof c === "string" ? c : blocksOf(c).filter((b) => b.t
705
705
  // went out with in order, which is the pairing a dropped id means. Only a
706
706
  // result with no call of its turn left to answer becomes text in the user's
707
707
  // own turn, so the words the tool returned stay with the conversation.
708
+ // callID is the id a call the client sent without one goes out with: the
709
+ // same on every turn of the conversation (#290), made from where the call
710
+ // is and what it says, so the history Qoder sees doesn't change under a
711
+ // prompt cache from one request to the next.
712
+ function callID(at, k, c) {
713
+ const args = c.function?.arguments
714
+ const said = typeof args === "string" ? args : JSON.stringify(args ?? {})
715
+ return "call_" + createHash("sha256").update(`${at}\0${k}\0${c.function?.name ?? ""}\0${said}`).digest("hex").slice(0, 32)
716
+ }
717
+
708
718
  function qoderMessages(msgs) {
709
719
  const out = []
710
720
  const names = {}
@@ -757,8 +767,8 @@ function qoderMessages(msgs) {
757
767
  if (m.role === "assistant" && m.tool_calls?.length) {
758
768
  // a call the client sent without an id gets one, so its result can
759
769
  // answer it instead of being turned into text
760
- const calls = m.tool_calls.map((c) => {
761
- const id = String(c.id ?? "").trim() || "call_" + hexID()
770
+ const calls = m.tool_calls.map((c, k) => {
771
+ const id = String(c.id ?? "").trim() || callID(i, k, c)
762
772
  names[id] = c.function?.name
763
773
  const args = c.function?.arguments
764
774
  return { id, type: "function", function: { name: c.function?.name, arguments: typeof args === "string" ? args : JSON.stringify(args ?? {}) } }
@@ -804,6 +814,49 @@ function windowFor(chat, m, reply = 0) {
804
814
  return m.windows.find((w) => w >= need) ?? m.windows[m.windows.length - 1]
805
815
  }
806
816
 
817
+ // UNCACHED are the blocks Qoder's client never marks for the prompt cache.
818
+ const UNCACHED = new Set(["thinking", "redacted_thinking", "tool_use", "tool_result"])
819
+
820
+ // cacheMarked is the turns as Qoder's own client sends them for the prompt
821
+ // cache (#290): every user turn's blocks also as contents, and the last
822
+ // block of the last turn that isn't thinking or a tool's marked
823
+ // cache_control ephemeral. That is qoder-agent-sdk's (Qoder.app and
824
+ // qodercli 1.1.75 alike): the last message's last such block, and nothing
825
+ // when the last turn is a tool's. A model behind Qoder that caches only
826
+ // what is marked (Anthropic's) read none of a conversation without it,
827
+ // while one that caches on its own (Fast) did. The caller's turns are left
828
+ // as they were.
829
+ function cacheMarked(msgs) {
830
+ const out = msgs.map((m) => (m.role === "user" && Array.isArray(m.content) ? { ...m, contents: m.content } : m))
831
+ const last = out.at(-1)
832
+ if (last?.role !== "user" || !Array.isArray(last.content)) return out
833
+ const at = last.content.findLastIndex((b) => b?.type && !UNCACHED.has(b.type))
834
+ if (at < 0) return out
835
+ const blocks = last.content.slice()
836
+ blocks[at] = { ...blocks[at], cache_control: { type: "ephemeral" } }
837
+ out[out.length - 1] = { ...last, content: blocks, contents: blocks }
838
+ return out
839
+ }
840
+
841
+ // sessionOf is the session_id a request goes out under: one for every turn
842
+ // of a conversation and another for each conversation, as Qoder's client
843
+ // sends its own session's (#290). Qoder can keep a conversation where its
844
+ // prompt is cached only when its turns say they are one. It is the
845
+ // client's prompt_cache_key (Codex's thread), else its user, else the
846
+ // conversation's opening (its system prompt and first user turn), hashed,
847
+ // so no key of the client's goes to Qoder as it is.
848
+ function sessionOf(chat) {
849
+ let seed = String(chat.prompt_cache_key ?? "").trim() ? "key\0" + chat.prompt_cache_key : String(chat.user ?? "").trim() ? "user\0" + chat.user : ""
850
+ if (!seed) {
851
+ const msgs = chat.messages ?? []
852
+ const sys = msgs.filter((x) => x.role === "system" || x.role === "developer").map((x) => textOf(x.content))
853
+ const first = msgs.find((x) => x.role === "user")
854
+ seed = "chat\0" + JSON.stringify([sys, first?.content ?? ""])
855
+ }
856
+ const h = createHash("sha256").update(seed).digest("hex")
857
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`
858
+ }
859
+
807
860
  // qoderBody is the plaintext agent_chat_generation takes for a chat
808
861
  // completion request, for model m.
809
862
  function qoderBody(chat, m) {
@@ -817,7 +870,12 @@ function qoderBody(chat, m) {
817
870
  if (effort) params.reasoning_effort = effort
818
871
  const window = windowFor(chat, m, params.max_tokens)
819
872
  if (window > 0) params.context_length = window
873
+ const request = randomUUID()
820
874
  const body = {
875
+ request_id: request,
876
+ request_set_id: request,
877
+ chat_record_id: request,
878
+ session_id: sessionOf(chat),
821
879
  parameters: params,
822
880
  business: { product: "app", version: COSY_VERSION, type: "agent", id: hexID(), name: "magpie session", begin_at: Date.now(), stage: "start" },
823
881
  agent_id: "agent_common",
@@ -825,7 +883,7 @@ function qoderBody(chat, m) {
825
883
  session_type: "app",
826
884
  model_config: m.config,
827
885
  system: [sys],
828
- messages: [{ role: "system", content: [sys] }, ...qoderMessages(chat.messages ?? [])],
886
+ messages: [{ role: "system", content: [sys] }, ...cacheMarked(qoderMessages(chat.messages ?? []))],
829
887
  }
830
888
  if (tools.length)
831
889
  body.tools = tools.map((t) => ({
@@ -1312,7 +1370,8 @@ const CAMPAIGN_MACHINE_TTL = 60 * 60 * 1000 // Qoder's desktop client renews its
1312
1370
  //
1313
1371
  // Where each one keeps it (Qoder desktop 0.4.3's packages, qodercli 1.1.66):
1314
1372
  // - desktop: <install>/resources/umid/runtime-info(.exe): Qoder.app on
1315
- // macOS, %LOCALAPPDATA%\Programs\Qoder (the user installer) on Windows,
1373
+ // macOS, %LOCALAPPDATA%\Programs\Qoder (the user installer) or
1374
+ // %ProgramFiles%\Qoder (a machine-wide install) on Windows,
1316
1375
  // /opt/Qoder (the .deb and the .rpm) on Linux;
1317
1376
  // - CLI: <config>/.bin, where <config> is QODER_CONFIG_DIR, else
1318
1377
  // QODER_CLI_HOME (or the home folder) joined with QODER_CONFIG_DIR_NAME
@@ -1327,6 +1386,8 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1327
1386
  if (platform === "win32") {
1328
1387
  const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
1329
1388
  paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
1389
+ // the machine-wide installer, as VS Code's system setup places it
1390
+ for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, "Qoder", "resources", "umid", name))
1330
1391
  } else if (platform === "darwin") {
1331
1392
  for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
1332
1393
  } else if (platform === "linux") {
@@ -1347,7 +1408,21 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1347
1408
  if (path && (await isFile(path))) found.push({ path, time: (await stat(path)).mtimeMs })
1348
1409
  }
1349
1410
  if (found.length) return found.sort((a, b) => b.time - a.time)[0].path
1350
- throw new Error("Qoder check-in: device identity runtime not found; install Qoder desktop or sign in to Qoder CLI once, or set QODER_RUNTIME_INFO to its runtime-info executable")
1411
+ throw new RuntimeMissing()
1412
+ }
1413
+
1414
+ // RuntimeMissing: no Qoder desktop or CLI on this machine to read the
1415
+ // device identity from. Qoder offers the daily credits only on a request
1416
+ // carrying it (without it the list answers 200 with no CLAIM_BENEFIT), and
1417
+ // the identity is the output of Qoder's own signed helper, which the plugin
1418
+ // never makes up. Asking again won't change that until the user installs
1419
+ // Qoder, so the check-in says what to do once instead of "tries again later".
1420
+ class RuntimeMissing extends Error {
1421
+ constructor() {
1422
+ super(
1423
+ "Qoder check-in: device identity runtime not found. Qoder gives the daily credits only to a device its own app identifies, and this machine has neither Qoder desktop nor Qoder CLI to read that identity from. Install Qoder desktop (or install Qoder CLI and sign in to it once), or set QODER_RUNTIME_INFO to its runtime-info executable; magpie checks in again the next day",
1424
+ )
1425
+ }
1351
1426
  }
1352
1427
 
1353
1428
  // Read the SDK identity as Qoder does: environment 3 is international; on
@@ -1790,6 +1865,9 @@ const makePlugin = (site) => async ({ client }) => {
1790
1865
  }
1791
1866
  return { outcome: "failed", message: `claim: ${said || "unknown answer"}` }
1792
1867
  } catch (e) {
1868
+ // no Qoder on this machine: an answer for the day, not a failure
1869
+ // magpie would ask again every 30 minutes (plugins#304)
1870
+ if (e instanceof RuntimeMissing) return { outcome: "ineligible", message: e.message }
1793
1871
  return { outcome: "failed", message: e?.message ?? String(e), ...(e?.expired ? { signIn: "expired" } : {}) }
1794
1872
  }
1795
1873
  }
@@ -1993,4 +2071,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1993
2071
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1994
2072
 
1995
2073
  // for tests
1996
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, SITES, decodeBody, events }
2074
+ export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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",