@magpie-community/opencode-trae-auth 0.1.12 → 0.1.14

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 +29 -4
  2. package/index.mjs +262 -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,19 @@ 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 config 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`; it asks the config's `__max` model, with `max_tokens` set
87
+ and `user_message_context.model_info.prompt_max_tokens` the window less
88
+ it, as Max mode does.
89
+
90
+ Reasoning: the models reason, but Trae's request takes no effort or
91
+ thinking level, so the plugin lists no variants and a level an agent
92
+ picks does nothing.
93
+
94
+ When no
70
95
  list can be read, the plugin uses the models Trae CN's `chat_v3` is
71
96
  known to serve: GLM-5.2, GLM-5, Kimi K2.6, Qwen 3.7 Plus, DeepSeek V4 Pro
72
97
  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,24 +550,57 @@ 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) {
557
571
  const m = this.buf.match(LEFTOVER)
558
572
  if (m) this.buf = this.buf.slice(m[0].length)
559
573
  if (!this.buf || (!end && MARKS.some((k) => k.startsWith(this.buf)))) break
574
+ // a tag cut short at the end (</), after the others: dropped too
575
+ if (end && this.buf.length > 1 && MARKS.some((k) => k.startsWith(this.buf.trimEnd()))) {
576
+ this.buf = ""
577
+ break
578
+ }
560
579
  this.after = false
561
580
  }
562
581
  const i = this.buf.indexOf(OPEN)
563
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
+ }
564
604
  if (i < 0 && d < 0) break
565
605
  if (d >= 0 && (i < 0 || d < i)) {
566
606
  text += this.buf.slice(0, d)
@@ -597,6 +637,16 @@ class TextTools {
597
637
  continue
598
638
  }
599
639
  const o = objectEnd(this.buf, i + OPEN.length)
640
+ const inner = this.buf.slice(i + OPEN.length).trimStart()
641
+ if (inner.startsWith("<") || (end && !inner)) {
642
+ // no JSON in it: DeepSeek's own invoke, or only the closes of one
643
+ // (magpie#823 again); the opener is dropped and what follows read
644
+ // as it is
645
+ text += this.buf.slice(0, i)
646
+ this.buf = inner
647
+ this.after = true
648
+ continue
649
+ }
600
650
  const v = o > 0 ? looseJSON(this.buf.slice(i + OPEN.length, o)) : null
601
651
  if (v?.name) {
602
652
  text += this.buf.slice(0, i)
@@ -605,28 +655,79 @@ class TextTools {
605
655
  this.after = true
606
656
  continue
607
657
  }
608
- const j = this.buf.indexOf(CLOSE, i + OPEN.length)
609
- 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
+ }
610
664
  text += this.buf.slice(0, i)
611
665
  const raw = this.buf.slice(i + OPEN.length, j).trim()
612
666
  this.buf = this.buf.slice(j + CLOSE.length)
613
- const w = looseJSON(raw) ?? {}
614
- if (w.name) calls.push({ name: String(w.name), arguments: callArgs(w) })
615
- 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
616
672
  }
617
673
  if (end) {
618
674
  text += this.buf
619
675
  this.buf = ""
620
676
  } else {
621
677
  // keep back an opened block, or what may be the start of one
622
- 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)
623
679
  let keep = at.length ? this.buf.length - Math.min(...at) : 0
624
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 }
625
681
  text += this.buf.slice(0, this.buf.length - keep)
626
682
  this.buf = this.buf.slice(this.buf.length - keep)
627
683
  }
628
- return { text, calls }
684
+ return { text, calls, reasoning }
685
+ }
686
+ }
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
+ }
629
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
630
731
  }
631
732
 
632
733
  // DeepSeek's tags, with its full-width bar or a plain one
@@ -641,7 +742,9 @@ const DSML_PARAM = new RegExp(String.raw`<\s*[||][\s||]*DSML[\s||]*paramet
641
742
  // next call's
642
743
  const LEFTOVER = new RegExp(String.raw`^(?:\s+|<\/tool_calls?>|<tool_calls>|` + DSML + String.raw`(?![\s||]*invoke\b)[^<>]*>)+`)
643
744
  // what text may be the start of, so held back
644
- 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*:/
645
748
  const MARK_MAX = Math.max(...MARKS.map((m) => m.length))
646
749
 
647
750
  // dsmlArgs is the arguments of a DSML invoke as JSON: a parameter marked
@@ -778,8 +881,15 @@ class NativeCalls {
778
881
  if (next.startsWith(prev) || done(prev)) o.arguments = next // the arguments so far
779
882
  else o.arguments = prev + next // a piece
780
883
  }
781
- take() {
884
+ take(tools = []) {
782
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
+ }
783
893
  const v = a && !done(a) ? looseJSON(a) : null
784
894
  return { id: id || callId(), name, arguments: v ? JSON.stringify(v) : a || "{}" }
785
895
  })
@@ -797,11 +907,48 @@ function done(a) {
797
907
  }
798
908
  }
799
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
+
800
943
  // parts turns Trae's events into what the answer is made of: text,
801
- // reasoning, tool calls, the token counts, the finish, or an error.
802
- async function* parts(events) {
803
- 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)
804
948
  const nc = new NativeCalls()
949
+ const th = new Thoughts()
950
+ const call = (c) => ({ call: { ...c, name: toolNamed(c.name, tools) } })
951
+ let finish = ""
805
952
  for await (const { event, data } of events) {
806
953
  const name = eventName(event)
807
954
  const d = data && typeof data === "object" ? data : {}
@@ -819,27 +966,38 @@ async function* parts(events) {
819
966
  if (name === "done" || name === "response_done" || name === "stream_done") {
820
967
  const u = tokensOf(d.usage)
821
968
  if (u) yield { usage: u }
969
+ finish = finishOf(d) || finish
822
970
  break
823
971
  }
824
- if (name && name !== "output" && name !== "message") continue // queueing, metadata, timing
825
- const reasoning = d.reasoning_content ?? d.reasoning ?? ""
826
- if (typeof reasoning === "string" && reasoning) yield { reasoning }
827
- 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 : ""
828
980
  // the IDE's own progress notes, not the model's
829
981
  if (/^(Building prompt:|Completed building prompt)/.test(text)) text = ""
830
982
  if (text) {
831
983
  const r = tt.push(text)
984
+ const t = r.reasoning && th.add(r.reasoning)
985
+ if (t) yield { reasoning: t }
832
986
  if (r.text) yield { text: r.text }
833
- for (const c of r.calls) yield { call: { ...c, id: callId() } }
987
+ for (const c of r.calls) yield call({ ...c, id: callId() })
834
988
  }
835
989
  for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) nc.push(tc)
836
990
  const u = tokensOf(d.usage)
837
991
  if (u) yield { usage: u }
992
+ finish = finishOf(d) || finish
838
993
  }
839
994
  const r = tt.push("", true)
995
+ const t = r.reasoning && th.add(r.reasoning)
996
+ if (t) yield { reasoning: t }
840
997
  if (r.text) yield { text: r.text }
841
- for (const c of r.calls) yield { call: { ...c, id: callId() } }
842
- 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 }
843
1001
  }
844
1002
 
845
1003
  // first reads events up to the first that is the answer's, so an answer
@@ -884,45 +1042,71 @@ async function openai(req, it) {
884
1042
  let reasoning = ""
885
1043
  const calls = []
886
1044
  let usage = null
1045
+ let finish = ""
887
1046
  for await (const p of it) {
888
1047
  if (p.error) return failure(502, p.code, p.error)
889
1048
  if (p.text) text += p.text
890
1049
  if (p.reasoning) reasoning += p.reasoning
891
1050
  if (p.call) calls.push(p.call)
892
1051
  if (p.usage) usage = p.usage
1052
+ if (p.finish) finish = p.finish
893
1053
  }
894
1054
  const message = { role: "assistant", content: text || (calls.length ? null : "") }
895
1055
  if (reasoning) message.reasoning_content = reasoning
896
1056
  if (calls.length) message.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.arguments } }))
897
- 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" }] }
898
1058
  if (usage) body.usage = usage
899
1059
  return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })
900
1060
  }
901
1061
  const enc = new TextEncoder()
902
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
903
1069
  const stream = new ReadableStream({
904
1070
  async start(ctl) {
905
1071
  let calls = 0
906
1072
  let usage = null
907
- ctl.enqueue(chunk({ role: "assistant", content: "" }))
1073
+ let finish = ""
1074
+ let failed = false
1075
+ const put = (b) => {
1076
+ if (!gone) ctl.enqueue(b)
1077
+ }
908
1078
  try {
1079
+ put(chunk({ role: "assistant", content: "" }))
909
1080
  for await (const p of it) {
1081
+ if (gone) break
910
1082
  if (p.error) {
911
- 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
912
1085
  break
913
1086
  }
914
- if (p.text) ctl.enqueue(chunk({ content: p.text }))
915
- if (p.reasoning) ctl.enqueue(chunk({ reasoning_content: p.reasoning }))
916
- 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 } }] }))
917
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`))
918
1096
  }
919
- ctl.enqueue(chunk({}, calls ? "tool_calls" : "stop"))
920
- if (usage) ctl.enqueue(enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage })}\n\n`))
921
- ctl.enqueue(enc.encode("data: [DONE]\n\n"))
922
1097
  } catch (e) {
923
- 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 {}
924
1101
  }
925
- 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?.()
926
1110
  },
927
1111
  })
928
1112
  return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
@@ -949,6 +1133,20 @@ function chatModel(m) {
949
1133
  // devModel is the model an entry serves requests with: its __dev one
950
1134
  const devModel = (m) => (Array.isArray(m?.model_detail_list) ? m.model_detail_list : []).find((d) => /__dev$/.test(String(d?.model_name ?? "")))
951
1135
 
1136
+ // maxModel is a config's Max mode: its __max model and the window
1137
+ // context_window_tokens.max gives it, when that is bigger than dev's; a
1138
+ // config without one has no Max
1139
+ const maxModel = (m) => {
1140
+ const d = (Array.isArray(m?.model_detail_list) ? m.model_detail_list : []).find((d) => /__max$/.test(String(d?.model_name ?? "")))
1141
+ const window = Number(m?.context_window_tokens?.max) || 0
1142
+ const dev = Number(m?.context_window_tokens?.dev) || 0
1143
+ return d && window > dev ? { name: String(d.model_name), most: Number(d.max_tokens) || 0, window } : null
1144
+ }
1145
+
1146
+ // MAX is the suffix of a model's Max: deepseek-v4.1-flash-max is
1147
+ // deepseek-v4.1-flash in Max mode
1148
+ const MAX = "-max"
1149
+
952
1150
  // ---- usage ------------------------------------------------------------------------
953
1151
 
954
1152
  // credits adds up the account's entitlement packs as the IDE shows them:
@@ -1204,6 +1402,11 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1204
1402
  const dev = devModel(m)
1205
1403
  if (dev) modelNames.set(id, { fn, name: dev.model_name, most: Number(dev.max_tokens) || 0 })
1206
1404
  else modelNames.delete(id)
1405
+ const max = maxModel(m)
1406
+ if (max && !out.has(id + MAX)) {
1407
+ listedBy.set(id + MAX, fn)
1408
+ modelNames.set(id + MAX, { fn, name: max.name, most: max.most, max: { base: id, window: max.window } })
1409
+ }
1207
1410
  }
1208
1411
  return [...out.values()].map((x) => x.m)
1209
1412
  }
@@ -1238,7 +1441,16 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1238
1441
  try {
1239
1442
  const ms = await liveModels(await fresh(async () => auth))
1240
1443
  if (!ms.length) return provider.models
1241
- return Object.fromEntries(ms.map((m) => [String(m.config_name), modelOf(provider, m)]))
1444
+ const ids = new Set(ms.map((m) => String(m.config_name)))
1445
+ return Object.fromEntries(ms.flatMap((m) => {
1446
+ const id = String(m.config_name)
1447
+ const own = modelOf(provider, m)
1448
+ const max = maxModel(m)
1449
+ if (!max || ids.has(id + MAX)) return [[id, own]]
1450
+ // its Max, a model of its own: the window and output Max mode gives
1451
+ const was = provider.models?.[id + MAX] ?? {}
1452
+ 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 } }]]
1453
+ }))
1242
1454
  } catch (e) {
1243
1455
  if (e instanceof Expired) throw e
1244
1456
  return provider.models
@@ -1281,12 +1493,19 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1281
1493
  // served this account last, then the rest
1282
1494
  const fns = [...new Set([listedBy.get(String(req.model)), fnOf.get(who), ...FUNCTIONS].filter(Boolean))]
1283
1495
  let last
1284
- const named = modelNames.get(String(req.model))
1285
- for (const fn of fns) {
1496
+ let named = modelNames.get(String(req.model))
1497
+ if (!named && String(req.model).endsWith(MAX)) {
1498
+ // a Max asked before the list was read here: read it
1499
+ await liveModels(a).catch(() => {})
1500
+ named = modelNames.get(String(req.model))
1501
+ if (named) fns.unshift(named.fn)
1502
+ }
1503
+ const max = named?.max ?? null
1504
+ for (const fn of [...new Set(fns)]) {
1286
1505
  const res = await fetch(apiOf(a) + "/api/agent/v3/llm_utils_chat", {
1287
1506
  method: "POST",
1288
1507
  headers: ideHeaders(a, { Accept: "text/event-stream", "X-Request-ID": randomUUID() }),
1289
- body: JSON.stringify(chatBody(req, fn, named?.fn === fn ? named.name : "", named?.fn === fn ? named.most : 0)),
1508
+ body: JSON.stringify(chatBody(req, fn, named?.fn === fn || max ? named.name : "", named?.fn === fn || max ? named.most : 0, max)),
1290
1509
  signal: init.signal ?? r0?.signal,
1291
1510
  })
1292
1511
  if (!res.ok) {
@@ -1304,7 +1523,7 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1304
1523
  if (wrongFunction(e.code)) continue
1305
1524
  break
1306
1525
  }
1307
- const it = parts(sse(res.body))[Symbol.asyncIterator]()
1526
+ const it = parts(sse(res.body), Array.isArray(req.tools) ? req.tools : [])[Symbol.asyncIterator]()
1308
1527
  const { held, done } = await first(it)
1309
1528
  const err = held.find((p) => p.error)
1310
1529
  if (err) {
@@ -1328,4 +1547,4 @@ export const TraeCNAuthPlugin = async ({ client }) => {
1328
1547
  }
1329
1548
 
1330
1549
  // for tests
1331
- export const _internal = { HOSTS, MODELS, TextTools, NativeCalls, looseJSON, traeMessages, chatBody, credits, whenOf, newDevice }
1550
+ 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.12",
3
+ "version": "0.1.14",
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",