@magpie-community/opencode-trae-auth 0.1.13 → 0.1.15

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 +31 -4
  2. package/index.mjs +264 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,17 +44,31 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
44
44
  `x-app-id`, the device headers and `x-uid`.
45
45
  - **Answer:** Trae always answers in its own SSE events:
46
46
  - `output` gives `response`/`content` and `reasoning_content`/`reasoning`.
47
+ Text under `message`, `delta` or an event with no name is the answer's
48
+ too.
47
49
  - `token_usage`, `done` and `error` mark usage, the end and failures.
50
+ `done`'s `finish_reason` `length`/`max_tokens` is OpenAI's `length`.
48
51
  - Queue events are ignored.
52
+ - A `{"reasoning_content": …}` object written into the text is reasoning,
53
+ and reasoning Trae sends again is sent once.
49
54
 
50
- The plugin turns these into an OpenAI stream or a single body.
55
+ The plugin turns these into an OpenAI stream or a single body. A reply
56
+ that fails (an `error` event, or Trae's stream breaking off) ends with the
57
+ error and `[DONE]`, with no finish.
51
58
  - **Tools:**
52
59
  - Trae's chat has no turn for a tool call or its result, so the tools are
53
60
  named twice:
54
61
  - natively in `tools`, with `parameters` as a JSON string;
55
62
  - in a system prompt that asks for each call as a
56
63
  `<tool_call>{"name", "arguments"}</tool_call>` block.
57
- - Calls from either source become OpenAI `tool_calls`.
64
+ - GLM also writes its own template, `<tool_call>name<arg_key>k</arg_key>
65
+ <arg_value>v</arg_value></tool_call>` (a string kept as written, other
66
+ types read as JSON), and sometimes a whole call into a native call's
67
+ name; both are read as the call.
68
+ - Calls from either source become OpenAI `tool_calls`. A name matching a
69
+ requested tool but for case or punctuation (`Read`) goes on as the
70
+ request's (`read`); any other goes on as written, so the agent can say
71
+ the tool doesn't exist.
58
72
  - Earlier calls and their results go back in as text.
59
73
  - Images aren't sent.
60
74
 
@@ -65,8 +79,21 @@ time, when the batch gives none). The lists are put together: SOLO and the
65
79
  TRAE agent list models `chat_v3` doesn't (deepseek-v4.1-flash is the TRAE
66
80
  agent's, `solo_agent`). Left out are the IDE's helpers (`usage` other than
67
81
  `chat_completion`: summary, fast_apply…), configs switched off and the
68
- custom-model slots. Context is the list's `context_window_tokens.dev`
69
- (`max` is Max mode's), output the `__dev` model's `max_tokens`. When no
82
+ custom-model slots. Context is the list's `context_window_tokens.dev`,
83
+ output the `__dev` model's `max_tokens`. A model with a `__max` model and
84
+ a bigger `context_window_tokens.max` is listed again as its Max,
85
+ `<id>-max` ("… (Max)"), with that window and the `__max` model's
86
+ `max_tokens`; the two can come from different functions' lists (chat_v3
87
+ can name the `__max` model, a SOLO list the windows). It asks the
88
+ `__max` model through the function that names it, with `max_tokens` set
89
+ and `user_message_context.model_info.prompt_max_tokens` the window less
90
+ it, as Max mode does.
91
+
92
+ Reasoning: the models reason, but Trae's request takes no effort or
93
+ thinking level, so the plugin lists no variants and a level an agent
94
+ picks does nothing.
95
+
96
+ When no
70
97
  list can be read, the plugin uses the models Trae CN's `chat_v3` is
71
98
  known to serve: GLM-5.2, GLM-5, Kimi K2.6, Qwen 3.7 Plus, DeepSeek V4 Pro
72
99
  and DeepSeek V4 Flash.
package/index.mjs CHANGED
@@ -449,21 +449,28 @@ const nativeTools = (tools) =>
449
449
  // chatBody is the request for llm_utils_chat; modelName is the model the
450
450
  // function's list names for the config (its __dev one), when it named one,
451
451
  // and most the max_tokens it gives that model (0: none given), which a
452
- // request asking more is held to
453
- function chatBody(req, fn, modelName, most = 0) {
452
+ // request asking more is held to. A Max model (max: {base, window}) asks
453
+ // the config base by its __max model, max_tokens always set, and gives
454
+ // the prompt the room the window has beside it, as the IDE's Max mode does.
455
+ function chatBody(req, fn, modelName, most = 0, max = null) {
454
456
  const session = randomUUID()
457
+ const config = max?.base ?? req.model
455
458
  const body = {
456
459
  messages: traeMessages(req),
457
460
  function: fn,
458
- config_name: req.model,
459
- model: req.model,
461
+ config_name: config,
462
+ model: config,
460
463
  ...(modelName ? { model_name: modelName } : {}),
461
464
  stream: true, // Trae answers in SSE either way
462
465
  request_id: session,
463
466
  session_id: session,
464
467
  }
465
- const max = req.max_completion_tokens ?? req.max_tokens
466
- if (Number.isFinite(max) && max > 0) body.max_tokens = most > 0 ? Math.min(Math.floor(max), most) : Math.floor(max)
468
+ const asked = req.max_completion_tokens ?? req.max_tokens
469
+ if (Number.isFinite(asked) && asked > 0) body.max_tokens = most > 0 ? Math.min(Math.floor(asked), most) : Math.floor(asked)
470
+ if (max?.window) {
471
+ if (!body.max_tokens && most > 0) body.max_tokens = most
472
+ body.user_message_context = { model_info: { prompt_max_tokens: max.window - (body.max_tokens || 0) } }
473
+ }
467
474
  if (typeof req.temperature === "number") body.temperature = req.temperature
468
475
  if (Array.isArray(req.tools) && req.tools.length && req.tool_choice !== "none") {
469
476
  body.tools = nativeTools(req.tools)
@@ -543,14 +550,21 @@ const callId = () => "call_" + randomBytes(12).toString("hex") + (callSeq++).toS
543
550
  // once whole even when DeepSeek closes it with its own tags in place of
544
551
  // </tool_call>, or DeepSeek's (<|DSML|invoke name="…">, magpie#823); the
545
552
  // tags left around a call are dropped.
553
+ //
554
+ // GLM writes a call as its own template has it, <tool_call>name
555
+ // <arg_key>k</arg_key><arg_value>v</arg_value></tool_call>, which is read
556
+ // too; and a {"reasoning_content": …} object in the text is the model's
557
+ // reasoning, not its answer (歧路亡羊 on magpie's Discord).
546
558
  class TextTools {
547
- constructor() {
559
+ constructor(tools = []) {
548
560
  this.buf = ""
549
561
  this.after = false
562
+ this.tools = tools
550
563
  }
551
564
  push(s, end = false) {
552
565
  this.buf += s
553
566
  let text = ""
567
+ let reasoning = ""
554
568
  const calls = []
555
569
  for (;;) {
556
570
  if (this.after) {
@@ -566,6 +580,27 @@ class TextTools {
566
580
  }
567
581
  const i = this.buf.indexOf(OPEN)
568
582
  const d = this.buf.search(DSML_AT)
583
+ const r = this.buf.search(REASON_AT)
584
+ if (r >= 0 && (i < 0 || r < i) && (d < 0 || r < d)) {
585
+ const o = objectEnd(this.buf, r)
586
+ if (o === -1 && !end) {
587
+ // not whole yet: held, the text before it sent
588
+ text += this.buf.slice(0, r)
589
+ this.buf = this.buf.slice(r)
590
+ break
591
+ }
592
+ const v = o > 0 ? looseJSON(this.buf.slice(r, o)) : null
593
+ if (v && typeof v.reasoning_content === "string") {
594
+ text += this.buf.slice(0, r)
595
+ reasoning += v.reasoning_content
596
+ this.buf = this.buf.slice(o)
597
+ continue
598
+ }
599
+ // not one after all: text, and what follows read on
600
+ text += this.buf.slice(0, r + 1)
601
+ this.buf = this.buf.slice(r + 1)
602
+ continue
603
+ }
569
604
  if (i < 0 && d < 0) break
570
605
  if (d >= 0 && (i < 0 || d < i)) {
571
606
  text += this.buf.slice(0, d)
@@ -620,30 +655,81 @@ class TextTools {
620
655
  this.after = true
621
656
  continue
622
657
  }
623
- const j = this.buf.indexOf(CLOSE, i + OPEN.length)
624
- if (j < 0) break
658
+ let j = this.buf.indexOf(CLOSE, i + OPEN.length)
659
+ if (j < 0) {
660
+ // GLM's call, cut short at the end, is still the call
661
+ if (!end || !glmCall(this.buf.slice(i + OPEN.length), this.tools)) break
662
+ j = this.buf.length
663
+ }
625
664
  text += this.buf.slice(0, i)
626
665
  const raw = this.buf.slice(i + OPEN.length, j).trim()
627
666
  this.buf = this.buf.slice(j + CLOSE.length)
628
- const w = looseJSON(raw) ?? {}
629
- if (w.name) calls.push({ name: String(w.name), arguments: callArgs(w) })
630
- else text += OPEN + raw + CLOSE
667
+ const w = looseJSON(raw) ?? glmCall(raw, this.tools) ?? {}
668
+ if (w.name) {
669
+ calls.push({ name: String(w.name), arguments: callArgs(w) })
670
+ this.after = true
671
+ } else text += OPEN + raw + CLOSE
631
672
  }
632
673
  if (end) {
633
674
  text += this.buf
634
675
  this.buf = ""
635
676
  } else {
636
677
  // keep back an opened block, or what may be the start of one
637
- const at = [this.buf.indexOf(OPEN), this.buf.search(DSML_AT)].filter((x) => x >= 0)
678
+ const at = [this.buf.indexOf(OPEN), this.buf.search(DSML_AT), this.buf.search(REASON_AT)].filter((x) => x >= 0)
638
679
  let keep = at.length ? this.buf.length - Math.min(...at) : 0
639
680
  if (!keep) for (let k = Math.min(MARK_MAX - 1, this.buf.length); k > 0; k--) if (MARKS.some((m) => m.startsWith(this.buf.slice(-k)))) { keep = k; break }
640
681
  text += this.buf.slice(0, this.buf.length - keep)
641
682
  this.buf = this.buf.slice(this.buf.length - keep)
642
683
  }
643
- return { text, calls }
684
+ return { text, calls, reasoning }
644
685
  }
645
686
  }
646
687
 
688
+ // GLM's call template: the tool's name, then each argument as
689
+ // <arg_key>k</arg_key><arg_value>v</arg_value>
690
+ const GLM_ARG = /<arg_key>\s*([\s\S]*?)\s*<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/g
691
+
692
+ // glmCall is a call GLM wrote in its template ({name, arguments}), null
693
+ // when s isn't one. A value is its text where the tool's schema says
694
+ // string (or the text isn't JSON), else its JSON, as GLM's template writes
695
+ // a value that isn't a string. A name alone is a call only to a tool the
696
+ // request has.
697
+ function glmCall(s, tools = []) {
698
+ s = String(s ?? "").replace(/<\/tool_call>\s*$/, "")
699
+ const k = s.indexOf("<arg_key>")
700
+ const name = (k < 0 ? s : s.slice(0, k)).trim()
701
+ if (!/^[\w.:-]{1,128}$/.test(name)) return null
702
+ if (k < 0 && !tools.some((t) => (t.function ?? t)?.name === name)) return null
703
+ const props = (tools.find((t) => (t.function ?? t)?.name === name)?.function ?? {}).parameters?.properties ?? {}
704
+ const args = {}
705
+ for (const m of s.matchAll(GLM_ARG)) {
706
+ const raw = m[2]
707
+ if (props[m[1]]?.type === "string") {
708
+ args[m[1]] = raw
709
+ continue
710
+ }
711
+ try {
712
+ args[m[1]] = JSON.parse(raw.trim())
713
+ } catch {
714
+ args[m[1]] = raw
715
+ }
716
+ }
717
+ if (k >= 0 && !Object.keys(args).length) return null
718
+ return { name, arguments: args }
719
+ }
720
+
721
+ // toolNamed is the request's own name for a tool a model named otherwise
722
+ // (Read for read, list-files for list_files); name itself when the request
723
+ // has none like it. A tool it has none of goes on as named: the agent tells
724
+ // the model there's no such tool, which it can act on, where a call
725
+ // dropped here would end the turn with nothing.
726
+ function toolNamed(name, tools) {
727
+ const names = tools.map((t) => (t.function ?? t)?.name).filter(Boolean)
728
+ if (!names.length || names.includes(name)) return name
729
+ const norm = (n) => String(n).toLowerCase().replace(/[^a-z0-9]/g, "")
730
+ return names.find((n) => norm(n) === norm(name)) ?? name
731
+ }
732
+
647
733
  // DeepSeek's tags, with its full-width bar or a plain one
648
734
  const DSML = String.raw`<\s*\/?\s*[||][\s||]*DSML[\s||]*`
649
735
  const DSML_AT = new RegExp(DSML)
@@ -656,7 +742,9 @@ const DSML_PARAM = new RegExp(String.raw`<\s*[||][\s||]*DSML[\s||]*paramet
656
742
  // next call's
657
743
  const LEFTOVER = new RegExp(String.raw`^(?:\s+|<\/tool_calls?>|<tool_calls>|` + DSML + String.raw`(?![\s||]*invoke\b)[^<>]*>)+`)
658
744
  // what text may be the start of, so held back
659
- const MARKS = [OPEN, CLOSE, "<|DSML|", "<|DSML|", "</|DSML|", "</|DSML|"]
745
+ const MARKS = [OPEN, CLOSE, "<|DSML|", "<|DSML|", "</|DSML|", "</|DSML|", '{"reasoning_content"']
746
+ // reasoning a model wrote into its text as JSON
747
+ const REASON_AT = /\{\s*"reasoning_content"\s*:/
660
748
  const MARK_MAX = Math.max(...MARKS.map((m) => m.length))
661
749
 
662
750
  // dsmlArgs is the arguments of a DSML invoke as JSON: a parameter marked
@@ -793,8 +881,15 @@ class NativeCalls {
793
881
  if (next.startsWith(prev) || done(prev)) o.arguments = next // the arguments so far
794
882
  else o.arguments = prev + next // a piece
795
883
  }
796
- take() {
884
+ take(tools = []) {
797
885
  return this.calls.filter((c) => c.name).map(({ id, name, arguments: a }) => {
886
+ // a name that holds the whole call, as JSON or in GLM's template:
887
+ // that call, its arguments the name's when it gave none of its own
888
+ const w = name.trimStart().startsWith("{") ? looseJSON(name) : name.includes("<arg_key>") ? glmCall(name, tools) : null
889
+ if (w?.name) {
890
+ name = String(w.name)
891
+ if (!a || a.trim() === "{}") a = callArgs(w)
892
+ }
798
893
  const v = a && !done(a) ? looseJSON(a) : null
799
894
  return { id: id || callId(), name, arguments: v ? JSON.stringify(v) : a || "{}" }
800
895
  })
@@ -812,11 +907,48 @@ function done(a) {
812
907
  }
813
908
  }
814
909
 
910
+ // the events that carry the answer: output and message, and the names
911
+ // Trae has put a reply's text under otherwise (delta, or none at all);
912
+ // any other is queueing, metadata or timing
913
+ const ANSWER = new Set(["", "output", "message", "delta", "content", "text", "answer", "chunk", "output_delta", "message_delta"])
914
+
915
+ // finishOf is OpenAI's finish_reason for the one Trae's last event gives,
916
+ // "" for none it says or one it has no word for
917
+ function finishOf(d) {
918
+ const f = String(d?.finish_reason ?? d?.stop_reason ?? d?.finishReason ?? "").toLowerCase()
919
+ if (["length", "max_tokens", "max_output_tokens", "max_length"].includes(f)) return "length"
920
+ if (f === "content_filter" || f === "sensitive") return "content_filter"
921
+ return ""
922
+ }
923
+
924
+ // Thoughts is the reasoning sent on so far: a piece Trae sends again (the
925
+ // same one twice in a row, or the reasoning so far, again or with more
926
+ // after it) sends only what is new
927
+ class Thoughts {
928
+ constructor() {
929
+ this.all = ""
930
+ this.last = ""
931
+ }
932
+ add(r) {
933
+ const last = this.last
934
+ this.last = r
935
+ if (r === last && r.length >= 8) return ""
936
+ if (this.all.length >= 16 && r.length >= 16 && this.all.endsWith(r)) return ""
937
+ if (this.all.length >= 16 && r.startsWith(this.all)) r = r.slice(this.all.length)
938
+ this.all += r
939
+ return r
940
+ }
941
+ }
942
+
815
943
  // parts turns Trae's events into what the answer is made of: text,
816
- // reasoning, tool calls, the token counts, the finish, or an error.
817
- async function* parts(events) {
818
- const tt = new TextTools()
944
+ // reasoning, tool calls, the token counts, the finish, or an error. tools
945
+ // are the request's, a call to one named otherwise going on under its name.
946
+ async function* parts(events, tools = []) {
947
+ const tt = new TextTools(tools)
819
948
  const nc = new NativeCalls()
949
+ const th = new Thoughts()
950
+ const call = (c) => ({ call: { ...c, name: toolNamed(c.name, tools) } })
951
+ let finish = ""
820
952
  for await (const { event, data } of events) {
821
953
  const name = eventName(event)
822
954
  const d = data && typeof data === "object" ? data : {}
@@ -834,27 +966,38 @@ async function* parts(events) {
834
966
  if (name === "done" || name === "response_done" || name === "stream_done") {
835
967
  const u = tokensOf(d.usage)
836
968
  if (u) yield { usage: u }
969
+ finish = finishOf(d) || finish
837
970
  break
838
971
  }
839
- if (name && name !== "output" && name !== "message") continue // queueing, metadata, timing
840
- const reasoning = d.reasoning_content ?? d.reasoning ?? ""
841
- if (typeof reasoning === "string" && reasoning) yield { reasoning }
842
- let text = typeof d.response === "string" ? d.response : typeof d.content === "string" ? d.content : ""
972
+ if (!ANSWER.has(name)) continue
973
+ const delta = d.delta && typeof d.delta === "object" ? d.delta : {}
974
+ const reasoning = d.reasoning_content ?? d.reasoning ?? delta.reasoning_content ?? ""
975
+ if (typeof reasoning === "string" && reasoning) {
976
+ const r = th.add(reasoning)
977
+ if (r) yield { reasoning: r }
978
+ }
979
+ let text = typeof d.response === "string" ? d.response : typeof d.content === "string" ? d.content : typeof delta.content === "string" ? delta.content : typeof d.delta === "string" ? d.delta : ""
843
980
  // the IDE's own progress notes, not the model's
844
981
  if (/^(Building prompt:|Completed building prompt)/.test(text)) text = ""
845
982
  if (text) {
846
983
  const r = tt.push(text)
984
+ const t = r.reasoning && th.add(r.reasoning)
985
+ if (t) yield { reasoning: t }
847
986
  if (r.text) yield { text: r.text }
848
- for (const c of r.calls) yield { call: { ...c, id: callId() } }
987
+ for (const c of r.calls) yield call({ ...c, id: callId() })
849
988
  }
850
989
  for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) nc.push(tc)
851
990
  const u = tokensOf(d.usage)
852
991
  if (u) yield { usage: u }
992
+ finish = finishOf(d) || finish
853
993
  }
854
994
  const r = tt.push("", true)
995
+ const t = r.reasoning && th.add(r.reasoning)
996
+ if (t) yield { reasoning: t }
855
997
  if (r.text) yield { text: r.text }
856
- for (const c of r.calls) yield { call: { ...c, id: callId() } }
857
- for (const c of nc.take()) yield { call: c }
998
+ for (const c of r.calls) yield call({ ...c, id: callId() })
999
+ for (const c of nc.take(tools)) yield call(c)
1000
+ if (finish) yield { finish }
858
1001
  }
859
1002
 
860
1003
  // first reads events up to the first that is the answer's, so an answer
@@ -899,45 +1042,71 @@ async function openai(req, it) {
899
1042
  let reasoning = ""
900
1043
  const calls = []
901
1044
  let usage = null
1045
+ let finish = ""
902
1046
  for await (const p of it) {
903
1047
  if (p.error) return failure(502, p.code, p.error)
904
1048
  if (p.text) text += p.text
905
1049
  if (p.reasoning) reasoning += p.reasoning
906
1050
  if (p.call) calls.push(p.call)
907
1051
  if (p.usage) usage = p.usage
1052
+ if (p.finish) finish = p.finish
908
1053
  }
909
1054
  const message = { role: "assistant", content: text || (calls.length ? null : "") }
910
1055
  if (reasoning) message.reasoning_content = reasoning
911
1056
  if (calls.length) message.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.arguments } }))
912
- const body = { id, object: "chat.completion", created, model, choices: [{ index: 0, message, finish_reason: calls.length ? "tool_calls" : "stop" }] }
1057
+ const body = { id, object: "chat.completion", created, model, choices: [{ index: 0, message, finish_reason: calls.length ? "tool_calls" : finish || "stop" }] }
913
1058
  if (usage) body.usage = usage
914
1059
  return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })
915
1060
  }
916
1061
  const enc = new TextEncoder()
917
1062
  const chunk = (delta, finish = null, extra = {}) => enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finish }], ...extra })}\n\n`)
1063
+ // a reply that failed (an error event, or Trae's stream breaking off)
1064
+ // ends with the error and [DONE], not a finish that says it stopped; the
1065
+ // stream is closed whatever happens, and an agent that goes away stops
1066
+ // the reading of Trae's
1067
+ const error = (message, code = null) => enc.encode(`data: ${JSON.stringify({ error: { message: `Trae CN: ${message}`, type: "api_error", code } })}\n\n`)
1068
+ let gone = false
918
1069
  const stream = new ReadableStream({
919
1070
  async start(ctl) {
920
1071
  let calls = 0
921
1072
  let usage = null
922
- ctl.enqueue(chunk({ role: "assistant", content: "" }))
1073
+ let finish = ""
1074
+ let failed = false
1075
+ const put = (b) => {
1076
+ if (!gone) ctl.enqueue(b)
1077
+ }
923
1078
  try {
1079
+ put(chunk({ role: "assistant", content: "" }))
924
1080
  for await (const p of it) {
1081
+ if (gone) break
925
1082
  if (p.error) {
926
- ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: `Trae CN: ${p.error}`, type: "api_error", code: p.code ?? null } })}\n\n`))
1083
+ put(error(p.error, p.code ?? null))
1084
+ failed = true
927
1085
  break
928
1086
  }
929
- if (p.text) ctl.enqueue(chunk({ content: p.text }))
930
- if (p.reasoning) ctl.enqueue(chunk({ reasoning_content: p.reasoning }))
931
- if (p.call) ctl.enqueue(chunk({ tool_calls: [{ index: calls++, id: p.call.id, type: "function", function: { name: p.call.name, arguments: p.call.arguments } }] }))
1087
+ if (p.text) put(chunk({ content: p.text }))
1088
+ if (p.reasoning) put(chunk({ reasoning_content: p.reasoning }))
1089
+ if (p.call) put(chunk({ tool_calls: [{ index: calls++, id: p.call.id, type: "function", function: { name: p.call.name, arguments: p.call.arguments } }] }))
932
1090
  if (p.usage) usage = p.usage
1091
+ if (p.finish) finish = p.finish
1092
+ }
1093
+ if (!failed) {
1094
+ put(chunk({}, calls ? "tool_calls" : finish || "stop"))
1095
+ if (usage) put(enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage })}\n\n`))
933
1096
  }
934
- ctl.enqueue(chunk({}, calls ? "tool_calls" : "stop"))
935
- if (usage) ctl.enqueue(enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage })}\n\n`))
936
- ctl.enqueue(enc.encode("data: [DONE]\n\n"))
937
1097
  } catch (e) {
938
- ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: `Trae CN: ${e?.message ?? e}`, type: "api_error", code: null } })}\n\n`))
1098
+ try {
1099
+ put(error(e?.message ?? e))
1100
+ } catch {}
939
1101
  }
940
- ctl.close()
1102
+ try {
1103
+ put(enc.encode("data: [DONE]\n\n"))
1104
+ if (!gone) ctl.close()
1105
+ } catch {}
1106
+ },
1107
+ cancel() {
1108
+ gone = true
1109
+ it.return?.()
941
1110
  },
942
1111
  })
943
1112
  return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
@@ -964,6 +1133,27 @@ function chatModel(m) {
964
1133
  // devModel is the model an entry serves requests with: its __dev one
965
1134
  const devModel = (m) => (Array.isArray(m?.model_detail_list) ? m.model_detail_list : []).find((d) => /__dev$/.test(String(d?.model_name ?? "")))
966
1135
 
1136
+ // maxModel is a model's Max mode, from the entries its lists give it (the
1137
+ // one it is asked through first): the first __max model one names, and the
1138
+ // window context_window_tokens.max gives it, when that is bigger than dev's.
1139
+ // The lists needn't agree: chat_v3 can name the __max model while the SOLO
1140
+ // list the model is asked through names only __dev, and the window can be
1141
+ // another entry's again. A model with no __max, or no bigger window, has no
1142
+ // Max.
1143
+ const maxModel = (...ms) => {
1144
+ const details = (m) => (Array.isArray(m?.model_detail_list) ? m.model_detail_list : [])
1145
+ const at = ms.find((m) => details(m).some((d) => /__max$/.test(String(d?.model_name ?? ""))))
1146
+ if (!at) return null
1147
+ const d = details(at).find((d) => /__max$/.test(String(d?.model_name ?? "")))
1148
+ const first = (k) => [at, ...ms].map((m) => Number(m?.context_window_tokens?.[k]) || 0).find((n) => n > 0) || 0
1149
+ const window = first("max")
1150
+ return window > first("dev") ? { name: String(d.model_name), most: Number(d.max_tokens) || 0, window, entry: at } : null
1151
+ }
1152
+
1153
+ // MAX is the suffix of a model's Max: deepseek-v4.1-flash-max is
1154
+ // deepseek-v4.1-flash in Max mode
1155
+ const MAX = "-max"
1156
+
967
1157
  // ---- usage ------------------------------------------------------------------------
968
1158
 
969
1159
  // credits adds up the account's entitlement packs as the IDE shows them:
@@ -1003,6 +1193,8 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1003
1193
  // model it names there
1004
1194
  const listedBy = new Map()
1005
1195
  const modelNames = new Map()
1196
+ // the Max each model the list last read has (null: none)
1197
+ const maxes = new Map()
1006
1198
 
1007
1199
  const save = async (auth) => {
1008
1200
  try {
@@ -1219,6 +1411,19 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1219
1411
  const dev = devModel(m)
1220
1412
  if (dev) modelNames.set(id, { fn, name: dev.model_name, most: Number(dev.max_tokens) || 0 })
1221
1413
  else modelNames.delete(id)
1414
+ if (out.has(id + MAX)) continue
1415
+ // its Max is asked through the function whose entry names the __max model
1416
+ const entries = [{ m, fn }, ...FUNCTIONS.flatMap((f) => (lists[f] ?? []).filter((o) => o !== m && chatModel(o) && String(o.config_name) === id).map((o) => ({ m: o, fn: f })))]
1417
+ const max = maxModel(...entries.map((e) => e.m))
1418
+ maxes.set(id, max)
1419
+ if (max) {
1420
+ const by = entries.find((e) => e.m === max.entry).fn
1421
+ listedBy.set(id + MAX, by)
1422
+ modelNames.set(id + MAX, { fn: by, name: max.name, most: max.most, max: { base: id, window: max.window } })
1423
+ } else {
1424
+ listedBy.delete(id + MAX)
1425
+ modelNames.delete(id + MAX)
1426
+ }
1222
1427
  }
1223
1428
  return [...out.values()].map((x) => x.m)
1224
1429
  }
@@ -1253,7 +1458,16 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1253
1458
  try {
1254
1459
  const ms = await liveModels(await fresh(async () => auth))
1255
1460
  if (!ms.length) return provider.models
1256
- return Object.fromEntries(ms.map((m) => [String(m.config_name), modelOf(provider, m)]))
1461
+ const ids = new Set(ms.map((m) => String(m.config_name)))
1462
+ return Object.fromEntries(ms.flatMap((m) => {
1463
+ const id = String(m.config_name)
1464
+ const own = modelOf(provider, m)
1465
+ const max = maxes.get(id)
1466
+ if (!max || ids.has(id + MAX)) return [[id, own]]
1467
+ // its Max, a model of its own: the window and output Max mode gives
1468
+ const was = provider.models?.[id + MAX] ?? {}
1469
+ return [[id, own], [id + MAX, { ...own, ...was, id: id + MAX, name: own.name + " (Max)", limit: { context: max.window, output: max.most || own.limit.output }, api: { ...own.api, id: id + MAX } }]]
1470
+ }))
1257
1471
  } catch (e) {
1258
1472
  if (e instanceof Expired) throw e
1259
1473
  return provider.models
@@ -1296,12 +1510,19 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1296
1510
  // served this account last, then the rest
1297
1511
  const fns = [...new Set([listedBy.get(String(req.model)), fnOf.get(who), ...FUNCTIONS].filter(Boolean))]
1298
1512
  let last
1299
- const named = modelNames.get(String(req.model))
1300
- for (const fn of fns) {
1513
+ let named = modelNames.get(String(req.model))
1514
+ if (!named && String(req.model).endsWith(MAX)) {
1515
+ // a Max asked before the list was read here: read it
1516
+ await liveModels(a).catch(() => {})
1517
+ named = modelNames.get(String(req.model))
1518
+ if (named) fns.unshift(named.fn)
1519
+ }
1520
+ const max = named?.max ?? null
1521
+ for (const fn of [...new Set(fns)]) {
1301
1522
  const res = await fetch(apiOf(a) + "/api/agent/v3/llm_utils_chat", {
1302
1523
  method: "POST",
1303
1524
  headers: ideHeaders(a, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
1304
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn ? named.name : "", named?.fn === fn ? named.most : 0)),
1525
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max)),
1305
1526
  signal: init.signal ?? r0?.signal,
1306
1527
  })
1307
1528
  if (!res.ok) {
@@ -1319,7 +1540,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1319
1540
  if (wrongFunction(e.code)) continue
1320
1541
  break
1321
1542
  }
1322
- const it = parts(sse(res.body))[Symbol.asyncIterator]()
1543
+ const it = parts(sse(res.body), Array.isArray(req.tools) ? req.tools : [])[Symbol.asyncIterator]()
1323
1544
  const { held, done } = await first(it)
1324
1545
  const err = held.find((p) => p.error)
1325
1546
  if (err) {
@@ -1343,4 +1564,4 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1343
1564
  }
1344
1565
 
1345
1566
  // for tests
1346
- export const _internal = { HOSTS, MODELS, TextTools, NativeCalls, looseJSON, traeMessages, chatBody, credits, whenOf, newDevice }
1567
+ export const _internal = { HOSTS, MODELS, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, traeMessages, chatBody, credits, whenOf, newDevice }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "OpenCode provider plugin for Trae CN (trae.cn): your Trae CN account's models, free tier included, with Trae's own browser sign-in",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",