@magpie-community/opencode-qoder-auth 0.2.28 → 0.2.31

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 +36 -6
  2. package/index.mjs +150 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -98,10 +98,20 @@ itself.
98
98
  `cache_control: ephemeral` (nothing is marked when the last turn is a
99
99
  tool's result), user turns carry their blocks as `contents` too, and each
100
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`.
101
+ the session the agent names (since 0.2.31, #330: DSH's
102
+ `x-deepseek-harness-session-id` and the other agents' own session
103
+ headers, which magpie hands the plugin's `chat.headers` hook, and
104
+ OpenCode's session), else of Codex's thread (`prompt_cache_key`) when
105
+ magpie sends one, else of the conversation's start, so every turn of one
106
+ conversation names the same session; the key itself never goes out.
107
+ Each request has its own `request_id` and `chat_record_id`.
108
+ - Every call that answers one user prompt goes out as one task, as Qoder's
109
+ own client sends it (since 0.2.31, #330): one `request_set_id`, which is
110
+ also `business.id`, and one `business.begin_at` for every call of the
111
+ tool loop, `business.stage` "start" until a call is answered and
112
+ "processing" after. A new user prompt in the session is a new task. The
113
+ plugin keeps at most 512 tasks, each for 2 hours after its last call.
114
+ Before 0.2.31 each call was a task of its own.
105
115
  - The model's own configuration from Qoder's list goes with it.
106
116
  - It picks the reasoning effort from the model's own levels:
107
117
  - The nearest level to the one asked, the higher one on a tie.
@@ -285,8 +295,28 @@ the account's credentials.
285
295
  international account gets no daily claim until Qoder is installed.
286
296
  - A helper that is there but can't be read, or gives an invalid identity,
287
297
  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.
298
+ result.
299
+
300
+ Qoder CN (since 0.2.30) sends its own device identity the same way when
301
+ Qoder CN is installed: its desktop 0.4.9 and CLI (`qoderclicn`) both ask
302
+ Qoder CN's `runtime-info` for environment `0` (the international one is
303
+ `3`) and send it on the campaigns pages (yetone/magpie#2911, #304).
304
+
305
+ - The plugin finds Qoder CN desktop's `resources/umid` (`Qoder CN.app` on
306
+ macOS, `%LOCALAPPDATA%\Programs\Qoder CN` or `%ProgramFiles%\Qoder CN`
307
+ on Windows, `/opt/Qoder-CN` on Linux) or the runtime its CLI caches in
308
+ `~/.qoder-cn/.bin` (`QODERCN_CONFIG_DIR`, `QODERCN_CLI_HOME` and
309
+ `QODERCN_CONFIG_DIR_NAME` move it). `QODERCN_RUNTIME_INFO` names
310
+ another location; `QODER_RUNTIME_INFO` is only the international one's.
311
+ - Without it the requests go out as before, as Qoder CN's desktop does
312
+ when it can't read the identity, and a claim the list still offers is
313
+ made. When the list then offers no daily claim, the check-in is
314
+ `ineligible` for the day with a message to install Qoder CN desktop (or
315
+ sign in to its CLI); a helper that is there but can't be read makes it
316
+ `failed`, asked again later.
317
+ - Not tried with a real Qoder CN account: whether Qoder CN now hides the
318
+ daily claim from a request without the identity, as the international
319
+ site does, is inferred from its clients, not seen.
290
320
 
291
321
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
292
322
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
package/index.mjs CHANGED
@@ -841,12 +841,16 @@ function cacheMarked(msgs) {
841
841
  // sessionOf is the session_id a request goes out under: one for every turn
842
842
  // of a conversation and another for each conversation, as Qoder's client
843
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
844
+ // prompt is cached only when its turns say they are one. It is the session
845
+ // the agent named (named: DSH's x-deepseek-harness-session-id, OpenCode's
846
+ // session, as the chat.headers hook is told it, #330), else the client's
847
+ // prompt_cache_key (Codex's thread), else its user, else the
846
848
  // 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 : ""
849
+ // so no key of the client's goes to Qoder as it is. DSH changes its system
850
+ // prompt mid-task (yetone/magpie#3298), so its own session is the one that
851
+ // holds for the whole task.
852
+ function sessionOf(chat, named = "") {
853
+ let seed = named ? "session\0" + named : String(chat.prompt_cache_key ?? "").trim() ? "key\0" + chat.prompt_cache_key : String(chat.user ?? "").trim() ? "user\0" + chat.user : ""
850
854
  if (!seed) {
851
855
  const msgs = chat.messages ?? []
852
856
  const sys = msgs.filter((x) => x.role === "system" || x.role === "developer").map((x) => textOf(x.content))
@@ -857,9 +861,62 @@ function sessionOf(chat) {
857
861
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`
858
862
  }
859
863
 
864
+ // SESSION carries the session magpie (or OpenCode) names a request's
865
+ // conversation by, from the chat.headers hook to the loader's fetch. It
866
+ // goes no further: nothing of the request's headers is sent to Qoder.
867
+ const SESSION = "x-magpie-qoder-session"
868
+
869
+ // namedSession is the session a chat.headers hook is told, "" when it
870
+ // names none. magpie, with no session header from the agent, makes one of
871
+ // the conversation's first user message ("magpie-" and 24 hex digits):
872
+ // that names no session, and sessionOf's own hash is taken instead.
873
+ function namedSession(input) {
874
+ const s = String(input?.sessionID ?? "").trim()
875
+ if (!s || /^magpie-[0-9a-f]{24}$/.test(s)) return ""
876
+ return s.slice(0, 128)
877
+ }
878
+
879
+ // A task is what Qoder's client sends under one request_set_id and one
880
+ // business record (#330): every model call that answers one user prompt,
881
+ // the tool loop's turns included. qoder-agent-sdk (Qoder.app's
882
+ // qoder-worker-runtime, AgentLifecycle) makes the record once per prompt
883
+ // (business.id the prompt's requestSetId, begin_at when the prompt
884
+ // started, stage "start"), keeps it for each call of the loop, and moves
885
+ // stage to "processing" once a call is answered; only request_id and
886
+ // chat_record_id are new on every call. The plugin made a new record for
887
+ // every call, so a 265-call DSH task was 265 tasks to Qoder. A task here is
888
+ // a session's last user turn: the tool loop repeats it, and a new prompt
889
+ // is a new one. TASKS holds at most TASK_MAX of them, each for TASK_IDLE
890
+ // after its last call, so a long-running magpie doesn't grow with them.
891
+ const TASKS = new Map()
892
+ const TASK_MAX = 512
893
+ const TASK_IDLE = 2 * 60 * 60 * 1000
894
+
895
+ // taskOf is the task chat's call belongs to, in session sid: made on its
896
+ // first call, the same one on every call after.
897
+ function taskOf(sid, chat, now = Date.now()) {
898
+ const msgs = chat.messages ?? []
899
+ const at = msgs.findLastIndex((x) => x?.role === "user")
900
+ const key = createHash("sha256").update(JSON.stringify([sid, at, at < 0 ? null : msgs[at].content ?? null])).digest("hex")
901
+ for (const [k, t] of TASKS) {
902
+ if (now - t.used <= TASK_IDLE) break
903
+ TASKS.delete(k)
904
+ }
905
+ let t = TASKS.get(key)
906
+ if (t && now - t.used > TASK_IDLE) t = undefined
907
+ TASKS.delete(key)
908
+ t ??= { id: randomUUID(), begin: now, answered: false }
909
+ t.used = now
910
+ TASKS.set(key, t)
911
+ while (TASKS.size > TASK_MAX) TASKS.delete(TASKS.keys().next().value)
912
+ return t
913
+ }
914
+
860
915
  // qoderBody is the plaintext agent_chat_generation takes for a chat
861
- // completion request, for model m.
862
- function qoderBody(chat, m) {
916
+ // completion request, for model m, in the session the agent named (named,
917
+ // "" when none) and the task it is a call of (taskOf's, a new one when
918
+ // none is given).
919
+ function qoderBody(chat, m, named = "", task) {
863
920
  const system = (chat.messages ?? []).filter((x) => x.role === "system" || x.role === "developer").map((x) => textOf(x.content)).filter(Boolean).join("\n\n")
864
921
  const tools = chat.tool_choice !== "none" ? (chat.tools ?? []).filter((t) => t?.type === "function" && t.function?.name) : []
865
922
  let sysText = system ? QODER_SYS + "\n\n" + system : QODER_SYS
@@ -871,13 +928,15 @@ function qoderBody(chat, m) {
871
928
  const window = windowFor(chat, m, params.max_tokens)
872
929
  if (window > 0) params.context_length = window
873
930
  const request = randomUUID()
931
+ const session = sessionOf(chat, named)
932
+ task ??= taskOf(session, chat)
874
933
  const body = {
875
934
  request_id: request,
876
- request_set_id: request,
935
+ request_set_id: task.id,
877
936
  chat_record_id: request,
878
- session_id: sessionOf(chat),
937
+ session_id: session,
879
938
  parameters: params,
880
- business: { product: "app", version: COSY_VERSION, type: "agent", id: hexID(), name: "magpie session", begin_at: Date.now(), stage: "start" },
939
+ business: { product: "app", version: COSY_VERSION, type: "agent", id: task.id, name: "magpie session", begin_at: task.begin, stage: task.answered ? "processing" : "start" },
881
940
  agent_id: "agent_common",
882
941
  task_id: "common",
883
942
  session_type: "app",
@@ -1526,23 +1585,45 @@ const CAMPAIGN_MACHINE_TTL = 60 * 60 * 1000 // Qoder's desktop client renews its
1526
1585
  // (or .qoder): a file runtime-info-<platform>-<arch>-<hash> on macOS and
1527
1586
  // Linux, a folder umid-win32-<arch>-<hash> holding runtime-info.exe on
1528
1587
  // Windows.
1529
- async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt" } = {}) {
1530
- if (env.QODER_RUNTIME_INFO) return resolve(env.QODER_RUNTIME_INFO)
1588
+ //
1589
+ // Qoder CN keeps its own (Qoder CN desktop 0.4.9's packages, qoderclicn
1590
+ // 1.1.77): Qoder CN.app on macOS (its installer's expectedBundleName),
1591
+ // /opt/Qoder-CN on Linux (the .deb), Qoder CN on Windows (the user
1592
+ // installer's "Uninstall Qoder CN.exe", electron-builder's
1593
+ // Programs\<productName>); its CLI's config is QODERCN_CONFIG_DIR, else
1594
+ // QODERCN_CLI_HOME (or home) joined with QODERCN_CONFIG_DIR_NAME (or
1595
+ // .qoder-cn). Both ask the helper for environment 0, the international
1596
+ // ones for 3 (the desktop's `regionCode === "global" ? 3 : 0`, the CLI's
1597
+ // UMID environment ONLINE = 0, US_ONLINE = 3).
1598
+ //
1599
+ // required: international Qoder lists the daily credits only on a request
1600
+ // carrying the identity. Qoder CN's desktop sends it when it can read it
1601
+ // and goes on without it when it can't, and a Qoder CN account was offered
1602
+ // and claimed the credits without one (0.2.21), so there it is sent when
1603
+ // Qoder CN is installed and left out when it isn't.
1604
+ const RUNTIMES = {
1605
+ qoder: { name: "Qoder", environment: "3", override: "QODER_RUNTIME_INFO", app: "Qoder", linux: "Qoder", cli: "QODER_", dir: ".qoder", required: true },
1606
+ "qoder-cn": { name: "Qoder CN", environment: "0", override: "QODERCN_RUNTIME_INFO", app: "Qoder CN", linux: "Qoder-CN", cli: "QODERCN_", dir: ".qoder-cn", required: false },
1607
+ }
1608
+
1609
+ async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt", runtime = RUNTIMES.qoder } = {}) {
1610
+ if (env[runtime.override]) return resolve(env[runtime.override])
1531
1611
  const name = platform === "win32" ? "runtime-info.exe" : "runtime-info"
1532
1612
  const isFile = async (path) => (await stat(path).catch(() => null))?.isFile() ?? false
1533
1613
  const paths = []
1534
1614
  if (platform === "win32") {
1535
1615
  const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
1536
- paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
1616
+ paths.push(join(local, "Programs", runtime.app, "resources", "umid", name))
1537
1617
  // the machine-wide installer, as VS Code's system setup places it
1538
- for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, "Qoder", "resources", "umid", name))
1618
+ for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, runtime.app, "resources", "umid", name))
1539
1619
  } else if (platform === "darwin") {
1540
- for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
1620
+ for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, runtime.app + ".app", "Contents", "Resources", "umid", name))
1541
1621
  } else if (platform === "linux") {
1542
- paths.push(join(opt, "Qoder", "resources", "umid", name))
1622
+ paths.push(join(opt, runtime.linux, "resources", "umid", name))
1543
1623
  }
1544
1624
  for (const path of paths) if (await isFile(path)) return path
1545
- const config = env.QODER_CONFIG_DIR || join(env.QODER_CLI_HOME || env.GEMINI_CLI_HOME || home, env.QODER_CONFIG_DIR_NAME || ".qoder")
1625
+ const cli = (key) => env[runtime.cli + key]
1626
+ const config = cli("CONFIG_DIR") || join(cli("CLI_HOME") || env.GEMINI_CLI_HOME || home, cli("CONFIG_DIR_NAME") || runtime.dir)
1546
1627
  const cache = join(config, ".bin")
1547
1628
  const entries = await readdir(cache, { withFileTypes: true }).catch(() => [])
1548
1629
  const found = []
@@ -1556,7 +1637,7 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1556
1637
  if (path && (await isFile(path))) found.push({ path, time: (await stat(path)).mtimeMs })
1557
1638
  }
1558
1639
  if (found.length) return found.sort((a, b) => b.time - a.time)[0].path
1559
- throw new RuntimeMissing()
1640
+ throw new RuntimeMissing(runtime)
1560
1641
  }
1561
1642
 
1562
1643
  // RuntimeMissing: no Qoder desktop or CLI on this machine to read the
@@ -1566,19 +1647,21 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1566
1647
  // never makes up. Asking again won't change that until the user installs
1567
1648
  // Qoder, so the check-in says what to do once instead of "tries again later".
1568
1649
  class RuntimeMissing extends Error {
1569
- constructor() {
1650
+ constructor(runtime = RUNTIMES.qoder) {
1651
+ const { name, override } = runtime
1570
1652
  super(
1571
- "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",
1653
+ `${name} check-in: device identity runtime not found. ${name} gives the daily credits only to a device its own app identifies, and this machine has neither ${name} desktop nor ${name} CLI to read that identity from. Install ${name} desktop (or install ${name} CLI and sign in to it once), or set ${override} to its runtime-info executable; magpie checks in again the next day`,
1572
1654
  )
1573
1655
  }
1574
1656
  }
1575
1657
 
1576
- // Read the SDK identity as Qoder does: environment 3 is international; on
1577
- // Windows and macOS the uid goes over stdin, never into the command line.
1578
- async function campaignMachine(uid) {
1658
+ // Read the SDK identity as Qoder does: environment 3 is international, 0
1659
+ // Qoder CN; on Windows and macOS the uid goes over stdin, never into the
1660
+ // command line.
1661
+ async function campaignMachine(uid, runtime = RUNTIMES.qoder) {
1579
1662
  if (!uid) throw new Error("Qoder check-in: device identity needs the account uid; sign in again")
1580
- const file = await campaignRuntime()
1581
- const args = process.platform === "linux" ? ["3"] : ["3", "--account-stdin"]
1663
+ const file = await campaignRuntime({ runtime })
1664
+ const args = process.platform === "linux" ? [runtime.environment] : [runtime.environment, "--account-stdin"]
1582
1665
  const text = await new Promise((resolve, reject) => {
1583
1666
  const child = execFile(file, args, { timeout: 20_000, windowsHide: true, maxBuffer: 1 << 20 }, (err, out) => {
1584
1667
  if (err) reject(err)
@@ -1587,7 +1670,7 @@ async function campaignMachine(uid) {
1587
1670
  child.stdin?.on("error", () => {})
1588
1671
  child.stdin?.end(process.platform === "linux" ? undefined : JSON.stringify({ account: uid }) + "\n")
1589
1672
  }).catch(() => {
1590
- throw new Error("Qoder check-in: could not read device identity; restart or update Qoder, or check QODER_RUNTIME_INFO")
1673
+ throw new Error(`${runtime.name} check-in: could not read device identity; restart or update ${runtime.name}, or check ${runtime.override}`)
1591
1674
  })
1592
1675
  try {
1593
1676
  const identity = JSON.parse(text.trim().split(/\r?\n/, 1)[0])
@@ -1599,7 +1682,7 @@ async function campaignMachine(uid) {
1599
1682
  }
1600
1683
  return headers
1601
1684
  } catch {
1602
- throw new Error("Qoder check-in: invalid device identity from Qoder; restart or update Qoder")
1685
+ throw new Error(`${runtime.name} check-in: invalid device identity from ${runtime.name}; restart or update ${runtime.name}`)
1603
1686
  }
1604
1687
  }
1605
1688
 
@@ -1754,12 +1837,13 @@ const makePlugin = (site) => async ({ client }) => {
1754
1837
  const ID = site.id
1755
1838
  // The SDK identity is account-specific. Share an in-flight read, refresh
1756
1839
  // after an hour, and leave a failed read available for the next attempt.
1840
+ const runtime = RUNTIMES[ID]
1757
1841
  const campaignMachines = new Map()
1758
1842
  const machineForCampaign = async (uid) => {
1759
1843
  const now = Date.now()
1760
1844
  let entry = campaignMachines.get(uid)
1761
1845
  if (!entry || now - entry.at >= CAMPAIGN_MACHINE_TTL) {
1762
- entry = { at: now, promise: campaignMachine(uid) }
1846
+ entry = { at: now, promise: campaignMachine(uid, runtime) }
1763
1847
  campaignMachines.set(uid, entry)
1764
1848
  }
1765
1849
  try {
@@ -1769,6 +1853,16 @@ const makePlugin = (site) => async ({ client }) => {
1769
1853
  throw e
1770
1854
  }
1771
1855
  }
1856
+ // campaignIdentity: the headers a campaigns request carries, and why
1857
+ // there are none where the site goes on without them (Qoder CN).
1858
+ const campaignIdentity = async (uid) => {
1859
+ if (runtime.required) return { machine: await machineForCampaign(uid) }
1860
+ try {
1861
+ return { machine: await machineForCampaign(uid) }
1862
+ } catch (e) {
1863
+ return { machine: {}, unread: e }
1864
+ }
1865
+ }
1772
1866
  // serializes checking, rotating and saving tokens: a refresh token is
1773
1867
  // spent once, so two refreshes would spend it twice
1774
1868
  let lock = Promise.resolve()
@@ -1927,7 +2021,7 @@ const makePlugin = (site) => async ({ client }) => {
1927
2021
  if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1928
2022
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1929
2023
  try {
1930
- const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
2024
+ const { machine } = await campaignIdentity(cred.uid)
1931
2025
  // A PAT account has no device token: its job token signs the campaigns
1932
2026
  // pages too, as it does the usage read. Nothing is rotated on that
1933
2027
  // path — the refusal is the answer.
@@ -1956,7 +2050,7 @@ const makePlugin = (site) => async ({ client }) => {
1956
2050
  const base = campaignTarget(site, siteOf(site, cred), site.openapi + CAMPAIGNS)
1957
2051
  if (!base) return { outcome: "inactive", message: "no campaigns page for this account" }
1958
2052
  try {
1959
- const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
2053
+ const { machine, unread } = await campaignIdentity(cred.uid)
1960
2054
  // A PAT account has no device token: the campaigns pages take the job
1961
2055
  // token the exchange gave, as the usage endpoint does. A live Qoder CN
1962
2056
  // PAT account answered 200 to the list and CLAIMED to a claim, so the
@@ -1994,7 +2088,13 @@ const makePlugin = (site) => async ({ client }) => {
1994
2088
  const amount = Number(done.benefit?.amount)
1995
2089
  return { outcome: "done", ...(Number.isFinite(amount) && amount > 0 ? { credit: amount } : {}) }
1996
2090
  }
1997
- return campaigns.length ? { outcome: "failed", message: "campaigns: today's credits aren't offered yet" } : { outcome: "inactive" }
2091
+ if (campaigns.length) return { outcome: "failed", message: "campaigns: today's credits aren't offered yet" }
2092
+ // Asked without Qoder CN's identity and offered nothing: what an
2093
+ // international list says to a request without one, so say why
2094
+ // (no Qoder CN on this machine) or ask again (it didn't answer).
2095
+ if (unread instanceof RuntimeMissing) return { outcome: "ineligible", message: unread.message }
2096
+ if (unread) return { outcome: "failed", message: unread.message }
2097
+ return { outcome: "inactive" }
1998
2098
  }
1999
2099
  const claimRes = await call(base.replace(/\/$/, "") + "/" + encodeURIComponent(campaign.campaignId) + "/claim", "POST", "{}")
2000
2100
  let claim = {}
@@ -2028,7 +2128,7 @@ const makePlugin = (site) => async ({ client }) => {
2028
2128
  })
2029
2129
 
2030
2130
  // ask is the answer to a chat completion on the live account cred.
2031
- const ask = async (chat, cred, init) => {
2131
+ const ask = async (chat, cred, init, named = "") => {
2032
2132
  let m
2033
2133
  try {
2034
2134
  m = (await models(cred)).find((x) => x.key === chat.model) ?? (await models(cred, true)).find((x) => x.key === chat.model)
@@ -2039,7 +2139,8 @@ const makePlugin = (site) => async ({ client }) => {
2039
2139
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
2040
2140
  const api = siteOf(site, cred).api
2041
2141
  const CHAT_URL = api + CHAT_PATH
2042
- const body = qoderBody(chat, m)
2142
+ const task = taskOf(sessionOf(chat, named), chat)
2143
+ const body = qoderBody(chat, m, named, task)
2043
2144
  const wire = encodeBody(JSON.stringify(body))
2044
2145
  // Qoder's queue is waited out (queueWait) and the same request, its
2045
2146
  // request_set_id the one the queue knows, sent again
@@ -2061,6 +2162,8 @@ const makePlugin = (site) => async ({ client }) => {
2061
2162
  return errorResponse({ status: 502, message: e.message })
2062
2163
  }
2063
2164
  const f = !res.ok ? failure(res.status, (await res.text()).slice(0, 1 << 20)) : open.first.value?.error
2165
+ // an answered call moves its task on, as Qoder's client moves it
2166
+ if (!f) task.answered = true
2064
2167
  if (!f?.queue || waited >= QUEUE.recoveries) return f && !res.ok ? errorResponse(f) : answer(open, chat)
2065
2168
  try {
2066
2169
  if (!(await queueWait(api, cred, ask, f.queue, init.signal, deadline))) return errorResponse(f)
@@ -2071,6 +2174,16 @@ const makePlugin = (site) => async ({ client }) => {
2071
2174
  }
2072
2175
 
2073
2176
  return {
2177
+ // the session the request is part of, for session_id and its task
2178
+ // (#330): magpie names it here (DSH's x-deepseek-harness-session-id
2179
+ // among the agents' own), as OpenCode does, and the loader's fetch
2180
+ // reads it back. Every plugin's hook sees every provider's requests,
2181
+ // so it is put on this site's alone.
2182
+ async "chat.headers"(input, output) {
2183
+ if (input?.model?.providerID !== ID && input?.provider?.info?.id !== ID) return
2184
+ const s = namedSession(input)
2185
+ if (s) output.headers[SESSION] = s
2186
+ },
2074
2187
  auth: {
2075
2188
  provider: ID,
2076
2189
  // magpie renews the chat token RENEW_LEAD before its end, once for
@@ -2123,7 +2236,9 @@ const makePlugin = (site) => async ({ client }) => {
2123
2236
  } catch (e) {
2124
2237
  return signedInError(e)
2125
2238
  }
2126
- return signed(await ask(chat, cred, init), renewed(cred))
2239
+ const h = new Headers(input instanceof Request ? input.headers : undefined)
2240
+ new Headers(init.headers ?? {}).forEach((v, k) => h.set(k, v))
2241
+ return signed(await ask(chat, cred, init, h.get(SESSION)?.trim().slice(0, 128) ?? ""), renewed(cred))
2127
2242
  },
2128
2243
  }
2129
2244
  },
@@ -2233,4 +2348,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
2233
2348
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
2234
2349
 
2235
2350
  // for tests
2236
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events, QUEUE, queueState }
2351
+ export const _internal = { campaignRuntime, RUNTIMES, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, namedSession, taskOf, TASKS, TASK_MAX, TASK_IDLE, SESSION, SITES, decodeBody, events, QUEUE, queueState }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.28",
3
+ "version": "0.2.31",
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",