@magpie-community/opencode-qoder-auth 0.2.30 → 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 +14 -4
  2. package/index.mjs +88 -14
  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.
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",
@@ -2069,7 +2128,7 @@ const makePlugin = (site) => async ({ client }) => {
2069
2128
  })
2070
2129
 
2071
2130
  // ask is the answer to a chat completion on the live account cred.
2072
- const ask = async (chat, cred, init) => {
2131
+ const ask = async (chat, cred, init, named = "") => {
2073
2132
  let m
2074
2133
  try {
2075
2134
  m = (await models(cred)).find((x) => x.key === chat.model) ?? (await models(cred, true)).find((x) => x.key === chat.model)
@@ -2080,7 +2139,8 @@ const makePlugin = (site) => async ({ client }) => {
2080
2139
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
2081
2140
  const api = siteOf(site, cred).api
2082
2141
  const CHAT_URL = api + CHAT_PATH
2083
- const body = qoderBody(chat, m)
2142
+ const task = taskOf(sessionOf(chat, named), chat)
2143
+ const body = qoderBody(chat, m, named, task)
2084
2144
  const wire = encodeBody(JSON.stringify(body))
2085
2145
  // Qoder's queue is waited out (queueWait) and the same request, its
2086
2146
  // request_set_id the one the queue knows, sent again
@@ -2102,6 +2162,8 @@ const makePlugin = (site) => async ({ client }) => {
2102
2162
  return errorResponse({ status: 502, message: e.message })
2103
2163
  }
2104
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
2105
2167
  if (!f?.queue || waited >= QUEUE.recoveries) return f && !res.ok ? errorResponse(f) : answer(open, chat)
2106
2168
  try {
2107
2169
  if (!(await queueWait(api, cred, ask, f.queue, init.signal, deadline))) return errorResponse(f)
@@ -2112,6 +2174,16 @@ const makePlugin = (site) => async ({ client }) => {
2112
2174
  }
2113
2175
 
2114
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
+ },
2115
2187
  auth: {
2116
2188
  provider: ID,
2117
2189
  // magpie renews the chat token RENEW_LEAD before its end, once for
@@ -2164,7 +2236,9 @@ const makePlugin = (site) => async ({ client }) => {
2164
2236
  } catch (e) {
2165
2237
  return signedInError(e)
2166
2238
  }
2167
- 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))
2168
2242
  },
2169
2243
  }
2170
2244
  },
@@ -2274,4 +2348,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
2274
2348
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
2275
2349
 
2276
2350
  // for tests
2277
- export const _internal = { campaignRuntime, RUNTIMES, 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.30",
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",