@magpie-community/opencode-trae-auth 0.2.12 → 0.2.13

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 +10 -1
  2. package/index.mjs +192 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -128,7 +128,16 @@ agent's `POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat`:
128
128
  - GLM also writes its own template, `<tool_call>name<arg_key>k</arg_key>
129
129
  <arg_value>v</arg_value></tool_call>` (a string kept as written, other
130
130
  types read as JSON), and sometimes a whole call into a native call's
131
- name; both are read as the call.
131
+ name; both are read as the call. A name that holds the wrapper with
132
+ GLM's tags where its service stopped mid-call — `{"name":"bash",
133
+ "arguments":{"command</arg_key><arg_value>ls</arg_value>` — is read too:
134
+ the tags are the service's own handoff, so what stands between them is
135
+ the model's text (a quote left unescaped read as the quote it is, a
136
+ comma inside a value kept, a field's closing tag ending the value before
137
+ it), and the fields the native call did carry are kept beside the ones
138
+ the name's own text fills in. A name whose tool the request doesn't
139
+ have, or one no field can be read from, goes on as written, as any other
140
+ unknown tool does.
132
141
  - A block is read as models get it wrong too: every quote escaped
133
142
  (`{\"name\":…}`), quotes or backslashes in a string left unescaped, an
134
143
  opener with the name as an attribute (`<tool_call name="bash">` with
package/index.mjs CHANGED
@@ -1493,6 +1493,165 @@ function glmCall(s, tools = []) {
1493
1493
  return { name, arguments: args }
1494
1494
  }
1495
1495
 
1496
+ // Trae reads a model's call for it and hands a call it could not read on as
1497
+ // the native call: the wrapper the client's prompt asked for, with GLM's own
1498
+ // template tags where the model wrote them and a field's close left behind
1499
+ // (``{"name":"bash","arguments":{"command</arg_key><arg_value>ls</arg_value>``).
1500
+ // The name then holds the call the model meant, and the call's own arguments
1501
+ // — where the model wrote them — hold the rest of it.
1502
+ const NAMECALL = /^\s*\{\s*"name"\s*:\s*"([^"\\]*)"\s*,\s*"(?:arguments|input|parameters)"\s*:\s*\{/
1503
+ // a wrapper the service put text in front of (`bash_abstract_command_0>{…}`):
1504
+ // the same shape, wherever it starts
1505
+ const NAMEHEAD = /\{\s*"name"\s*:\s*"([^"\\]*)"\s*,\s*"(?:arguments|input|parameters)"\s*:\s*\{/
1506
+ const NAMETAG = /<\/?arg_(?:key|value)\s*>/
1507
+
1508
+ // nameCall is the call a name holds when it is one the model wrote there
1509
+ // ({name, arguments}), null when it isn't: a name without the wrapper, one
1510
+ // whose tool the request doesn't have, or one no field could be read from.
1511
+ // The wrapper and the tags are the service's own handoff, so what stands
1512
+ // between them is the model's text as it wrote it — a quote it left
1513
+ // unescaped is read as the quote it is, the way repairedJSON reads a file's
1514
+ // content. A value is JSON where the tool's schema says the field is no
1515
+ // string and the text is one; else the text, cut where the value was cut.
1516
+ function nameCall(s, tools = []) {
1517
+ const text = String(s ?? "")
1518
+ // a wrapper the service put text in front of (`bash_abstract_command_0>{…}`)
1519
+ // is the call it holds, that text being no name the request has
1520
+ const at = text.indexOf("{")
1521
+ if (at < 0) return null
1522
+ const m = NAMECALL.exec(text.slice(at))
1523
+ if (!m) return null
1524
+ const name = m[1]
1525
+ const tool = tools.map((t) => t.function ?? t).find((t) => t?.name === name)
1526
+ if (!tool) return null
1527
+ const fields = nameFields(text.slice(at + m[0].length))
1528
+ const args = {}
1529
+ for (const [key, value] of fields) {
1530
+ // a field the schema says is no string is its JSON where it is one (a
1531
+ // number the model wrote, an object), else the text it is
1532
+ if (tool.parameters?.properties?.[key]?.type !== "string") {
1533
+ try {
1534
+ args[key] = JSON.parse(value.trim())
1535
+ continue
1536
+ } catch {}
1537
+ }
1538
+ args[key] = value
1539
+ }
1540
+ return Object.keys(args).length ? { name, arguments: args } : null
1541
+ }
1542
+
1543
+ // nameFields reads a wrapper's body as its fields: a key, then its value.
1544
+ // A tag stands where the service cut a colon or a quote, so a value ends at
1545
+ // the tag that ends it, at the next key, or at the structure that closes it
1546
+ // — and at a closing tag (`</description>`) whose name is a field's, because
1547
+ // what follows such a tag is the service's own text, not the model's.
1548
+ // What none of those reached is no field's text and is left out, so a call
1549
+ // cut in the middle keeps what it has.
1550
+ function nameFields(body) {
1551
+ const out = []
1552
+ let i = 0
1553
+ for (let n = 0; n < 64 && i < body.length; n++) {
1554
+ // between fields: blanks, a comma, or the tags that stand for either
1555
+ i += (body.slice(i).match(/^[\s,]+|^<\/?[a-z_]+\s*>/) || [""])[0].length
1556
+ if (body[i] !== '"') break
1557
+ const key = nameString(body, i, () => true)
1558
+ i = key.i
1559
+ if (!key.text) break
1560
+ const colon = body.slice(i).match(/^\s*:\s*/)
1561
+ if (colon) i += colon[0].length
1562
+ // a value the service cut at its tag: what stands after the tag is the
1563
+ // model's text (limit</arg_key><arg_value>55)
1564
+ i += (body.slice(i).match(/^(?:<\/?[a-z_]+\s*>)+/) || [""])[0].length
1565
+ let value
1566
+ if (body[i] === '"') {
1567
+ const v = nameString(body, i, (rest) => endsValue(rest))
1568
+ value = v.text
1569
+ i = v.i
1570
+ if (!value && !v.closed) break // an open quote with nothing in it
1571
+ } else {
1572
+ const rest = body.slice(i)
1573
+ const stop = valueEnd(rest)
1574
+ value = rest.slice(0, stop).replace(/^[\s,:]+/, "")
1575
+ // the field's own close: the structure that follows it, and a quote
1576
+ // the service cut it at where an escape isn't what stands before it
1577
+ value = value.replace(/[\s,}\]]+$/, "").replace(/(?<!\\)"$/, "").replace(/[\s,}\]]+$/, "")
1578
+ i += stop
1579
+ // nothing introduced a value (no colon, no quote, no tag) and none
1580
+ // stands: the wrapper is cut at the key, and an empty call read out
1581
+ // of it would be a call the model never wrote
1582
+ if (!value) break
1583
+ }
1584
+ out.push([key.text, value])
1585
+ }
1586
+ return out
1587
+ }
1588
+
1589
+ // endsValue is what may stand after a string's closing quote: the structure
1590
+ // that closes the field (`,`, `}`, `]`, the end), a tag that stands for one,
1591
+ // or the next field's key.
1592
+ function endsValue(rest) {
1593
+ if (/^\s*(?:[,}\]$]|<\/?[a-z_]+\s*>)/.test(rest)) return true
1594
+ return /^\s*"(?:[^"\\]|\\.)*"\s*(?::|<\/?[a-z_]+\s*>)/.test(rest)
1595
+ }
1596
+
1597
+ // valueEnd is where an unquoted value runs out: the next key, a tag that
1598
+ // ends it, or the structure that closes the field. A comma inside it is the
1599
+ // model's own (sed -n 1,9p), so only a brace or a bracket closes a field.
1600
+ function valueEnd(rest) {
1601
+ const stops = [
1602
+ rest.search(/"(?:(?:[^"\\<>]*)"\s*(?::|,|\s*<\/?[a-z_]+\s*>)|[^"\\<>]*<\/?[a-z_]+\s*>)/),
1603
+ rest.search(/<\/?[a-z_]+\s*>/),
1604
+ rest.search(/[}\]]/),
1605
+ ].filter((x) => x >= 0)
1606
+ return stops.length ? Math.min(...stops) : rest.length
1607
+ }
1608
+
1609
+ // nameString reads the string at s[i] === '"', its escapes kept as written.
1610
+ // ends says, for what follows a quote, whether that quote ended the string:
1611
+ // a quote the model left unescaped inside a value is escaped as read on.
1612
+ // `closed` is whether the model's own quote ended it, where the read ran
1613
+ // into a tag or the end of the text.
1614
+ function nameString(s, i, ends) {
1615
+ i++ // the opening quote
1616
+ let src = ""
1617
+ while (i < s.length) {
1618
+ const ch = s[i]
1619
+ if (ch === "\\") {
1620
+ src += s[i] + (s[i + 1] ?? "")
1621
+ i += 2
1622
+ continue
1623
+ }
1624
+ if (ch === "<") {
1625
+ // a tag ends the run: the service cut the value at it, whether it is
1626
+ // one of its own (`</arg_value>`) or a field's (`</description>`),
1627
+ // for neither is text the model wrote
1628
+ if (/^<\/?[a-z_][\w-]*\s*>/.test(s.slice(i))) break
1629
+ }
1630
+ if (ch === '"') {
1631
+ if (ends(s.slice(i + 1))) {
1632
+ i++
1633
+ return { text: decode(src), i, closed: true }
1634
+ }
1635
+ src += '\\"'
1636
+ i++
1637
+ continue
1638
+ }
1639
+ src += ch
1640
+ i++
1641
+ }
1642
+ return { text: decode(src), i, closed: false }
1643
+ }
1644
+
1645
+ // decode is a string's own source as the string: the escapes the model wrote
1646
+ // are the characters they stand for, a newline among them
1647
+ function decode(src) {
1648
+ try {
1649
+ return JSON.parse('"' + escapeRaw(src) + '"')
1650
+ } catch {
1651
+ return src
1652
+ }
1653
+ }
1654
+
1496
1655
  // toolNamed is the request's own name for a tool a model named otherwise
1497
1656
  // (Read for read, list-files for list_files); name itself when the request
1498
1657
  // has none like it. A tool it has none of goes on as named: the agent tells
@@ -1684,12 +1843,21 @@ class NativeCalls {
1684
1843
  }
1685
1844
  take(tools = []) {
1686
1845
  return this.calls.filter((c) => c.name).map(({ id, name, arguments: a }) => {
1687
- // a name that holds the whole call, as JSON or in GLM's template:
1688
- // that call, its arguments the name's when it gave none of its own
1689
- const w = name.trimStart().startsWith("{") ? looseJSON(name) : name.includes("<arg_key>") ? glmCall(name, tools) : null
1846
+ // a name that holds the whole call, as the client's wrapper or in
1847
+ // GLM's template: that call, its arguments the name's when it gave
1848
+ // none of its own. The wrapper is read as JSON where it is whole —
1849
+ // its nesting is its own — and as the service's handoff where it was
1850
+ // left mid-call, a wrapper written with a space (`{ "name"`) whole
1851
+ // like any other
1852
+ const w = name.trimStart().startsWith("{") ? looseJSON(name) ?? nameCall(name, tools)
1853
+ : NAMEHEAD.test(name) ? nameCall(name, tools)
1854
+ : name.includes("<arg_key>") ? glmCall(name, tools) : null
1690
1855
  if (w?.name) {
1691
1856
  name = String(w.name)
1692
- if (!a || a.trim() === "{}") a = callArgs(w)
1857
+ // the name's own arguments are the model's; what the name read fills
1858
+ // the fields they left (the wrapper's text is the whole call where
1859
+ // Trae moved the arguments with it, and the rest where it did not)
1860
+ a = nameArgs(a, w)
1693
1861
  }
1694
1862
  const v = a && !done(a) ? looseJSON(a) : null
1695
1863
  return { id: id || callId(), name, arguments: v ? JSON.stringify(v) : a || "{}" }
@@ -1697,6 +1865,25 @@ class NativeCalls {
1697
1865
  }
1698
1866
  }
1699
1867
 
1868
+ // nameArgs is the arguments of a call a name held: the native call's own
1869
+ // where it carried them, else the name's, and the two together where each
1870
+ // has a field the other hasn't — the service moves the arguments it read
1871
+ // into the native call and leaves the rest in the name. The name's side is
1872
+ // read the way main reads a whole name (`callArgs`: `arguments`, `input`,
1873
+ // `parameters`, arguments given as a string, or the flat fields), so what
1874
+ // main already read keeps reading the same. The two stand together only as
1875
+ // two sets of fields; a call's own arguments that are not that — text the
1876
+ // service cut, or a value that isn't a call's fields — go on as they stand,
1877
+ // which is what main passed on and what the agent can answer.
1878
+ function nameArgs(native, fromName) {
1879
+ const a = typeof native === "string" ? native.trim() : ""
1880
+ const named = callArgs(fromName)
1881
+ if (!a || a === "{}") return named
1882
+ const own = looseJSON(a), side = looseJSON(named)
1883
+ if (!own || !side || typeof own !== "object" || typeof side !== "object" || Array.isArray(own) || Array.isArray(side)) return a
1884
+ return JSON.stringify({ ...side, ...own })
1885
+ }
1886
+
1700
1887
  // resent says next is the call's whole arguments sent again after a
1701
1888
  // partial snapshot, not a piece of them: Trae resends a snapshot whose
1702
1889
  // punctuation isn't the first one's (− for -, ′ for '), so it doesn't
@@ -2496,4 +2683,4 @@ const makePlugin = (site) => async ({ client }) => {
2496
2683
  export const TraeCNAuthPlugin = makePlugin(SITES["trae-cn"])
2497
2684
  export const TraeGlobalAuthPlugin = makePlugin(SITES["trae-global"])
2498
2685
 
2499
- export const _internal = { traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
2686
+ export const _internal = { traeHost, accountOf, HOSTS: SITES["trae-cn"].hosts, MODELS: SITES["trae-cn"].models, TextTools, NativeCalls, looseJSON, glmCall, nameCall, toolNamed, unwrapped, traeMessages, chatBody, credits, dollarUsageOf, whenOf, newDevice, proofOf, SITES, SESSION, conversationID }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-trae-auth",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "OpenCode provider plugins for Trae: Trae CN (trae.cn) and Trae Global (trae.ai), free tier included, with Trae's own browser sign-in",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",