copilot-reverse 0.5.2 → 0.5.3

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.
@@ -86,13 +86,26 @@ export class CopilotAdapter {
86
86
  const data = (await res.json());
87
87
  const choice = data.choices[0];
88
88
  const content = [];
89
- if (choice.message.content)
90
- content.push({ type: "text", text: choice.message.content });
89
+ // Recover inline-XML tool calls in non-stream replies too (same reason as the stream path).
90
+ let xmlTool = false;
91
+ if (choice.message.content) {
92
+ const ex = new ToolCallExtractor();
93
+ for (const ev of [...ex.feed(choice.message.content), ...ex.flush()]) {
94
+ if (ev.kind === "text") {
95
+ if (ev.text)
96
+ content.push({ type: "text", text: ev.text });
97
+ }
98
+ else {
99
+ xmlTool = true;
100
+ content.push({ type: "tool_use", id: ev.tool.id, name: ev.tool.name, input: ev.tool.input });
101
+ }
102
+ }
103
+ }
91
104
  for (const tc of choice.message.tool_calls ?? [])
92
105
  content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) });
93
106
  return {
94
107
  id: data.id ?? `cmpl-${randomUUID().replace(/-/g, "")}`, model: req.model, content,
95
- finishReason: choice.finish_reason === "tool_calls" ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
108
+ finishReason: choice.finish_reason === "tool_calls" || xmlTool ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
96
109
  usage: { promptTokens: data.usage?.prompt_tokens ?? 0, completionTokens: data.usage?.completion_tokens ?? 0 },
97
110
  };
98
111
  }
@@ -134,9 +147,11 @@ export class CopilotAdapter {
134
147
  let usage;
135
148
  const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
136
149
  // Some models emit a tool call as inline XML text instead of native tool_calls (more likely on
137
- // long/tool-heavy turns). When the request has tools, route assistant text through an extractor
138
- // that recovers those blocks into structured tool calls; otherwise text passes straight through.
139
- const extractor = req.tools?.length ? new ToolCallExtractor() : undefined;
150
+ // long/tool-heavy turns) and they do it even when THIS request declared no tools (a follow-up
151
+ // turn, or a model that ignores the tools field). Always run assistant text through the extractor;
152
+ // it only captures on the distinctive `<invoke>`/`<function_calls>` sentinel and flushes anything
153
+ // unparseable back as text, so plain prose is unaffected.
154
+ const extractor = new ToolCallExtractor();
140
155
  let extractedTool = false;
141
156
  let extIdx = 100; // separate index space so recovered tools never collide with native tool_calls
142
157
  const toChunks = (events) => {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ToolCallExtractor } from "../../core/tool-xml.js";
2
3
  // Outbound translation to GitHub Copilot's OpenAI Responses API. Newer Copilot models (e.g. gpt-5.5)
3
4
  // are served ONLY on /responses — their `supported_endpoints` omits /chat/completions — so the adapter
4
5
  // routes them here instead of the chat path. This is the mirror image of core/responses-inbound.ts
@@ -68,8 +69,20 @@ export function parseResponsesResult(data) {
68
69
  for (const item of data.output ?? []) {
69
70
  if (item.type === "message") {
70
71
  const text = (item.content ?? []).filter((p) => p.type === "output_text").map((p) => p.text ?? "").join("");
71
- if (text)
72
- content.push({ type: "text", text });
72
+ if (text) {
73
+ // Recover inline-XML tool calls here too (some models emit them as output_text).
74
+ const ex = new ToolCallExtractor();
75
+ for (const ev of [...ex.feed(text), ...ex.flush()]) {
76
+ if (ev.kind === "text") {
77
+ if (ev.text)
78
+ content.push({ type: "text", text: ev.text });
79
+ }
80
+ else {
81
+ sawTool = true;
82
+ content.push({ type: "tool_use", id: ev.tool.id, name: ev.tool.name, input: ev.tool.input });
83
+ }
84
+ }
85
+ }
73
86
  }
74
87
  else if (item.type === "function_call") {
75
88
  sawTool = true;
@@ -99,6 +112,25 @@ export async function* streamResponses(res) {
99
112
  let usage;
100
113
  const toolByOutputIndex = new Map(); // responses output_index -> canonical tool index
101
114
  let nextToolIndex = 0;
115
+ // Some models stream a tool call as inline XML text instead of a function_call item; recover it.
116
+ // Extracted tools use a high index space so they never collide with native function_call indices.
117
+ const extractor = new ToolCallExtractor();
118
+ let extIdx = 100, extractedTool = false;
119
+ const toChunks = (events) => {
120
+ const out = [];
121
+ for (const ev of events) {
122
+ if (ev.kind === "text") {
123
+ if (ev.text)
124
+ out.push({ kind: "text", delta: ev.text, done: false });
125
+ continue;
126
+ }
127
+ const index = extIdx++;
128
+ extractedTool = true;
129
+ out.push({ kind: "tool_use_start", index, id: ev.tool.id, name: ev.tool.name, done: false });
130
+ out.push({ kind: "tool_use_delta", index, argsDelta: JSON.stringify(ev.tool.input), done: false });
131
+ }
132
+ return out;
133
+ };
102
134
  const usageOf = (u) => u ? { promptTokens: u.input_tokens ?? 0, completionTokens: u.output_tokens ?? 0, cachedTokens: u.input_tokens_details?.cached_tokens ?? 0 } : undefined;
103
135
  for (;;) {
104
136
  const { value, done } = await reader.read();
@@ -133,7 +165,8 @@ export async function* streamResponses(res) {
133
165
  }
134
166
  case "response.output_text.delta":
135
167
  if (ev.delta)
136
- yield { kind: "text", delta: ev.delta, done: false };
168
+ for (const ch of toChunks(extractor.feed(ev.delta)))
169
+ yield ch;
137
170
  break;
138
171
  case "response.function_call_arguments.delta": {
139
172
  const idx = toolByOutputIndex.get(ev.output_index);
@@ -142,7 +175,7 @@ export async function* streamResponses(res) {
142
175
  break;
143
176
  }
144
177
  case "response.completed":
145
- if (toolByOutputIndex.size)
178
+ if (toolByOutputIndex.size || extractedTool)
146
179
  finishReason = "tool_use";
147
180
  usage = usageOf(ev.response?.usage) ?? usage;
148
181
  break;
@@ -157,5 +190,9 @@ export async function* streamResponses(res) {
157
190
  }
158
191
  }
159
192
  }
193
+ for (const ch of toChunks(extractor.flush()))
194
+ yield ch;
195
+ if (extractedTool && finishReason === "stop")
196
+ finishReason = "tool_use";
160
197
  yield { kind: "done", done: true, finishReason, usage };
161
198
  }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
2
- export const APP_VERSION = "0.5.2";
2
+ export const APP_VERSION = "0.5.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
5
5
  "type": "module",
6
6
  "license": "MIT",