@magpie-community/opencode-qoder-auth 0.2.3 → 0.2.5

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 +9 -1
  2. package/index.mjs +71 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,6 +62,10 @@ itself.
62
62
  - The nearest level to the one asked, the higher one on a tie.
63
63
  - The model's default when none is asked.
64
64
  - The lowest level for "none", when the model can't turn thinking off.
65
+ - It picks the context window (`context_length`) from the model's own
66
+ windows (Qoder's Context setting: 200K, 400K, 1M): the default while the
67
+ request fits it, else the smallest that holds it, else the largest. A
68
+ larger window may cost more, so it is asked for only when needed.
65
69
  - It encodes the body with the client's codec and signs the call with the
66
70
  client's COSY envelope. The envelope carries:
67
71
  - the account, AES-encrypted, with the key wrapped by Qoder's RSA key;
@@ -89,7 +93,11 @@ itself.
89
93
  The `provider.models` hook reads the account's own list, as Qoder's client
90
94
  asks for it (`/algo/api/v2/model/list`). It keeps the enabled chat models
91
95
  and leaves out "auto" and "default", which route inside Qoder. It takes
92
- each model's reasoning levels from its `thinking_config`.
96
+ each model's reasoning levels from its `thinking_config`, and its context
97
+ from its `context_config`: the largest window it offers (Qoder's docs
98
+ give Qwen3.8-Flash 200K, 400K and 1M), not `max_input_tokens` (180K
99
+ there), which is only what a request naming no window gets. A model listed
100
+ with no windows keeps `max_input_tokens`.
93
101
 
94
102
  The `config` hook declares the list as it was on 2026-09-30:
95
103
 
package/index.mjs CHANGED
@@ -340,15 +340,51 @@ function rateOf(raw) {
340
340
  return { rate, rateWas }
341
341
  }
342
342
 
343
+ // windowsOf reads the context windows a listing entry offers, smallest
344
+ // first, and the one Qoder's client picks unless asked for another:
345
+ // context_config's entries ({"200k": {token_count: 200000, is_default:
346
+ // true}, "1m": {token_count: 1000000}, …}), else available_context_windows,
347
+ // as Qoder's client reads them (qodercli's catalog). Qoder's Context
348
+ // setting (200K / 400K / 1M) offers them on Qwen3.8-Flash and the rest;
349
+ // max_input_tokens (180K there) is only what a request that names none
350
+ // gets.
351
+ function windowsOf(raw) {
352
+ const n = (v) => (Number.isInteger(v) && v > 0 ? v : 0)
353
+ const windows = []
354
+ let def = 0
355
+ const cc = raw?.context_config ?? raw?.contextConfig
356
+ if (cc && typeof cc === "object" && !Array.isArray(cc)) {
357
+ for (const e of Object.values(cc)) {
358
+ const t = n(e?.token_count)
359
+ if (!t) continue
360
+ if (!windows.includes(t)) windows.push(t)
361
+ if (e.is_default === true || e.is_default === 1 || e.is_default === "1") def = t
362
+ }
363
+ }
364
+ if (!windows.length) {
365
+ const list = raw?.available_context_windows ?? raw?.availableContextWindows
366
+ for (const v of Array.isArray(list) ? list : []) if (n(v) && !windows.includes(v)) windows.push(v)
367
+ def = n(raw?.default_context_window ?? raw?.defaultContextWindow)
368
+ if (!windows.includes(def)) def = 0
369
+ }
370
+ windows.sort((a, b) => a - b)
371
+ return { windows, defaultWindow: def }
372
+ }
373
+
343
374
  // modelInfo reads one entry of the listing's "chat" array: its efforts and
344
- // default from thinking_config (is_reasoning alone when it has none).
375
+ // default from thinking_config (is_reasoning alone when it has none), and
376
+ // its context: the largest window it offers (windowsOf), since a request
377
+ // that needs more than the default is sent a larger one (windowFor).
345
378
  function modelInfo(raw) {
379
+ const { windows, defaultWindow } = windowsOf(raw)
346
380
  const m = {
347
381
  key: raw.key,
348
382
  source: raw.source ?? "",
349
383
  name: raw.display_name || raw.key,
350
384
  images: !!raw.is_vl,
351
- context: raw.max_input_tokens || 0,
385
+ context: windows.length ? windows[windows.length - 1] : raw.max_input_tokens || 0,
386
+ windows,
387
+ defaultWindow,
352
388
  thinks: !!raw.is_reasoning,
353
389
  alwaysThinks: false,
354
390
  efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
@@ -533,6 +569,22 @@ function qoderMessages(msgs) {
533
569
  return out
534
570
  }
535
571
 
572
+ // windowFor is the context window a request is sent (context_length) on a
573
+ // model that offers several: the one Qoder's client picks by default
574
+ // (max_input_tokens without one) while the request fits it, else the
575
+ // smallest that holds it, else the largest. A larger window may cost more
576
+ // (Qoder: changing the Context "may change the rate"), so it is asked for
577
+ // only when the conversation needs it. The request's size is counted
578
+ // generously, a token for every 3 bytes of its UTF-8 JSON, so a window is
579
+ // raised a little early rather than late.
580
+ function windowFor(chat, m) {
581
+ const base = m.defaultWindow || (m.config?.max_input_tokens > 0 ? m.config.max_input_tokens : 0) || m.context
582
+ if (!m.windows?.length) return m.context
583
+ const need = Math.ceil(Buffer.byteLength(JSON.stringify({ m: chat.messages ?? [], t: chat.tools ?? [] })) / 3)
584
+ if (base > 0 && need <= base) return base
585
+ return m.windows.find((w) => w >= need) ?? m.windows[m.windows.length - 1]
586
+ }
587
+
536
588
  // qoderBody is the plaintext agent_chat_generation takes for a chat
537
589
  // completion request, for model m.
538
590
  function qoderBody(chat, m) {
@@ -544,7 +596,8 @@ function qoderBody(chat, m) {
544
596
  const [thinking, effort] = effortFor(chat.reasoning_effort, m)
545
597
  const params = { enable_thinking: thinking, max_tokens: chat.max_completion_tokens || chat.max_tokens || 32000 }
546
598
  if (effort) params.reasoning_effort = effort
547
- if (m.context > 0) params.context_length = m.context
599
+ const window = windowFor(chat, m)
600
+ if (window > 0) params.context_length = window
548
601
  const body = {
549
602
  parameters: params,
550
603
  business: { product: "app", version: COSY_VERSION, type: "agent", id: hexID(), name: "magpie session", begin_at: Date.now(), stage: "start" },
@@ -726,6 +779,7 @@ async function* events(body) {
726
779
  yield { error: failure(Number(env.statusCodeValue), inner || payload) }
727
780
  return
728
781
  }
782
+ if (inner.trim() === "[DONE]") break
729
783
  let chunk
730
784
  try {
731
785
  chunk = JSON.parse(inner)
@@ -749,12 +803,19 @@ async function* events(body) {
749
803
  yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
750
804
  } else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
751
805
  }
752
- fr = chunk?.choices?.[0]?.finish_reason ?? ""
753
- if (fr) {
754
- const u = chunk.usage
755
- if (u) usage = { prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0, total_tokens: (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0) }
756
- break
757
- }
806
+ fr = chunk?.choices?.[0]?.finish_reason || fr
807
+ // the usage comes in a chunk of its own after the finish, with no
808
+ // choices, as the built-in read it (qoder.go): every request was
809
+ // counted as 0 tokens when the finish ended the reading
810
+ const u = chunk?.usage
811
+ if (u)
812
+ usage = {
813
+ prompt_tokens: u.prompt_tokens ?? 0,
814
+ completion_tokens: u.completion_tokens ?? 0,
815
+ total_tokens: (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0),
816
+ ...(u.prompt_tokens_details?.cached_tokens ? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } : {}),
817
+ ...(u.completion_tokens_details?.reasoning_tokens ? { completion_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}),
818
+ }
758
819
  }
759
820
  yield* flushed()
760
821
  yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
@@ -1245,4 +1306,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1245
1306
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1246
1307
 
1247
1308
  // for tests
1248
- export const _internal = { parseUsage, gfmt, when, failure, modelInfo, SITES, decodeBody }
1309
+ export const _internal = { parseUsage, gfmt, when, failure, modelInfo, qoderBody, 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.3",
3
+ "version": "0.2.5",
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",