@magpie-community/opencode-qoder-auth 0.2.24 → 0.2.26

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 +19 -3
  2. package/index.mjs +70 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,6 +22,11 @@ hosts; [its section](#qoder-cn) says what differs.
22
22
  3. The device token is traded for a job token, and the account's email and
23
23
  name are read.
24
24
 
25
+ The page is asked for no `redirect_uri`, as Qoder's CLI asks for none, so
26
+ the sign-in ends in the browser tab even when Qoder.app is installed
27
+ (before 0.2.25 it sent the desktop app's `qoder-app://`, which handed the
28
+ page to Qoder.app).
29
+
25
30
  **Sign in with Qoder PAT** (and **Sign in with Qoder CN PAT**) skips the
26
31
  device flow: a Personal Access Token from `<site>/account/integrations` is
27
32
  posted to `/api/v1/jobToken/exchange` (`personal_token`), which answers a
@@ -86,6 +91,17 @@ itself.
86
91
  answers no call before it. A result with no call left to answer goes
87
92
  as text in the user's turn.
88
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`.
89
105
  - The model's own configuration from Qoder's list goes with it.
90
106
  - It picks the reasoning effort from the model's own levels:
91
107
  - The nearest level to the one asked, the higher one on a tie.
@@ -178,7 +194,7 @@ Qoder CN's own CLI builds in for its `cn` build (`@qodercn-ai/qoderclicn`
178
194
  | Accounts: poll, tokens, user info, usage | openapi.qoder.sh | openapi.qoder.com.cn |
179
195
  | Models and chat | api3.qoder.sh | gateway.qoder.com.cn |
180
196
  | Device-flow client id | Qoder's desktop client's | Qoder CN's CLI's (production) |
181
- | `redirect_uri` on the sign-in page | `qoder-app://` | none, as the CLI sends |
197
+ | `redirect_uri` on the sign-in page | none, as Qoder's CLI sends (since 0.2.25) | none, as the CLI sends |
182
198
 
183
199
  Qoder CN's CLI chats with the device token itself and never makes a job
184
200
  token. The plugin asks qoder.cn for a job token first, as it does on
@@ -252,8 +268,8 @@ the account's credentials.
252
268
 
253
269
  - Install Qoder desktop, or sign in to Qoder CLI once, to make its native
254
270
  runtime available. The plugin finds the desktop's `resources/umid`
255
- (`Qoder.app` on macOS, `%LOCALAPPDATA%\Programs\Qoder` on Windows,
256
- `/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`
257
273
  (`runtime-info-<platform>-<arch>-*` on macOS and Linux,
258
274
  `umid-win32-x64-*\runtime-info.exe` on Windows; `QODER_CONFIG_DIR`,
259
275
  `QODER_CLI_HOME` and `QODER_CONFIG_DIR_NAME` move it as they move the
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") {
@@ -1504,7 +1565,10 @@ const SITES = {
1504
1565
  web: "https://qoder.com",
1505
1566
  openapi: "https://openapi.qoder.sh",
1506
1567
  api: "https://api3.qoder.sh",
1507
- redirect: "qoder-app://",
1568
+ // none, as Qoder's CLI (@qoder-ai/qodercli 1.1.75) sends: the desktop
1569
+ // app's qoder-app:// hands the finished page to Qoder.app, when it is
1570
+ // installed, instead of leaving it in the tab while this polls (#276)
1571
+ redirect: "",
1508
1572
  models: MODELS,
1509
1573
  },
1510
1574
  "qoder-cn": {
@@ -1990,4 +2054,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1990
2054
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1991
2055
 
1992
2056
  // for tests
1993
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, SITES, decodeBody, events }
2057
+ 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.24",
3
+ "version": "0.2.26",
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",