@aexol/spectral 0.9.228 → 0.9.230

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.
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Contract (Batch 1 backend):
5
5
  * POST <backend>/api/machines/register
6
- * Headers: Authorization: Bearer <teamApiKey|userJwt>
6
+ * Headers: Authorization: Bearer <userJwt|userApiKey|teamApiKey>
7
7
  * Body: { name?: string, hostname: string, version: string, pid: number }
8
- * 200: { machineId: string, jwt: string, name: string, ownerId?: string }
8
+ * 200: { machineId: string, jwt: string, name: string, teamId?, ownerId?, visibility? }
9
9
  *
10
10
  * Auth priority:
11
11
  * - If `userJwt` is set → used as Bearer token (machine gets ownerId)
12
+ * - Else if `userApiKey` is set → used as Bearer token (machine is PRIVATE
13
+ * with ownerId derived from the key by the backend)
12
14
  * - Else → `apiKey` (team API key, machine gets no owner)
13
15
  *
14
16
  * Why this lives in its own module:
@@ -30,6 +32,12 @@ export interface RegistrationDeps {
30
32
  backendUrl: string;
31
33
  /** Team API key — used when `userJwt` is not set. */
32
34
  apiKey: string;
35
+ /**
36
+ * Optional user API key ("sk-aexol-user-"), pinned to a team but owned by a
37
+ * single user. Used when `userJwt` is not set; takes priority over `apiKey`.
38
+ * The backend registers the machine as PRIVATE with ownerId from the key.
39
+ */
40
+ userApiKey?: string;
33
41
  /** Optional user JWT from OAuth login. When set, used instead of `apiKey` for registration. */
34
42
  userJwt?: string;
35
43
  /** Display name override (from `--machine-name`). Falls back to hostname. */
@@ -1 +1 @@
1
- {"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../../src/relay/registration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,EAA4B,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAElF,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,MAAM,EAAE,MAAM,CAAC;IACf,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8BAA8B;IAC9B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAWvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUvD;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,SAAK,GAAG,OAAO,CAI/D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,gBAAgB,CAAC,CA+G3B"}
1
+ {"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../../src/relay/registration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,EAA4B,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAElF,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8BAA8B;IAC9B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAWvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUvD;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,SAAK,GAAG,OAAO,CAI/D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,gBAAgB,CAAC,CA2I3B"}
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Contract (Batch 1 backend):
5
5
  * POST <backend>/api/machines/register
6
- * Headers: Authorization: Bearer <teamApiKey|userJwt>
6
+ * Headers: Authorization: Bearer <userJwt|userApiKey|teamApiKey>
7
7
  * Body: { name?: string, hostname: string, version: string, pid: number }
8
- * 200: { machineId: string, jwt: string, name: string, ownerId?: string }
8
+ * 200: { machineId: string, jwt: string, name: string, teamId?, ownerId?, visibility? }
9
9
  *
10
10
  * Auth priority:
11
11
  * - If `userJwt` is set → used as Bearer token (machine gets ownerId)
12
+ * - Else if `userApiKey` is set → used as Bearer token (machine is PRIVATE
13
+ * with ownerId derived from the key by the backend)
12
14
  * - Else → `apiKey` (team API key, machine gets no owner)
13
15
  *
14
16
  * Why this lives in its own module:
@@ -92,6 +94,7 @@ export async function ensureMachineRegistered(deps) {
92
94
  const skew = deps.expirySkewSec ?? 60;
93
95
  const existing = await loadMachine();
94
96
  const forceRefresh = deps.forceRefresh === true;
97
+ const usingUserApiKey = !deps.userJwt && !!deps.userApiKey;
95
98
  if (!forceRefresh && existing && !isJwtExpired(existing.machineJwt, skew)) {
96
99
  // The cached machine JWT is still valid. If the caller authenticated with a
97
100
  // user JWT (OAuth login) and the cached machine was previously owned by a
@@ -106,6 +109,21 @@ export async function ensureMachineRegistered(deps) {
106
109
  shouldReuse = false;
107
110
  }
108
111
  }
112
+ else if (usingUserApiKey) {
113
+ // A user API key carries no `sub` claim, so identity is tracked by the
114
+ // key itself: `ensureMachineRegistered` records the key used for the
115
+ // last registration in `machine.json` extra. A different key means a
116
+ // different user (the backend derives `ownerId` from the key), so the
117
+ // machine must be re-registered to transfer ownership and flip
118
+ // visibility to PRIVATE. A missing marker means the cached machine was
119
+ // registered with a team key — re-register so the backend can make it
120
+ // PRIVATE for this user.
121
+ const previousKey = existing.extra?.userApiKey;
122
+ if (previousKey !== deps.userApiKey) {
123
+ console.log("Machine registered under a different user API key, re-registering to transfer ownership...");
124
+ shouldReuse = false;
125
+ }
126
+ }
109
127
  if (shouldReuse) {
110
128
  return { reused: true, record: existing };
111
129
  }
@@ -114,6 +132,8 @@ export async function ensureMachineRegistered(deps) {
114
132
  console.warn("Re-registering machine: cached JWT was rejected by the backend (forceRefresh).");
115
133
  }
116
134
  const fetchImpl = deps.fetchImpl ?? fetch;
135
+ // Token priority: userJwt (OAuth) > userApiKey (user-pinned key) > team key.
136
+ const authToken = deps.userJwt ?? deps.userApiKey ?? deps.apiKey;
117
137
  const url = `${deps.backendUrl.replace(/\/$/, "")}/api/machines/register`;
118
138
  const body = {
119
139
  // Reuse the cached display name on JWT-expiry refresh so the backend's
@@ -129,7 +149,7 @@ export async function ensureMachineRegistered(deps) {
129
149
  res = await fetchImpl(url, {
130
150
  method: "POST",
131
151
  headers: {
132
- Authorization: `Bearer ${deps.userJwt ?? deps.apiKey}`,
152
+ Authorization: `Bearer ${authToken}`,
133
153
  "Content-Type": "application/json",
134
154
  },
135
155
  body: JSON.stringify(body),
@@ -163,6 +183,14 @@ export async function ensureMachineRegistered(deps) {
163
183
  typeof obj.name !== "string") {
164
184
  throw new Error(`Machine registration: backend response missing required fields (machineId, jwt, name)`);
165
185
  }
186
+ const extra = { ...(existing?.extra ?? {}) };
187
+ delete extra.userApiKey;
188
+ if (usingUserApiKey) {
189
+ // Record which key was used so a later run with a different user key
190
+ // (a different account) forces re-registration. machine.json is 0600,
191
+ // same as config.json where the key already lives.
192
+ extra.userApiKey = deps.userApiKey;
193
+ }
166
194
  const record = {
167
195
  machineId: obj.machineId,
168
196
  machineName: obj.name,
@@ -173,6 +201,7 @@ export async function ensureMachineRegistered(deps) {
173
201
  registeredAt: Date.now(),
174
202
  hostname: hostname(),
175
203
  version: deps.version,
204
+ extra: Object.keys(extra).length > 0 ? extra : undefined,
176
205
  };
177
206
  await saveMachine(record);
178
207
  return { reused: false, forced: forceRefresh, record };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Parser for tool calls that a model leaked into plain text output as
3
+ * antml-style XML markup instead of emitting them through the native
4
+ * `tool_calls` stream.
5
+ *
6
+ * Observed shape (OpenRouter models occasionally emit this despite native
7
+ * tools being passed in the request):
8
+ *
9
+ * wrapper-open tag
10
+ * invoke-open tag with name attribute
11
+ * parameter-open tag with name attribute ... value ... parameter-close tag
12
+ * invoke-close tag
13
+ * wrapper-close tag
14
+ *
15
+ * where the wrapper tag is `function_calls` (optionally with an `antml:`
16
+ * namespace prefix) and invoke/parameter tags follow the same convention.
17
+ *
18
+ * Everything here is pure string logic — no provider imports — so the tests
19
+ * cover it without any streaming harness.
20
+ */
21
+ /** Longest opener tag is the antml-prefixed one (22 chars). Keep a margin. */
22
+ export declare const LEAK_TAIL_LENGTH = 32;
23
+ export type LeakedToolCall = {
24
+ name: string;
25
+ args: Record<string, string>;
26
+ };
27
+ export type LeakOpenerMatch = "full" | "partial" | "none";
28
+ /**
29
+ * Locate a leaked wrapper-opener tag in `text`.
30
+ * - "full": a complete opener tag exists; `index` points at its `<`.
31
+ * - "partial": the text ENDS with a strict prefix of an opener tag (an opener
32
+ * split across streaming deltas); `index` points at the start of that tail.
33
+ * - "none": no opener, complete or partial.
34
+ */
35
+ export declare function findLeakedToolCallOpener(text: string): {
36
+ match: LeakOpenerMatch;
37
+ index: number;
38
+ };
39
+ /**
40
+ * Extract tool calls from a leaked wrapper block.
41
+ *
42
+ * Returns:
43
+ * - `complete: true` → the wrapper closer was found; `calls` holds every
44
+ * invoke in order and `remaining` is the text outside the block (prose
45
+ * before + after). `consumed` is the raw block text (opener→closer
46
+ * inclusive) so callers can fall back to emitting it as text when the
47
+ * parsed tool names turn out to be unknown.
48
+ * - `complete: false` → no closed wrapper block yet; `calls` is empty and
49
+ * `remaining`/`consumed` echo the input so nothing is cut prematurely.
50
+ */
51
+ export declare function extractLeakedToolCalls(text: string): {
52
+ calls: LeakedToolCall[];
53
+ complete: boolean;
54
+ remaining: string;
55
+ consumed: string;
56
+ };
57
+ /**
58
+ * Coerce a leaked parameter string into a JSON value when it clearly parses
59
+ * as one — native `tool_calls` arguments are real JSON, so a leaked
60
+ * `<parameter name="x">[1,2]</parameter>` should arrive as an array, not a
61
+ * string. Anything that does not fully parse stays a string.
62
+ */
63
+ export declare function coerceLeakedParameterValue(raw: string): unknown;
64
+ //# sourceMappingURL=leaked-tool-call-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"leaked-tool-call-parser.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/leaked-tool-call-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAiBH,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,MAAM,MAAM,cAAc,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAI1D;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACvC,IAAI,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA0B3C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG;IACrD,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CACjB,CAoCA;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAW/D"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Parser for tool calls that a model leaked into plain text output as
3
+ * antml-style XML markup instead of emitting them through the native
4
+ * `tool_calls` stream.
5
+ *
6
+ * Observed shape (OpenRouter models occasionally emit this despite native
7
+ * tools being passed in the request):
8
+ *
9
+ * wrapper-open tag
10
+ * invoke-open tag with name attribute
11
+ * parameter-open tag with name attribute ... value ... parameter-close tag
12
+ * invoke-close tag
13
+ * wrapper-close tag
14
+ *
15
+ * where the wrapper tag is `function_calls` (optionally with an `antml:`
16
+ * namespace prefix) and invoke/parameter tags follow the same convention.
17
+ *
18
+ * Everything here is pure string logic — no provider imports — so the tests
19
+ * cover it without any streaming harness.
20
+ */
21
+ /** Namespace prefix variants are matched verbatim, case-sensitively. */
22
+ const NS = "(?:antml:)?";
23
+ const INVOKE_RE = new RegExp(`<${NS}invoke\\s+name="([^"]*)"\\s*>([\\s\\S]*?)</${NS}invoke\\s*>`, "g");
24
+ const PARAMETER_RE = new RegExp(`<${NS}parameter\\s+name="([^"]*)"\\s*>([\\s\\S]*?)</${NS}parameter\\s*>`, "g");
25
+ const WRAPPER_OPEN_RE = /<(?:antml:)?function_calls\s*>/;
26
+ const WRAPPER_CLOSE = /<\/(?:antml:)?function_calls\s*>/;
27
+ /** Longest opener tag is the antml-prefixed one (22 chars). Keep a margin. */
28
+ export const LEAK_TAIL_LENGTH = 32;
29
+ const OPENER_PREFIXES = ["<function_calls", "<antml:function_calls"];
30
+ /**
31
+ * Locate a leaked wrapper-opener tag in `text`.
32
+ * - "full": a complete opener tag exists; `index` points at its `<`.
33
+ * - "partial": the text ENDS with a strict prefix of an opener tag (an opener
34
+ * split across streaming deltas); `index` points at the start of that tail.
35
+ * - "none": no opener, complete or partial.
36
+ */
37
+ export function findLeakedToolCallOpener(text) {
38
+ let best = {
39
+ match: "none",
40
+ index: -1,
41
+ };
42
+ // Full openers allow trailing whitespace before `>`, mirroring
43
+ // WRAPPER_OPEN_RE so both matchers agree on what an opener is.
44
+ for (const m of text.matchAll(/<(?:antml:)?function_calls\s*>/g)) {
45
+ if (best.match !== "full" || m.index < best.index) {
46
+ best = { match: "full", index: m.index };
47
+ }
48
+ }
49
+ if (best.match === "full")
50
+ return best;
51
+ for (const opener of OPENER_PREFIXES) {
52
+ const maxLen = Math.min(opener.length - 1, text.length);
53
+ for (let size = maxLen; size > 0; size--) {
54
+ if (text.endsWith(opener.slice(0, size))) {
55
+ const index = text.length - size;
56
+ if (best.match === "none" || index < best.index) {
57
+ best = { match: "partial", index };
58
+ }
59
+ break;
60
+ }
61
+ }
62
+ }
63
+ return best;
64
+ }
65
+ /**
66
+ * Extract tool calls from a leaked wrapper block.
67
+ *
68
+ * Returns:
69
+ * - `complete: true` → the wrapper closer was found; `calls` holds every
70
+ * invoke in order and `remaining` is the text outside the block (prose
71
+ * before + after). `consumed` is the raw block text (opener→closer
72
+ * inclusive) so callers can fall back to emitting it as text when the
73
+ * parsed tool names turn out to be unknown.
74
+ * - `complete: false` → no closed wrapper block yet; `calls` is empty and
75
+ * `remaining`/`consumed` echo the input so nothing is cut prematurely.
76
+ */
77
+ export function extractLeakedToolCalls(text) {
78
+ const openMatch = WRAPPER_OPEN_RE.exec(text);
79
+ if (!openMatch) {
80
+ return { calls: [], complete: false, remaining: text, consumed: "" };
81
+ }
82
+ const openStart = openMatch.index;
83
+ const bodyStart = openStart + openMatch[0].length;
84
+ let closeStart = -1;
85
+ let closeEnd = -1;
86
+ for (const closer of ["</function_calls", "</antml:function_calls"]) {
87
+ const idx = text.indexOf(closer, bodyStart);
88
+ if (idx >= 0 && (closeStart === -1 || idx < closeStart)) {
89
+ closeStart = idx;
90
+ const gt = text.indexOf(">", idx);
91
+ if (gt === -1)
92
+ return { calls: [], complete: false, remaining: text, consumed: "" };
93
+ closeEnd = gt;
94
+ }
95
+ }
96
+ if (closeStart === -1) {
97
+ return { calls: [], complete: false, remaining: text, consumed: "" };
98
+ }
99
+ const region = text.slice(bodyStart, closeStart);
100
+ const calls = [];
101
+ for (const m of region.matchAll(INVOKE_RE)) {
102
+ const args = {};
103
+ for (const p of m[2].matchAll(PARAMETER_RE)) {
104
+ args[p[1]] = p[2].trim();
105
+ }
106
+ calls.push({ name: m[1], args });
107
+ }
108
+ return {
109
+ calls,
110
+ complete: true,
111
+ consumed: text.slice(openStart, closeEnd + 1),
112
+ remaining: text.slice(0, openStart) + text.slice(closeEnd + 1),
113
+ };
114
+ }
115
+ /**
116
+ * Coerce a leaked parameter string into a JSON value when it clearly parses
117
+ * as one — native `tool_calls` arguments are real JSON, so a leaked
118
+ * `<parameter name="x">[1,2]</parameter>` should arrive as an array, not a
119
+ * string. Anything that does not fully parse stays a string.
120
+ */
121
+ export function coerceLeakedParameterValue(raw) {
122
+ const trimmed = raw.trim();
123
+ if (!trimmed)
124
+ return raw;
125
+ if (!/^[[{"]/.test(trimmed) && !/^(true|false|null)$/.test(trimmed) && !/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed)) {
126
+ return raw;
127
+ }
128
+ try {
129
+ return JSON.parse(trimmed);
130
+ }
131
+ catch {
132
+ return raw;
133
+ }
134
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAMX,0BAA0B,EAE1B,MAAM,sCAAsC,CAAC;AAI9C,OAAO,KAAK,EAGX,OAAO,EAGP,KAAK,EACL,uBAAuB,EACvB,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAMb,MAAM,aAAa,CAAC;AAoErB,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC9D,UAAU,CAAC,EACR,MAAM,GACN,MAAM,GACN,UAAU,GACV;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACpD,eAAe,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;CAClE;AAED,KAAK,+BAA+B,GAAG,QAAQ,CAAC,uBAAuB,CAAC,CAAC;AAqRzE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CACnD,oBAAoB,EACpB,wBAAwB,CAsexB,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,cAAc,CACzD,oBAAoB,EACpB,mBAAmB,CAyBnB,CAAC;AAkNF,wBAAgB,eAAe,CAC9B,KAAK,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,+BAA+B,GACrC,0BAA0B,EAAE,CA0R9B"}
1
+ {"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAMX,0BAA0B,EAE1B,MAAM,sCAAsC,CAAC;AAI9C,OAAO,KAAK,EAGX,OAAO,EAGP,KAAK,EACL,uBAAuB,EACvB,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAMb,MAAM,aAAa,CAAC;AAmFrB,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC9D,UAAU,CAAC,EACR,MAAM,GACN,MAAM,GACN,UAAU,GACV;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACpD,eAAe,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;CAClE;AAED,KAAK,+BAA+B,GAAG,QAAQ,CAAC,uBAAuB,CAAC,CAAC;AAqRzE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CACnD,oBAAoB,EACpB,wBAAwB,CAylBxB,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,cAAc,CACzD,oBAAoB,EACpB,mBAAmB,CAyBnB,CAAC;AAkNF,wBAAgB,eAAe,CAC9B,KAAK,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,+BAA+B,GACrC,0BAA0B,EAAE,CA0R9B"}
@@ -1,4 +1,5 @@
1
1
  import OpenAI from "openai";
2
+ import { coerceLeakedParameterValue, extractLeakedToolCalls, findLeakedToolCallOpener, } from "./leaked-tool-call-parser.js";
2
3
  import { backendHostedImageResolver } from "../utils/hosted-image-resolver.js";
3
4
  import { getApiUrl, readConfig } from "../../../config.js";
4
5
  import { getEnvApiKey } from "../env-api-keys.js";
@@ -30,6 +31,19 @@ function hasToolHistory(messages) {
30
31
  }
31
32
  return false;
32
33
  }
34
+ /**
35
+ * Parse streaming tool-call arguments without throwing. Returns undefined
36
+ * when the accumulated JSON fragment cannot be parsed (yet), so a malformed
37
+ * delta never aborts the whole agent turn.
38
+ */
39
+ function safeParseArgs(partial) {
40
+ try {
41
+ return parseStreamingJson(partial);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
33
47
  function isTextContentBlock(block) {
34
48
  return block.type === "text";
35
49
  }
@@ -366,6 +380,16 @@ export const streamOpenAICompletions = (model, context, options) => {
366
380
  let rawChunkCount = 0;
367
381
  let inlineThinkingPending = "";
368
382
  let inInlineThinking = false;
383
+ // Leaked tool-call XML: some OpenRouter models emit antml-style
384
+ // markup in plain content instead of native tool_calls. Text is
385
+ // buffered here until a complete wrapper block arrives, then parsed
386
+ // into real toolCall blocks (see leaked-tool-call-parser.ts).
387
+ let leakMode = false;
388
+ let leakBuffer = "";
389
+ let leakPending = "";
390
+ let leakedToolCallSeq = 0;
391
+ let emittedLeakedToolCalls = false;
392
+ const availableToolNames = new Set((context.tools ?? []).map((tool) => tool.name));
369
393
  const toolCallBlocksByIndex = new Map();
370
394
  const toolCallBlocksById = new Map();
371
395
  const blocks = output.content;
@@ -393,7 +417,7 @@ export const streamOpenAICompletions = (model, context, options) => {
393
417
  });
394
418
  }
395
419
  else if (block.type === "toolCall") {
396
- block.arguments = parseStreamingJson(block.partialArgs);
420
+ block.arguments = safeParseArgs(block.partialArgs) ?? {};
397
421
  // Finalize in-place and strip the scratch buffers so replay only
398
422
  // carries parsed arguments.
399
423
  delete block.partialArgs;
@@ -486,7 +510,7 @@ export const streamOpenAICompletions = (model, context, options) => {
486
510
  emitThinkingDelta("inline_think", part.delta);
487
511
  }
488
512
  else {
489
- emitTextDelta(part.delta);
513
+ processTextDelta(part.delta);
490
514
  }
491
515
  }
492
516
  };
@@ -529,6 +553,91 @@ export const streamOpenAICompletions = (model, context, options) => {
529
553
  }
530
554
  return block;
531
555
  };
556
+ // Synthetic tool calls parsed from leaked XML travel the same path
557
+ // as native ones (ensureToolCallBlock → finishBlock → toolcall_end),
558
+ // but use negative stream indexes so they can never collide with
559
+ // native tool_call indexes in the same response.
560
+ const emitSyntheticToolCall = (call) => {
561
+ const args = {};
562
+ for (const [key, value] of Object.entries(call.args)) {
563
+ args[key] = coerceLeakedParameterValue(value);
564
+ }
565
+ const block = ensureToolCallBlock({
566
+ id: `leaked_tc_${Date.now().toString(36)}_${Math.random()
567
+ .toString(36)
568
+ .slice(2, 8)}_${++leakedToolCallSeq}`,
569
+ index: -1 - leakedToolCallSeq,
570
+ function: { name: call.name, arguments: JSON.stringify(args) },
571
+ });
572
+ block.partialArgs = JSON.stringify(args);
573
+ block.arguments = args;
574
+ sawStreamOutput = true;
575
+ stream.push({
576
+ type: "toolcall_delta",
577
+ contentIndex: getContentIndex(block),
578
+ delta: block.partialArgs,
579
+ partial: output,
580
+ });
581
+ };
582
+ const drainLeakBuffer = () => {
583
+ while (true) {
584
+ if (!leakMode) {
585
+ const m = findLeakedToolCallOpener(leakPending);
586
+ if (m.match === "full") {
587
+ const before = leakPending.slice(0, m.index);
588
+ if (before)
589
+ emitTextDelta(before);
590
+ leakBuffer = leakPending.slice(m.index);
591
+ leakPending = "";
592
+ leakMode = true;
593
+ }
594
+ else if (m.match === "partial") {
595
+ const before = leakPending.slice(0, m.index);
596
+ if (before)
597
+ emitTextDelta(before);
598
+ leakPending = leakPending.slice(m.index);
599
+ return;
600
+ }
601
+ else {
602
+ if (leakPending)
603
+ emitTextDelta(leakPending);
604
+ leakPending = "";
605
+ return;
606
+ }
607
+ }
608
+ const parsed = extractLeakedToolCalls(leakBuffer);
609
+ if (!parsed.complete)
610
+ return; // keep buffering until the closer arrives
611
+ // All-or-nothing tool-name validation: a block naming a tool
612
+ // that does not exist in this context is prose (legal XML in
613
+ // an answer), not a tool call — emit it as text instead.
614
+ if (parsed.calls.length > 0 &&
615
+ parsed.calls.every((c) => availableToolNames.has(c.name))) {
616
+ for (const call of parsed.calls)
617
+ emitSyntheticToolCall(call);
618
+ emittedLeakedToolCalls = true;
619
+ }
620
+ else {
621
+ emitTextDelta(parsed.consumed);
622
+ }
623
+ leakBuffer = "";
624
+ leakMode = false;
625
+ leakPending = parsed.remaining; // may contain another block
626
+ }
627
+ };
628
+ // Route plain content text through leak detection. Holds back a
629
+ // bounded tail so an opener split across deltas is still caught.
630
+ const processTextDelta = (text) => {
631
+ if (!text)
632
+ return;
633
+ if (leakMode) {
634
+ leakBuffer += text;
635
+ }
636
+ else {
637
+ leakPending += text;
638
+ }
639
+ drainLeakBuffer();
640
+ };
532
641
  for await (const chunk of openaiStream) {
533
642
  rawChunkCount++;
534
643
  if (!chunk || typeof chunk !== "object")
@@ -597,7 +706,12 @@ export const streamOpenAICompletions = (model, context, options) => {
597
706
  delta = toolCall.function.arguments;
598
707
  block.partialArgs =
599
708
  (block.partialArgs ?? "") + toolCall.function.arguments;
600
- block.arguments = parseStreamingJson(block.partialArgs);
709
+ const parsed = safeParseArgs(block.partialArgs);
710
+ if (parsed !== undefined) {
711
+ // Keep the last-good arguments if this fragment fails to
712
+ // parse; a later delta usually completes the JSON.
713
+ block.arguments = parsed;
714
+ }
601
715
  }
602
716
  sawStreamOutput = true;
603
717
  stream.push({
@@ -632,13 +746,31 @@ export const streamOpenAICompletions = (model, context, options) => {
632
746
  emitThinkingDelta("inline_think", inlineThinkingPending);
633
747
  }
634
748
  else {
635
- emitTextDelta(inlineThinkingPending);
749
+ processTextDelta(inlineThinkingPending);
636
750
  }
637
751
  inlineThinkingPending = "";
638
752
  }
753
+ // Flush leak buffers. A never-closed block is emitted as plain text
754
+ // (graceful degradation) so no content is lost; a held-back tail
755
+ // without an opener is just prose.
756
+ if (leakMode && leakBuffer) {
757
+ emitTextDelta(leakBuffer);
758
+ leakBuffer = "";
759
+ leakMode = false;
760
+ }
761
+ if (leakPending) {
762
+ emitTextDelta(leakPending);
763
+ leakPending = "";
764
+ }
639
765
  for (const block of blocks) {
640
766
  finishBlock(block);
641
767
  }
768
+ // The provider finished with "stop" even though the model leaked its
769
+ // tool calls into text; the agent loop only continues on "toolUse",
770
+ // so promote the parsed synthetic calls.
771
+ if (emittedLeakedToolCalls && output.stopReason === "stop") {
772
+ output.stopReason = "toolUse";
773
+ }
642
774
  if (options?.signal?.aborted) {
643
775
  throw new Error("Request was aborted");
644
776
  }
@@ -1 +1 @@
1
- {"version":3,"file":"system-prompt.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/core/system-prompt.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAyB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACxC,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6FAA6F;IAC7F,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,yBAAyB;IACzB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CAsM3E"}
1
+ {"version":3,"file":"system-prompt.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/core/system-prompt.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAyB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACxC,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6FAA6F;IAC7F,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,yBAAyB;IACzB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CA6M3E"}
@@ -75,6 +75,11 @@ export function buildSystemPrompt(options) {
75
75
  }
76
76
  addGuideline("When creating new files, maintain a tree-like directory structure. Place files in semantically meaningful subdirectories (e.g. `handlers/auth/`, `utils/string/`) rather than dumping them at the project root or in shallow flat folders. Group related files into dedicated folders, prefer `feature/module/file.ts` over `feature-file.ts` at root. Avoid creating files directly in root unless they are truly project-wide (e.g. config, package.json). Keep folder depth reasonable (max 5–6 levels) but prefer nesting over flattening — tree-like structure improves LLM navigability and agentic coding efficiency.");
77
77
  addGuideline("When editing code, first inspect nearby files and existing patterns; follow the codebase’s conventions and libraries");
78
+ // Some models (notably via OpenRouter) emit antml-style XML tool calls in
79
+ // plain text instead of using the native tool-call mechanism. The provider
80
+ // parses those as a fallback, but the native path is strictly better
81
+ // (streaming, validation, real tool results in the transcript) — say so.
82
+ addGuideline("Always invoke tools through the native tool-call mechanism. Never write tool calls as XML markup or fake tags in plain text (e.g. wrapper/invoke/parameter style blocks) — they must never appear in your prose output.");
78
83
  // Always include these
79
84
  if (hasSubagent) {
80
85
  addGuideline("Subagent mode is enabled: use the subagent tool for non-trivial work when specialized or parallel delegation improves quality or speed.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.228",
3
+ "version": "0.9.230",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,