copilot-reverse 0.2.0 → 0.3.0
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.
- package/dist/cli/auth.js +17 -3
- package/dist/cli/index.js +60 -16
- package/dist/core/anthropic-inbound.js +24 -6
- package/dist/core/responses-inbound.js +140 -0
- package/dist/core/server-tools.js +43 -0
- package/dist/providers/copilot/models.js +14 -0
- package/dist/providers/webiq/client.js +66 -0
- package/dist/shared/webiq-key.js +21 -0
- package/dist/tui/app.js +69 -4
- package/dist/tui/assistant/on-chat.js +10 -1
- package/dist/tui/screens/webiq-key.js +30 -0
- package/dist/tui/setup/codex-toml.js +41 -16
- package/dist/tui/slash/commands.js +1 -0
- package/dist/tui/status-summary.js +13 -0
- package/dist/version.js +1 -1
- package/dist/worker/anthropic-server.js +105 -44
- package/dist/worker/index.js +6 -1
- package/dist/worker/openai-server.js +62 -0
- package/dist/worker/server.js +2 -2
- package/package.json +1 -1
package/dist/cli/auth.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { requestDeviceCode, pollForToken } from "../providers/copilot/auth.js";
|
|
2
2
|
import { writeGhToken } from "../shared/creds.js";
|
|
3
|
-
|
|
3
|
+
// Two-phase device login. `beginDeviceLogin` returns the verification code right away so a caller
|
|
4
|
+
// can surface it to the user; `complete()` then blocks on authorization and writes the token.
|
|
5
|
+
// Splitting these is what lets the TUI render the code while the poll is still pending — folding
|
|
6
|
+
// both into one call buffers the code behind the blocking poll, and the user can't authorize a
|
|
7
|
+
// code they can't see.
|
|
8
|
+
export async function beginDeviceLogin(dir, fetchFn = fetch) {
|
|
4
9
|
const code = await requestDeviceCode(fetchFn);
|
|
10
|
+
return {
|
|
11
|
+
code,
|
|
12
|
+
complete: async () => {
|
|
13
|
+
const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
|
|
14
|
+
writeGhToken(token, dir);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
|
|
19
|
+
const { code, complete } = await beginDeviceLogin(dir, fetchFn);
|
|
5
20
|
log(`\nOpen ${code.verification_uri} and enter code: ${code.user_code}\n`);
|
|
6
|
-
|
|
7
|
-
writeGhToken(token, dir);
|
|
21
|
+
await complete();
|
|
8
22
|
log("GitHub authorization complete.");
|
|
9
23
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -5,18 +5,20 @@ import { Command } from "commander";
|
|
|
5
5
|
import { App } from "../tui/app.js";
|
|
6
6
|
import { buildRegistry } from "../tui/slash/commands.js";
|
|
7
7
|
import { DaemonClient } from "../tui/daemon-client.js";
|
|
8
|
-
import { runDeviceLogin } from "./auth.js";
|
|
8
|
+
import { runDeviceLogin, beginDeviceLogin } from "./auth.js";
|
|
9
9
|
import { probeSupervisor } from "../daemon/lifecycle.js";
|
|
10
10
|
import { startSupervisor } from "../supervisor/index.js";
|
|
11
11
|
import { runAssistantTurn } from "../tui/assistant/runtime.js";
|
|
12
12
|
import { makeOnChat } from "../tui/assistant/on-chat.js";
|
|
13
13
|
import { readGhToken, clearGhToken } from "../shared/creds.js";
|
|
14
|
+
import { writeWebIqKey, readWebIqKey } from "../shared/webiq-key.js";
|
|
14
15
|
import { readClientSetup, writeClientSetup } from "../shared/client-setup.js";
|
|
15
16
|
import { readChatModel, writeChatModel } from "../shared/prefs.js";
|
|
16
17
|
import { CopilotTokenStore, isCopilotTokenValid } from "../providers/copilot/token.js";
|
|
17
18
|
import { fetchCopilotModels, fetchModelLimits } from "../providers/copilot/models.js";
|
|
18
19
|
import { applyClaude, applyCodex, resetClaude, resetCodex, CLAUDE_ENV_KEYS, CODEX_ENV_KEYS } from "../tui/setup/apply.js";
|
|
19
20
|
import { readClientStatus } from "../tui/setup/status.js";
|
|
21
|
+
import { summarizeStatus } from "../tui/status-summary.js";
|
|
20
22
|
import { applyCodexToml } from "../tui/setup/codex-toml.js";
|
|
21
23
|
import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
|
|
22
24
|
import { dataDir } from "../shared/paths.js";
|
|
@@ -74,14 +76,6 @@ async function launchTui() {
|
|
|
74
76
|
appVersion: APP_VERSION,
|
|
75
77
|
platform: `${process.platform} node-${process.version}`,
|
|
76
78
|
resetClient,
|
|
77
|
-
// Re-run device-code login, then restart the worker so it picks up the new token.
|
|
78
|
-
login: async () => {
|
|
79
|
-
const lines = [];
|
|
80
|
-
await runDeviceLogin(dataDir(), fetch, (m) => lines.push(m));
|
|
81
|
-
await client.restart().catch(() => { });
|
|
82
|
-
lines.push("worker restarting with the new token");
|
|
83
|
-
return lines;
|
|
84
|
-
},
|
|
85
79
|
// Clear the stored token and restart the worker (it will report unauthenticated until re-login).
|
|
86
80
|
logout: async () => {
|
|
87
81
|
clearGhToken(dataDir());
|
|
@@ -89,9 +83,22 @@ async function launchTui() {
|
|
|
89
83
|
return ["signed out — GitHub token removed", "run /login to sign in again"];
|
|
90
84
|
},
|
|
91
85
|
});
|
|
86
|
+
// Two-phase /login for the TUI: surface the device code immediately, poll in the background, then
|
|
87
|
+
// restart the worker so it picks up the new token. The blocking single-call form deadlocked the
|
|
88
|
+
// Repl (the code stayed hidden behind the poll, so the user could never authorize it).
|
|
89
|
+
const doLogin = async (show) => {
|
|
90
|
+
const { code, complete } = await beginDeviceLogin(dataDir());
|
|
91
|
+
show([`Open ${code.verification_uri} and enter code: ${code.user_code}`, "waiting for authorization…"]);
|
|
92
|
+
await complete();
|
|
93
|
+
// Re-point the token store at the freshly written GitHub token; the old store still holds the
|
|
94
|
+
// expired one and would 401 once its cached Copilot token rotates, breaking the model picker.
|
|
95
|
+
tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
|
|
96
|
+
await client.restart().catch(() => { });
|
|
97
|
+
return ["GitHub authorization complete — worker restarting with the new token"];
|
|
98
|
+
};
|
|
92
99
|
// Filled in below once we have a token; the assistant prefers a model's real window over the default.
|
|
93
100
|
const modelLimits = {};
|
|
94
|
-
|
|
101
|
+
let tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
|
|
95
102
|
const loadModels = async () => {
|
|
96
103
|
const token = await tokenStore.get();
|
|
97
104
|
const [ids, limits] = await Promise.all([fetchCopilotModels(token), fetchModelLimits(token)]);
|
|
@@ -102,19 +109,21 @@ async function launchTui() {
|
|
|
102
109
|
void tokenStore.get().then((t) => fetchModelLimits(t)).then((m) => Object.assign(modelLimits, m)).catch(() => { });
|
|
103
110
|
// Apply a client's config (shared by the /setup wizard and the assistant's setup_* tools).
|
|
104
111
|
// For Claude Code we also write the selected model's real context window so the client doesn't
|
|
112
|
+
// For Claude Code we also write the selected model's real context window so the client doesn't
|
|
105
113
|
// assume the default 200K (which makes a 1M model read "context 100%" far too early). For Codex
|
|
106
|
-
//
|
|
107
|
-
//
|
|
114
|
+
// the native config is ~/.codex/config.toml (what the standalone CLI actually reads); we also keep
|
|
115
|
+
// a legacy .env for older OpenAI-style tooling, but report the config.toml path since that's the
|
|
116
|
+
// one that matters.
|
|
108
117
|
const applyClient = (clientKind, scope, model) => {
|
|
109
118
|
if (clientKind === "claude") {
|
|
110
119
|
const r = applyClaude(scope, claudeCopilotReverseEnv(anthropicBase, "copilot-reverse-local", model, modelLimits[model]));
|
|
111
120
|
writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), claude: true });
|
|
112
121
|
return r;
|
|
113
122
|
}
|
|
114
|
-
|
|
115
|
-
applyCodexToml({ baseUrl: openaiBase, model, contextWindow: modelLimits[model] });
|
|
123
|
+
applyCodex(scope, { OPENAI_BASE_URL: openaiBase, OPENAI_API_KEY: "copilot-reverse-local", OPENAI_MODEL: model }); // legacy .env
|
|
124
|
+
const toml = applyCodexToml({ baseUrl: openaiBase, model, contextWindow: modelLimits[model], apiKey: "copilot-reverse-local" });
|
|
116
125
|
writeClientSetup(dataDir(), { ...readClientSetup(dataDir()), codex: true });
|
|
117
|
-
return
|
|
126
|
+
return toml; // the native config Codex reads — surface this path in the setup card
|
|
118
127
|
};
|
|
119
128
|
const setup = { apply: async (clientKind, scope, model) => applyClient(clientKind, scope, model) };
|
|
120
129
|
const onChat = makeOnChat({
|
|
@@ -122,8 +131,33 @@ async function launchTui() {
|
|
|
122
131
|
maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
|
|
123
132
|
listModels: loadModels,
|
|
124
133
|
setupClient: async (c, s, m) => applyClient(c, s, m),
|
|
125
|
-
}, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort)
|
|
134
|
+
}, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort), undefined,
|
|
135
|
+
// Pre-flight auth gate: block a turn (with an actionable hint) when there's no GitHub token, or
|
|
136
|
+
// the stored one no longer exchanges for a Copilot token — instead of firing a request that just
|
|
137
|
+
// hangs until the turn timeout. Reuses the long-lived tokenStore so a valid login is a cached,
|
|
138
|
+
// round-trip-free check between message bursts (its get() caches with a 60s skew).
|
|
139
|
+
async () => {
|
|
140
|
+
if (!readGhToken(dataDir()))
|
|
141
|
+
return "you're signed out — run /login to sign in before chatting";
|
|
142
|
+
try {
|
|
143
|
+
await tokenStore.get();
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return "your GitHub login has expired — run /login to sign in again";
|
|
148
|
+
}
|
|
149
|
+
});
|
|
126
150
|
const persistedModel = readChatModel(dataDir());
|
|
151
|
+
// Startup overview. The token was already validated above (re-auth happens before we get here), so
|
|
152
|
+
// GitHub is connected; web search readiness and configured clients are read from disk.
|
|
153
|
+
const clientStatus = readClientStatus();
|
|
154
|
+
const startupStatus = summarizeStatus({
|
|
155
|
+
hasToken: Boolean(readGhToken(dataDir())),
|
|
156
|
+
tokenValid: true,
|
|
157
|
+
webSearchReady: Boolean(readWebIqKey(dataDir())),
|
|
158
|
+
worker: "ready",
|
|
159
|
+
clients: { claude: clientStatus.claude.user || clientStatus.claude.project, codex: clientStatus.codex.user || clientStatus.codex.project },
|
|
160
|
+
});
|
|
127
161
|
app = render(React.createElement(App, {
|
|
128
162
|
registry,
|
|
129
163
|
title: "copilot-reverse",
|
|
@@ -143,6 +177,16 @@ async function launchTui() {
|
|
|
143
177
|
},
|
|
144
178
|
onModelChange: (m) => writeChatModel(dataDir(), m),
|
|
145
179
|
pickModelOnStart: !persistedModel,
|
|
180
|
+
login: doLogin,
|
|
181
|
+
saveWebIqKey: (k) => writeWebIqKey(k, dataDir()),
|
|
182
|
+
webSearchReady: () => Boolean(readWebIqKey(dataDir())),
|
|
183
|
+
startupStatus,
|
|
184
|
+
githubStatus: async () => {
|
|
185
|
+
const token = readGhToken(dataDir());
|
|
186
|
+
if (!token)
|
|
187
|
+
return "signed-out";
|
|
188
|
+
return (await isCopilotTokenValid(token)) ? "connected" : "expired";
|
|
189
|
+
},
|
|
146
190
|
}));
|
|
147
191
|
}
|
|
148
192
|
const program = new Command();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GATEWAY_TOOL_DEFS, isGatewayTool } from "./server-tools.js";
|
|
1
2
|
// The Anthropic `system` field may be a plain string or an array of text blocks (the Claude Code
|
|
2
3
|
// SDK sends blocks with cache_control). Flatten either shape to a string — otherwise it stringifies
|
|
3
4
|
// to "[object Object]" and the model gets garbage instructions.
|
|
@@ -41,15 +42,32 @@ export function anthropicRequestToCanonical(req) {
|
|
|
41
42
|
}
|
|
42
43
|
return {
|
|
43
44
|
model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_tokens,
|
|
44
|
-
|
|
45
|
-
// bash, computer, …) arrive with a `type` and no `input_schema`; forwarding them produces an
|
|
46
|
-
// invalid tool the model can't fulfil, and the client hangs forever waiting for a tool_result.
|
|
47
|
-
tools: req.tools
|
|
48
|
-
?.filter((t) => t.input_schema != null && typeof t.input_schema === "object")
|
|
49
|
-
.map((t) => ({ name: t.name, description: t.description, parameters: t.input_schema })),
|
|
45
|
+
tools: mapTools(req.tools),
|
|
50
46
|
messages,
|
|
51
47
|
};
|
|
52
48
|
}
|
|
49
|
+
// Custom tools (with a real JSON-Schema) pass through. Anthropic server-side tools arrive with a
|
|
50
|
+
// dated `type` and no input_schema: web_search / web_fetch are converted to gateway function tools
|
|
51
|
+
// (the gateway runs them itself against WebIQ), and every OTHER server tool (bash, computer, …) is
|
|
52
|
+
// dropped — forwarding an unfulfillable tool makes the client hang forever waiting for a result.
|
|
53
|
+
function mapTools(tools) {
|
|
54
|
+
if (!tools)
|
|
55
|
+
return undefined;
|
|
56
|
+
const out = [];
|
|
57
|
+
let injectedGateway = false;
|
|
58
|
+
for (const t of tools) {
|
|
59
|
+
if (t.input_schema != null && typeof t.input_schema === "object") {
|
|
60
|
+
out.push({ name: t.name, description: t.description, parameters: t.input_schema });
|
|
61
|
+
}
|
|
62
|
+
else if (isGatewayTool(t.name) && !injectedGateway) {
|
|
63
|
+
// Replace the schema-less server tool with our gateway defs. Inject the whole set once so the
|
|
64
|
+
// model can use both web_search and web_fetch whenever it asks for either.
|
|
65
|
+
out.push(...GATEWAY_TOOL_DEFS);
|
|
66
|
+
injectedGateway = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
53
71
|
export function canonicalToAnthropicResponse(r) {
|
|
54
72
|
const content = r.content.map((b) => b.type === "text" ? { type: "text", text: b.text } :
|
|
55
73
|
b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: b.input } :
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { joinText } from "./canonical.js";
|
|
2
|
+
function partsText(content) {
|
|
3
|
+
if (content == null)
|
|
4
|
+
return "";
|
|
5
|
+
if (typeof content === "string")
|
|
6
|
+
return content;
|
|
7
|
+
return content.map((p) => (typeof p === "string" ? p : p?.text ?? "")).join("");
|
|
8
|
+
}
|
|
9
|
+
function partsImages(content) {
|
|
10
|
+
if (!Array.isArray(content))
|
|
11
|
+
return [];
|
|
12
|
+
const urlOf = (p) => typeof p.image_url === "string" ? p.image_url : p.image_url?.url;
|
|
13
|
+
return content.filter((p) => p?.type === "input_image" && urlOf(p)).map((p) => ({ type: "image", dataUrl: urlOf(p) }));
|
|
14
|
+
}
|
|
15
|
+
function safeJson(s) { try {
|
|
16
|
+
return s ? JSON.parse(s) : {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
} }
|
|
21
|
+
function itemToMessage(it) {
|
|
22
|
+
if (it.type === "function_call" && it.call_id) {
|
|
23
|
+
return { role: "assistant", content: [{ type: "tool_use", id: it.call_id, name: it.name ?? "", input: safeJson(it.arguments) }] };
|
|
24
|
+
}
|
|
25
|
+
if (it.type === "function_call_output" && it.call_id) {
|
|
26
|
+
return { role: "tool", content: [{ type: "tool_result", toolUseId: it.call_id, content: it.output ?? "" }] };
|
|
27
|
+
}
|
|
28
|
+
// default: a message item
|
|
29
|
+
const role = (["system", "user", "assistant"].includes(it.role ?? "") ? it.role : "user");
|
|
30
|
+
const content = [];
|
|
31
|
+
const text = partsText(it.content);
|
|
32
|
+
if (text)
|
|
33
|
+
content.push({ type: "text", text });
|
|
34
|
+
content.push(...partsImages(it.content));
|
|
35
|
+
return content.length ? { role, content } : null;
|
|
36
|
+
}
|
|
37
|
+
export function responsesRequestToCanonical(req) {
|
|
38
|
+
const messages = [];
|
|
39
|
+
if (req.instructions)
|
|
40
|
+
messages.push({ role: "system", content: [{ type: "text", text: req.instructions }] });
|
|
41
|
+
if (typeof req.input === "string") {
|
|
42
|
+
messages.push({ role: "user", content: [{ type: "text", text: req.input }] });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
for (const it of req.input) {
|
|
46
|
+
const m = itemToMessage(it);
|
|
47
|
+
if (m)
|
|
48
|
+
messages.push(m);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
model: req.model, stream: Boolean(req.stream), temperature: req.temperature, maxTokens: req.max_output_tokens,
|
|
53
|
+
tools: req.tools?.filter((t) => t.type === "function" && t.name).map((t) => ({ name: t.name, description: t.description, parameters: t.parameters ?? {} })),
|
|
54
|
+
messages,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Build the non-stream Responses object: text -> an output_text message item, tool_use -> function_call items.
|
|
58
|
+
export function canonicalToResponsesResponse(r) {
|
|
59
|
+
const output = [];
|
|
60
|
+
const text = joinText(r.content);
|
|
61
|
+
if (text)
|
|
62
|
+
output.push({ type: "message", id: `msg_${r.id}`, role: "assistant", status: "completed", content: [{ type: "output_text", text, annotations: [] }] });
|
|
63
|
+
for (const b of r.content) {
|
|
64
|
+
if (b.type === "tool_use")
|
|
65
|
+
output.push({ type: "function_call", id: `fc_${b.id}`, call_id: b.id, name: b.name, arguments: JSON.stringify(b.input ?? {}), status: "completed" });
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
id: r.id, object: "response", status: "completed", model: r.model,
|
|
69
|
+
output, output_text: text,
|
|
70
|
+
usage: { input_tokens: r.usage.promptTokens, output_tokens: r.usage.completionTokens, total_tokens: r.usage.promptTokens + r.usage.completionTokens },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Stateful SSE emitter for the Responses stream. Each event carries a monotonically increasing
|
|
74
|
+
// sequence_number (Codex/agent-maestro require it). Text streams as one output_text message item;
|
|
75
|
+
// each tool call is its own function_call output item. Indices are allocated sequentially.
|
|
76
|
+
const frame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
|
77
|
+
export class ResponsesSSE {
|
|
78
|
+
responseId;
|
|
79
|
+
model;
|
|
80
|
+
seq = 0;
|
|
81
|
+
nextIndex = 0;
|
|
82
|
+
textIndex;
|
|
83
|
+
textItemId;
|
|
84
|
+
toolIndex = new Map();
|
|
85
|
+
constructor(responseId, model) {
|
|
86
|
+
this.responseId = responseId;
|
|
87
|
+
this.model = model;
|
|
88
|
+
}
|
|
89
|
+
ev(type, extra) {
|
|
90
|
+
return frame({ type, sequence_number: this.seq++, ...extra });
|
|
91
|
+
}
|
|
92
|
+
envelope(status) {
|
|
93
|
+
return { id: this.responseId, object: "response", status, model: this.model };
|
|
94
|
+
}
|
|
95
|
+
start() {
|
|
96
|
+
return this.ev("response.created", { response: { ...this.envelope("in_progress"), output: [] } });
|
|
97
|
+
}
|
|
98
|
+
text(delta) {
|
|
99
|
+
const out = [];
|
|
100
|
+
if (this.textIndex === undefined) {
|
|
101
|
+
this.textIndex = this.nextIndex++;
|
|
102
|
+
this.textItemId = `msg_${this.responseId}`;
|
|
103
|
+
out.push(this.ev("response.output_item.added", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "in_progress", content: [] } }));
|
|
104
|
+
out.push(this.ev("response.content_part.added", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }));
|
|
105
|
+
}
|
|
106
|
+
out.push(this.ev("response.output_text.delta", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, delta }));
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
toolStart(copilotIdx, callId, name) {
|
|
110
|
+
if (this.toolIndex.has(copilotIdx))
|
|
111
|
+
return [];
|
|
112
|
+
const outputIndex = this.nextIndex++;
|
|
113
|
+
const itemId = `fc_${callId}`;
|
|
114
|
+
this.toolIndex.set(copilotIdx, { outputIndex, itemId });
|
|
115
|
+
return [this.ev("response.output_item.added", { output_index: outputIndex, item: { type: "function_call", id: itemId, call_id: callId, name, arguments: "", status: "in_progress" } })];
|
|
116
|
+
}
|
|
117
|
+
toolArgs(copilotIdx, deltaArgs) {
|
|
118
|
+
const t = this.toolIndex.get(copilotIdx);
|
|
119
|
+
if (!t)
|
|
120
|
+
return [];
|
|
121
|
+
return [this.ev("response.function_call_arguments.delta", { item_id: t.itemId, output_index: t.outputIndex, delta: deltaArgs })];
|
|
122
|
+
}
|
|
123
|
+
// Close all open items and complete the response. `argsByIdx` supplies final accumulated tool args.
|
|
124
|
+
finish(usage, _finishReason, argsByIdx) {
|
|
125
|
+
const out = [];
|
|
126
|
+
if (this.textIndex !== undefined) {
|
|
127
|
+
out.push(this.ev("response.output_text.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, text: "" }));
|
|
128
|
+
out.push(this.ev("response.content_part.done", { item_id: this.textItemId, output_index: this.textIndex, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }));
|
|
129
|
+
out.push(this.ev("response.output_item.done", { output_index: this.textIndex, item: { type: "message", id: this.textItemId, role: "assistant", status: "completed", content: [] } }));
|
|
130
|
+
}
|
|
131
|
+
for (const [copilotIdx, t] of this.toolIndex) {
|
|
132
|
+
const args = argsByIdx?.get(copilotIdx) ?? "";
|
|
133
|
+
out.push(this.ev("response.function_call_arguments.done", { item_id: t.itemId, output_index: t.outputIndex, arguments: args }));
|
|
134
|
+
out.push(this.ev("response.output_item.done", { output_index: t.outputIndex, item: { type: "function_call", id: t.itemId, status: "completed" } }));
|
|
135
|
+
}
|
|
136
|
+
const u = usage ? { input_tokens: usage.promptTokens, output_tokens: usage.completionTokens, total_tokens: usage.promptTokens + usage.completionTokens } : undefined;
|
|
137
|
+
out.push(this.ev("response.completed", { response: { ...this.envelope("completed"), ...(u ? { usage: u } : {}) } }));
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { webSearch, webFetch, formatSearchResults, formatFetchResult } from "../providers/webiq/client.js";
|
|
2
|
+
// Tools the GATEWAY executes itself (against WebIQ), rather than forwarding to the model's client.
|
|
3
|
+
// These mirror Claude Code's server-side web_search / web_fetch, which a Copilot-backed gateway must
|
|
4
|
+
// fulfil internally — the model calls them like normal function tools and we run them in-process.
|
|
5
|
+
export const GATEWAY_TOOL_DEFS = [
|
|
6
|
+
{
|
|
7
|
+
name: "web_search",
|
|
8
|
+
description: "Search the web for current information. Returns ranked results with titles, URLs, and content snippets.",
|
|
9
|
+
parameters: { type: "object", properties: { query: { type: "string", description: "The search query." } }, required: ["query"] },
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "web_fetch",
|
|
13
|
+
description: "Fetch and read the content of a specific web page by URL.",
|
|
14
|
+
parameters: { type: "object", properties: { url: { type: "string", description: "The URL of the page to fetch." } }, required: ["url"] },
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
const GATEWAY_TOOL_NAMES = new Set(GATEWAY_TOOL_DEFS.map((t) => t.name));
|
|
18
|
+
export function isGatewayTool(name) { return GATEWAY_TOOL_NAMES.has(name); }
|
|
19
|
+
const DEFAULT_CLIENT = { search: webSearch, fetchPage: webFetch };
|
|
20
|
+
const NO_KEY = "web search is not configured — run /web-search-support to add a WebIQ API key";
|
|
21
|
+
export function makeGatewayRunner(getKey, client = DEFAULT_CLIENT) {
|
|
22
|
+
return async (name, input) => {
|
|
23
|
+
const key = getKey();
|
|
24
|
+
if (!key)
|
|
25
|
+
return NO_KEY;
|
|
26
|
+
const arg = (input ?? {});
|
|
27
|
+
if (name === "web_search") {
|
|
28
|
+
const query = typeof arg.query === "string" ? arg.query : "";
|
|
29
|
+
if (!query)
|
|
30
|
+
return "web_search error: missing 'query'";
|
|
31
|
+
const out = await client.search(key, { query });
|
|
32
|
+
return out.ok ? formatSearchResults(out.results) : out.error;
|
|
33
|
+
}
|
|
34
|
+
if (name === "web_fetch") {
|
|
35
|
+
const url = typeof arg.url === "string" ? arg.url : "";
|
|
36
|
+
if (!url)
|
|
37
|
+
return "web_fetch error: missing 'url'";
|
|
38
|
+
const out = await client.fetchPage(key, { url });
|
|
39
|
+
return out.ok ? formatFetchResult(out) : out.error;
|
|
40
|
+
}
|
|
41
|
+
return `unknown gateway tool: ${name}`;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -32,6 +32,20 @@ export async function fetchCopilotModels(token, fetchFn = fetch, timeoutMs = DEF
|
|
|
32
32
|
const ids = [...new Set(data.map((m) => m.id).filter((x) => Boolean(x)))];
|
|
33
33
|
return ids.length ? ids : FALLBACK_MODELS;
|
|
34
34
|
}
|
|
35
|
+
// Map of model id -> the Copilot API endpoints it supports (e.g. ["/responses","ws:/responses"]).
|
|
36
|
+
// Used to route each request to the right upstream: newer gpt-5.x models are /responses-only and
|
|
37
|
+
// reject /chat/completions. Returns {} on failure so the adapter falls back to chat/completions.
|
|
38
|
+
export async function fetchModelEndpoints(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
39
|
+
const data = await getModels(token, fetchFn, timeoutMs);
|
|
40
|
+
if (!data)
|
|
41
|
+
return {};
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const m of data) {
|
|
44
|
+
if (m.id && Array.isArray(m.supported_endpoints) && m.supported_endpoints.length)
|
|
45
|
+
out[m.id] = m.supported_endpoints;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
35
49
|
// Map of model id -> its real input/context window, used to size auto-compaction per model and
|
|
36
50
|
// to show the window in the picker. Returns {} on failure/timeout so callers fall back gracefully.
|
|
37
51
|
export async function fetchModelLimits(token, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Microsoft Web IQ REST client. Two grounding endpoints used to back Claude Code's server-side
|
|
2
|
+
// web_search / web_fetch tools, which our gateway executes itself (Copilot can't). Every call
|
|
3
|
+
// returns a discriminated result instead of throwing: a failed search must degrade to a message the
|
|
4
|
+
// model can read and answer around, never abort the in-flight turn.
|
|
5
|
+
const SEARCH_URL = "https://api.microsoft.ai/v3/search/web";
|
|
6
|
+
const BROWSE_URL = "https://api.microsoft.ai/v3/browse";
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
|
+
const headers = (key) => ({ host: "api.microsoft.ai", "x-apikey": key, "content-type": "application/json" });
|
|
9
|
+
// Status -> readable, model-facing reason. Kept identical across both endpoints so the model gets a
|
|
10
|
+
// consistent, actionable string it can reason about (e.g. fall back to its own knowledge).
|
|
11
|
+
function statusError(status, kind) {
|
|
12
|
+
if (status === 401 || status === 403)
|
|
13
|
+
return "web search unavailable: WebIQ API key missing or invalid — run /web-search-support to set it";
|
|
14
|
+
if (status === 429)
|
|
15
|
+
return "web search unavailable: WebIQ rate limit exceeded — try again shortly";
|
|
16
|
+
if (status === 404 && kind === "fetch")
|
|
17
|
+
return "web fetch failed: the page was not found or is not indexed";
|
|
18
|
+
return `web ${kind} failed: WebIQ returned ${status}`;
|
|
19
|
+
}
|
|
20
|
+
async function post(url, key, body, fetchFn, timeoutMs) {
|
|
21
|
+
const ctrl = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
23
|
+
try {
|
|
24
|
+
return await fetchFn(url, { method: "POST", headers: headers(key), body: JSON.stringify(body), signal: ctrl.signal });
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function webSearch(key, params, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
31
|
+
if (!key)
|
|
32
|
+
return { ok: false, error: statusError(401, "search") };
|
|
33
|
+
try {
|
|
34
|
+
const res = await post(SEARCH_URL, key, { maxResults: 10, contentFormat: "passage", ...params }, fetchFn, timeoutMs);
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
return { ok: false, error: statusError(res.status, "search") };
|
|
37
|
+
const data = (await res.json());
|
|
38
|
+
return { ok: true, results: data.webResults ?? [] };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { ok: false, error: "web search failed: could not reach WebIQ" };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function webFetch(key, params, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
45
|
+
if (!key)
|
|
46
|
+
return { ok: false, error: statusError(401, "fetch") };
|
|
47
|
+
try {
|
|
48
|
+
const res = await post(BROWSE_URL, key, { maxLength: 10_000, contentFormat: "markdown", ...params }, fetchFn, timeoutMs);
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
return { ok: false, error: statusError(res.status, "fetch") };
|
|
51
|
+
const data = (await res.json());
|
|
52
|
+
return { ok: true, title: data.title ?? "", url: data.url ?? params.url, content: data.content ?? "" };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { ok: false, error: "web fetch failed: could not reach WebIQ" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Render results as the tool_result text fed back to the model — compact, citation-friendly.
|
|
59
|
+
export function formatSearchResults(results) {
|
|
60
|
+
if (!results.length)
|
|
61
|
+
return "no results found";
|
|
62
|
+
return results.map((r, i) => `[${i + 1}] ${r.title}\n${r.url}\n${r.content}`.trim()).join("\n\n");
|
|
63
|
+
}
|
|
64
|
+
export function formatFetchResult(r) {
|
|
65
|
+
return `${r.title}\n${r.url}\n\n${r.content}`.trim();
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
// WebIQ API key for the gateway-run web_search / web_fetch tools. Stored like the GitHub token
|
|
4
|
+
// (plaintext, 0600, in the data dir). The WEBIQ_API_KEY env var takes precedence so CI / headless
|
|
5
|
+
// runs can inject it without writing a file. Read lazily per request → no worker restart on change.
|
|
6
|
+
const file = (dir) => join(dir, "webiq.json");
|
|
7
|
+
export function writeWebIqKey(key, dir) {
|
|
8
|
+
if (!existsSync(dir))
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
writeFileSync(file(dir), JSON.stringify({ apiKey: key }), { mode: 0o600 });
|
|
11
|
+
}
|
|
12
|
+
export function readWebIqKey(dir) {
|
|
13
|
+
if (process.env.WEBIQ_API_KEY)
|
|
14
|
+
return process.env.WEBIQ_API_KEY;
|
|
15
|
+
if (!existsSync(file(dir)))
|
|
16
|
+
return null;
|
|
17
|
+
return JSON.parse(readFileSync(file(dir), "utf8")).apiKey ?? null;
|
|
18
|
+
}
|
|
19
|
+
export function clearWebIqKey(dir) {
|
|
20
|
+
rmSync(file(dir), { force: true });
|
|
21
|
+
}
|
package/dist/tui/app.js
CHANGED
|
@@ -6,12 +6,30 @@ import { Repl } from "./repl.js";
|
|
|
6
6
|
import { SetupWizard } from "./setup/wizard.js";
|
|
7
7
|
import { ModelScreen } from "./screens/model.js";
|
|
8
8
|
import { ConfigScreen } from "./screens/config.js";
|
|
9
|
+
import { WebIqKeyScreen } from "./screens/webiq-key.js";
|
|
10
|
+
import { summarizeStatus } from "./status-summary.js";
|
|
9
11
|
import { theme } from "./theme.js";
|
|
10
12
|
const stateColor = {
|
|
11
13
|
ready: theme.ready, starting: theme.starting, crashed: theme.crashed, unhealthy: theme.unhealthy,
|
|
12
14
|
};
|
|
13
15
|
const EMPTY_STATUS = { claude: { user: false, project: false }, codex: { user: false, project: false } };
|
|
14
16
|
const SPINNER = ["✶", "✸", "✹", "✺", "✹", "✷"];
|
|
17
|
+
// Startup overview card. GitHub shows a login STATE (no real token expiry exists), web search shows
|
|
18
|
+
// whether a WebIQ key is configured with the command to fix it when not. `extra` appends detail
|
|
19
|
+
// lines (e.g. worker restart history for /status).
|
|
20
|
+
function statusCard(s, extra = []) {
|
|
21
|
+
const gh = s.github === "connected" ? "✓ connected" : s.github === "expired" ? "✗ expired — run /login" : "✗ signed out — run /login";
|
|
22
|
+
const web = s.webSearch === "ready" ? "✓ ready" : "✗ not configured — run /web-search-support";
|
|
23
|
+
const clients = `claude ${s.clients.claude ? "✓" : "○"} codex ${s.clients.codex ? "✓" : "○"}`;
|
|
24
|
+
const tone = s.github === "connected" ? "ok" : "error";
|
|
25
|
+
return { type: "card", title: "status", tone, lines: [
|
|
26
|
+
`GitHub login ${gh}`,
|
|
27
|
+
`web search ${web}`,
|
|
28
|
+
`worker ${s.worker}`,
|
|
29
|
+
`clients ${clients}`,
|
|
30
|
+
...extra,
|
|
31
|
+
] };
|
|
32
|
+
}
|
|
15
33
|
const fmtElapsed = (ms) => {
|
|
16
34
|
const s = Math.floor(ms / 1000);
|
|
17
35
|
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
|
|
@@ -36,20 +54,24 @@ function ClientBadge({ name, status }) {
|
|
|
36
54
|
const cell = (label, on) => (_jsxs(Text, { color: on ? theme.ready : theme.muted, children: [label, ":", on ? "✓" : "○"] }));
|
|
37
55
|
return (_jsxs(Text, { color: theme.muted, children: [name, " ", cell("u", status.user), " ", cell("p", status.project)] }));
|
|
38
56
|
}
|
|
39
|
-
export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, }) {
|
|
57
|
+
export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, login, saveWebIqKey, webSearchReady, startupStatus, githubStatus, }) {
|
|
40
58
|
const cmds = registry.list().map((c) => ({ name: c.name, describe: c.describe }));
|
|
41
|
-
const [entries, setEntries] = useState([
|
|
59
|
+
const [entries, setEntries] = useState(() => [
|
|
60
|
+
...(startupStatus ? [statusCard(startupStatus)] : []),
|
|
42
61
|
{ type: "system", text: "Type a message to chat with the assistant, or /help for commands." },
|
|
43
62
|
]);
|
|
44
63
|
const [state, setState] = useState(workerState);
|
|
45
64
|
const [status, setStatus] = useState(() => readStatus?.() ?? EMPTY_STATUS);
|
|
65
|
+
const [webReady, setWebReady] = useState(() => webSearchReady?.() ?? false);
|
|
46
66
|
const [model, setModel] = useState(initialModel);
|
|
47
67
|
const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
|
|
48
68
|
const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
|
|
49
69
|
const abortRef = useRef(null); // current turn's interrupt handle
|
|
70
|
+
const loginInFlight = useRef(false); // guards against starting a second device-login flow
|
|
50
71
|
const add = (e) => setEntries((p) => [...p, e].slice(-100));
|
|
51
72
|
const refreshStatus = () => { if (readStatus)
|
|
52
|
-
setStatus(readStatus());
|
|
73
|
+
setStatus(readStatus()); if (webSearchReady)
|
|
74
|
+
setWebReady(webSearchReady()); };
|
|
53
75
|
// esc interrupts an in-flight assistant turn (the Repl doesn't use esc, so this is unambiguous).
|
|
54
76
|
useInput((_input, key) => { if (key.escape)
|
|
55
77
|
abortRef.current?.abort(); });
|
|
@@ -91,10 +113,50 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
91
113
|
setScreen({ kind: "model" });
|
|
92
114
|
return;
|
|
93
115
|
}
|
|
116
|
+
if (t === "/web-search-support" && saveWebIqKey) {
|
|
117
|
+
setScreen({ kind: "webiq-key" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (t === "/status" && (startupStatus || githubStatus || webSearchReady)) {
|
|
121
|
+
// Render the live status overview (same card as startup), then the worker restart history.
|
|
122
|
+
const github = githubStatus ? await githubStatus() : (startupStatus?.github ?? "signed-out");
|
|
123
|
+
let worker = state, restarts = [];
|
|
124
|
+
try {
|
|
125
|
+
const s = await statusSource?.();
|
|
126
|
+
if (s) {
|
|
127
|
+
worker = s.workerState;
|
|
128
|
+
restarts = s.restarts.slice(0, 5).map((r) => ` ${r.reason} exit=${r.exitCode ?? "-"} ${r.stderrTail.slice(0, 60)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* daemon momentarily down — show what we have */ }
|
|
132
|
+
const summary = summarizeStatus({
|
|
133
|
+
hasToken: github !== "signed-out", tokenValid: github === "connected",
|
|
134
|
+
webSearchReady: webSearchReady?.() ?? webReady, worker,
|
|
135
|
+
clients: { claude: status.claude.user || status.claude.project, codex: status.codex.user || status.codex.project },
|
|
136
|
+
});
|
|
137
|
+
add(statusCard(summary, restarts.length ? ["", "recent restarts:", ...restarts] : []));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
94
140
|
if (t === "/config" && info) {
|
|
95
141
|
setScreen({ kind: "config" });
|
|
96
142
|
return;
|
|
97
143
|
}
|
|
144
|
+
if (t === "/login" && login) {
|
|
145
|
+
// Show the verification URL + code right away, then resolve a completion card once the user
|
|
146
|
+
// authorizes. Done as a special case (not a registry command) because the slash registry only
|
|
147
|
+
// renders a command's final return value — it can't surface the code mid-poll. Guarded so a
|
|
148
|
+
// double Enter doesn't start two device-code flows (polling a superseded code 401s).
|
|
149
|
+
if (loginInFlight.current) {
|
|
150
|
+
add({ type: "card", title: "/login", tone: "info", lines: ["already waiting for authorization…"] });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
loginInFlight.current = true;
|
|
154
|
+
void login((lines) => add({ type: "card", title: "/login", tone: "info", lines }))
|
|
155
|
+
.then((lines) => add({ type: "card", title: "/login", tone: "ok", lines }))
|
|
156
|
+
.catch((e) => add({ type: "card", title: "/login", tone: "error", lines: [`login failed: ${e instanceof Error ? e.message : String(e)}`] }))
|
|
157
|
+
.finally(() => { loginInFlight.current = false; });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
98
160
|
if (setup && loadModels && (t === "/setup-claude" || t === "/setup-codex")) {
|
|
99
161
|
setScreen({ kind: "setup", client: t === "/setup-claude" ? "claude" : "codex" });
|
|
100
162
|
return;
|
|
@@ -164,6 +226,9 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
164
226
|
setScreen(null);
|
|
165
227
|
} }));
|
|
166
228
|
}
|
|
229
|
+
else if (screen?.kind === "webiq-key" && saveWebIqKey) {
|
|
230
|
+
body = (_jsx(WebIqKeyScreen, { onSubmit: (k) => { saveWebIqKey(k); setWebReady(true); setScreen(null); add({ type: "card", title: "/web-search-support", tone: "ok", lines: ["✓ WebIQ key saved — web search is now enabled for connected clients"] }); }, onCancel: () => { setScreen(null); add({ type: "system", text: "web-search-support cancelled" }); } }));
|
|
231
|
+
}
|
|
167
232
|
else {
|
|
168
233
|
body = _jsx(Repl, { onSubmit: handle, commands: cmds });
|
|
169
234
|
}
|
|
@@ -180,5 +245,5 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
180
245
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.accent, children: ["\u273D ", _jsxs(Text, { color: theme.muted, children: [frame, " ", loadingVerb(elapsed), "\u2026 (esc to interrupt \u00B7 ", fmtElapsed(elapsed), " \u00B7 \u2193 ", fmtTokens(tokens), " tokens \u00B7 thinking)"] })] }), e.text ? _jsx(Text, { color: color, children: e.text }) : null] }, i));
|
|
181
246
|
}
|
|
182
247
|
return _jsx(Text, { color: color, children: e.text }, i);
|
|
183
|
-
}) }), body, _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: theme.muted, children: "model " }), _jsx(Text, { color: theme.accent, children: model }), _jsx(Text, { color: theme.muted, children: " \u00B7 daemon " }), _jsx(Text, { color: stateColor[state], children: state }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] }));
|
|
248
|
+
}) }), body, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "model " }), _jsx(Text, { color: theme.accent, children: model }), _jsx(Text, { color: theme.muted, children: " \u00B7 daemon " }), _jsx(Text, { color: stateColor[state], children: state }), _jsx(Text, { color: theme.muted, children: " \u00B7 web " }), _jsx(Text, { color: webReady ? theme.ready : theme.muted, children: webReady ? "✓" : "✗ /web-search-support" })] }), _jsxs(Box, { children: [_jsx(ClientBadge, { name: "claude", status: status.claude }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(ClientBadge, { name: "codex", status: status.codex }), _jsx(Text, { color: theme.muted, children: " \u00B7 /help" })] })] })] }));
|
|
184
249
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
const DEFAULT_TURN_TIMEOUT_MS = 120_000; // 2 minutes — a turn that hasn't replied by then is given up on
|
|
2
|
-
export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS) {
|
|
2
|
+
export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS, precheck) {
|
|
3
3
|
return async (text, print, model, abort) => {
|
|
4
|
+
// Gate the turn on auth before firing a doomed request. Without this, a signed-out user's message
|
|
5
|
+
// hangs until the 120s timeout instead of getting an immediate, actionable hint.
|
|
6
|
+
if (precheck) {
|
|
7
|
+
const blocked = await precheck().catch(() => null); // a failed check must never wedge chat
|
|
8
|
+
if (blocked) {
|
|
9
|
+
print(blocked);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
4
13
|
const ctrl = abort ?? new AbortController();
|
|
5
14
|
let timedOut = false;
|
|
6
15
|
// Race the turn against a hard timeout so a hung SDK/upstream can never block the UI forever.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { theme } from "../theme.js";
|
|
5
|
+
// Masked single-line input for the WebIQ API key. Mirrors the Repl's end-of-line editing (append /
|
|
6
|
+
// backspace), but renders bullets instead of the secret. Enter submits a non-empty key; Esc cancels.
|
|
7
|
+
export function WebIqKeyScreen({ onSubmit, onCancel }) {
|
|
8
|
+
const [value, setValue] = useState("");
|
|
9
|
+
useInput((input, key) => {
|
|
10
|
+
if (key.escape) {
|
|
11
|
+
onCancel();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (key.return) {
|
|
15
|
+
const k = value.trim();
|
|
16
|
+
if (k)
|
|
17
|
+
onSubmit(k);
|
|
18
|
+
else
|
|
19
|
+
onCancel();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (key.backspace || key.delete) {
|
|
23
|
+
setValue((v) => v.slice(0, -1));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (input && !key.ctrl && !key.meta)
|
|
27
|
+
setValue((v) => v + input);
|
|
28
|
+
});
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "web search support \u2014 paste your WebIQ API key" }), _jsx(Text, { color: theme.muted, children: "enables web_search / web_fetch for connected clients \u00B7 enter to save \u00B7 esc to cancel" }), _jsxs(Box, { children: [_jsx(Text, { color: theme.prompt, children: "key › " }), _jsx(Text, { children: "•".repeat(value.length) }), _jsx(Text, { inverse: true, children: " " })] })] }));
|
|
30
|
+
}
|
|
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
// Codex reads ~/.codex/config.toml. copilot-reverse writes a managed provider block there (model,
|
|
5
5
|
// provider, context window) while preserving the user's other top-level keys. Mirrors
|
|
6
|
-
// agent-maestro's `configureCodex
|
|
6
|
+
// agent-maestro's `configureCodex`. Codex removed wire_api="chat" (codex#7782), so we write
|
|
7
|
+
// "responses" and serve the OpenAI Responses API at /openai/responses (Codex appends /responses to
|
|
8
|
+
// base_url verbatim — no /v1 auto-added).
|
|
7
9
|
export const PROVIDER_ID = "copilot-reverse";
|
|
8
10
|
export function codexTomlPath(home = homedir()) {
|
|
9
11
|
return join(home, ".codex", "config.toml");
|
|
@@ -14,34 +16,57 @@ export function applyCodexToml(opts) {
|
|
|
14
16
|
const path = codexTomlPath(opts.home);
|
|
15
17
|
if (!existsSync(dirname(path)))
|
|
16
18
|
mkdirSync(dirname(path), { recursive: true });
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
+
// Parse existing content into top-level (pre-table) bare keys vs. table blocks, dropping our
|
|
20
|
+
// managed keys and any prior managed provider table. We MUST keep top-level keys and tables
|
|
21
|
+
// separate: in TOML a bare `key = value` after a `[table]` header belongs to that table, so
|
|
22
|
+
// appending our `model_provider` at the end (after the user's [windows]/[marketplaces] tables)
|
|
23
|
+
// silently nested it under the last table — Codex then couldn't see it and fell back to "openai".
|
|
19
24
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
20
|
-
const
|
|
21
|
-
|
|
25
|
+
const keptTopKeys = []; // bare key=value lines before any table
|
|
26
|
+
const keptTables = []; // everything from the first [table] onward (preserved verbatim)
|
|
27
|
+
let inTable = false; // have we passed the first table header?
|
|
28
|
+
let inOurTable = false; // are we inside our own [model_providers.copilot-reverse] block?
|
|
22
29
|
for (const line of existing.split(/\r?\n/)) {
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
if (/^\s*\[/.test(line)) {
|
|
31
|
+
inTable = true;
|
|
25
32
|
inOurTable = line.trim() === `[model_providers.${PROVIDER_ID}]`;
|
|
33
|
+
}
|
|
26
34
|
if (inOurTable)
|
|
27
|
-
continue; // skip our previously-written provider table
|
|
35
|
+
continue; // skip our previously-written provider table entirely
|
|
28
36
|
const keyMatch = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
37
|
+
// Drop our managed top keys wherever they appear. They belong at the top level, but a previous
|
|
38
|
+
// buggy version wrote them AFTER tables (where TOML nests them) — so filter them in the table
|
|
39
|
+
// region too, otherwise the rewrite would duplicate them.
|
|
29
40
|
if (keyMatch && MANAGED_TOP_KEYS.includes(keyMatch[1]))
|
|
30
|
-
continue;
|
|
31
|
-
|
|
41
|
+
continue;
|
|
42
|
+
(inTable ? keptTables : keptTopKeys).push(line);
|
|
32
43
|
}
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
// Reassemble in valid TOML order: ALL top-level keys (ours + the user's) first, then all table
|
|
45
|
+
// blocks (the user's preserved tables, then our managed provider table last).
|
|
46
|
+
const topKeys = [
|
|
35
47
|
`model = "${opts.model}"`,
|
|
36
48
|
`model_provider = "${PROVIDER_ID}"`,
|
|
37
49
|
...(opts.contextWindow ? [`model_context_window = ${opts.contextWindow}`] : []),
|
|
38
|
-
|
|
50
|
+
...keptTopKeys.filter((l) => l.trim()), // the user's other top-level keys (approval_policy, etc.)
|
|
51
|
+
];
|
|
52
|
+
const ourTable = [
|
|
39
53
|
`[model_providers.${PROVIDER_ID}]`,
|
|
40
54
|
`name = "copilot-reverse"`,
|
|
41
55
|
`base_url = "${opts.baseUrl}"`,
|
|
42
|
-
`wire_api = "
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
`wire_api = "responses"`,
|
|
57
|
+
// Auth: inline a static bearer token so Codex talks to our local proxy instead of falling back
|
|
58
|
+
// to the OpenAI login flow. env_key is unreliable here (a standalone Codex CLI won't see our
|
|
59
|
+
// .env), so we embed the placeholder directly — the worker ignores the key value anyway.
|
|
60
|
+
`requires_openai_auth = false`,
|
|
61
|
+
`experimental_bearer_token = "${opts.apiKey ?? "copilot-reverse-local"}"`,
|
|
62
|
+
];
|
|
63
|
+
const userTables = keptTables.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
64
|
+
const managed = [
|
|
65
|
+
topKeys.join("\n"),
|
|
66
|
+
...(userTables ? [userTables] : []),
|
|
67
|
+
ourTable.join("\n"),
|
|
68
|
+
].join("\n\n");
|
|
69
|
+
const body = managed + "\n";
|
|
45
70
|
writeFileSync(path, body);
|
|
46
71
|
return { path, changed: MANAGED_TOP_KEYS };
|
|
47
72
|
}
|
|
@@ -45,6 +45,7 @@ export function buildRegistry(ctx, endpoint, opts = {}) {
|
|
|
45
45
|
reg.add({ name: "/login", describe: "sign in to GitHub (device-code)", run: async () => opts.login ? opts.login() : ["login not available"] });
|
|
46
46
|
reg.add({ name: "/logout", describe: "sign out — remove the stored GitHub token", run: async () => opts.logout ? opts.logout() : ["logout not available"] });
|
|
47
47
|
reg.add({ name: "/model", describe: "switch the chat model", run: async () => ["opening model picker…"] });
|
|
48
|
+
reg.add({ name: "/web-search-support", describe: "enable web search/fetch (set WebIQ API key)", run: async () => ["opening web-search-support…"] });
|
|
48
49
|
reg.add({ name: "/config", describe: "view & change configuration", run: async () => ["opening config panel…"] });
|
|
49
50
|
reg.add({ name: "/dashboard", describe: "open the web dashboard in your browser", run: async () => {
|
|
50
51
|
if (!opts.dashboardUrl)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function githubLoginState(hasToken, tokenValid) {
|
|
2
|
+
if (!hasToken)
|
|
3
|
+
return "signed-out";
|
|
4
|
+
return tokenValid ? "connected" : "expired";
|
|
5
|
+
}
|
|
6
|
+
export function summarizeStatus(i) {
|
|
7
|
+
return {
|
|
8
|
+
github: githubLoginState(i.hasToken, i.tokenValid),
|
|
9
|
+
webSearch: i.webSearchReady ? "ready" : "not-configured",
|
|
10
|
+
worker: i.worker,
|
|
11
|
+
clients: i.clients,
|
|
12
|
+
};
|
|
13
|
+
}
|
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.
|
|
2
|
+
export const APP_VERSION = "0.3.0";
|
|
@@ -3,8 +3,18 @@ import { anthropicRequestToCanonical, canonicalToAnthropicResponse } from "../co
|
|
|
3
3
|
import { estimateTokens } from "../core/tokens.js";
|
|
4
4
|
import { errorHint } from "./errors.js";
|
|
5
5
|
import { CopilotAuthError } from "../providers/copilot/token.js";
|
|
6
|
+
import { isGatewayTool } from "../core/server-tools.js";
|
|
6
7
|
const frame = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
7
|
-
|
|
8
|
+
const safeJson = (s) => { try {
|
|
9
|
+
return JSON.parse(s);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
} };
|
|
14
|
+
// Bounds the gateway tool loop so a model that calls web_search every turn (or a runner that always
|
|
15
|
+
// returns "search more") can never spin forever inside one request.
|
|
16
|
+
const MAX_TOOL_ITERS = 5;
|
|
17
|
+
export function mountAnthropic(app, router, onMetric, runner) {
|
|
8
18
|
// Model discovery — Anthropic list shape. Claude Desktop / Anthropic-protocol clients GET this
|
|
9
19
|
// before chatting; without it they 404 on the connection test.
|
|
10
20
|
app.get("/anthropic/v1/models", (_req, res) => {
|
|
@@ -33,61 +43,112 @@ export function mountAnthropic(app, router, onMetric) {
|
|
|
33
43
|
// isn't stuck at 0%; the terminal message_delta then reports the exact count.
|
|
34
44
|
const estInput = estimateTokens(canon);
|
|
35
45
|
res.write(frame("message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model: canon.model, content: [], stop_reason: null, usage: { input_tokens: estInput, output_tokens: 0, cache_read_input_tokens: 0 } } }));
|
|
36
|
-
// D3 (interface-freeze §5.4) + mixed text+tool fix (architect, 2026-06-17)
|
|
37
|
-
// open/stop bookkeeping with DYNAMIC SEQUENTIAL allocation
|
|
38
|
-
// and
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
46
|
+
// D3 (interface-freeze §5.4) + mixed text+tool fix (architect, 2026-06-17) + gateway tool loop
|
|
47
|
+
// (2026-06): the endpoint owns block open/stop bookkeeping with DYNAMIC SEQUENTIAL allocation,
|
|
48
|
+
// and `next` spans ALL loop iterations so block indices stay contiguous-from-0 across turns.
|
|
49
|
+
// Within a turn, text streams live (transparent progress) but tool calls are BUFFERED: only
|
|
50
|
+
// after the turn ends do we know whether they're gateway tools (run here, then loop) or client
|
|
51
|
+
// tools (forwarded to the client, exactly as before). Whichever block opens first claims index 0.
|
|
42
52
|
let next = 0;
|
|
43
|
-
let
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
let lastPrompt = estInput, lastCached = 0, sumCompletion = 0;
|
|
54
|
+
let finalStop = "stop";
|
|
55
|
+
for (let iter = 0; iter < MAX_TOOL_ITERS; iter++) {
|
|
56
|
+
let textIndex; // Anthropic index of this turn's text block
|
|
57
|
+
const byCopilotIdx = new Map();
|
|
58
|
+
const buffered = []; // tool calls seen this turn, in order
|
|
59
|
+
let turnStop = "stop";
|
|
60
|
+
for await (const chunk of provider.stream(canon)) {
|
|
61
|
+
if (chunk.done) {
|
|
62
|
+
turnStop = chunk.finishReason ?? "stop";
|
|
63
|
+
if (chunk.usage) {
|
|
64
|
+
lastPrompt = chunk.usage.promptTokens ?? lastPrompt;
|
|
65
|
+
lastCached = chunk.usage.cachedTokens ?? 0;
|
|
66
|
+
sumCompletion += chunk.usage.completionTokens ?? 0;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
59
69
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
if (chunk.kind === "text") {
|
|
71
|
+
if (textIndex === undefined) {
|
|
72
|
+
textIndex = next++;
|
|
73
|
+
res.write(frame("content_block_start", { type: "content_block_start", index: textIndex, content_block: { type: "text", text: "" } }));
|
|
74
|
+
}
|
|
75
|
+
res.write(frame("content_block_delta", { type: "content_block_delta", index: textIndex, delta: { type: "text_delta", text: chunk.delta } }));
|
|
76
|
+
}
|
|
77
|
+
else if (chunk.kind === "tool_use_start") {
|
|
78
|
+
if (!byCopilotIdx.has(chunk.index)) {
|
|
79
|
+
const t = { id: chunk.id, name: chunk.name, args: "" };
|
|
80
|
+
byCopilotIdx.set(chunk.index, t);
|
|
81
|
+
buffered.push(t);
|
|
82
|
+
}
|
|
68
83
|
}
|
|
84
|
+
else if (chunk.kind === "tool_use_delta") {
|
|
85
|
+
const t = byCopilotIdx.get(chunk.index);
|
|
86
|
+
if (t)
|
|
87
|
+
t.args += chunk.argsDelta;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (textIndex !== undefined)
|
|
91
|
+
res.write(frame("content_block_stop", { type: "content_block_stop", index: textIndex }));
|
|
92
|
+
const gatewayCalls = buffered.filter((t) => isGatewayTool(t.name));
|
|
93
|
+
// Invariant: a gateway tool (web_search/web_fetch) must NEVER reach the client — the client
|
|
94
|
+
// has no handler for it and would stall. So whenever the model calls gateway tools (and a
|
|
95
|
+
// runner is wired), run them here and loop, feeding results back. Any client tools called in
|
|
96
|
+
// the SAME turn are deliberately NOT forwarded yet: we drop them this turn and let the model
|
|
97
|
+
// re-issue them on the next turn, now informed by the search result. (Forwarding them now
|
|
98
|
+
// would end the turn as tool_use and strand the gateway result with nowhere to go.)
|
|
99
|
+
if (runner && gatewayCalls.length) {
|
|
100
|
+
canon.messages.push({ role: "assistant", content: gatewayCalls.map((t) => ({ type: "tool_use", id: t.id, name: t.name, input: safeJson(t.args) })) });
|
|
101
|
+
const results = [];
|
|
102
|
+
for (const t of gatewayCalls)
|
|
103
|
+
results.push({ type: "tool_result", toolUseId: t.id, content: await runner(t.name, safeJson(t.args)) });
|
|
104
|
+
canon.messages.push({ role: "tool", content: results });
|
|
105
|
+
continue;
|
|
69
106
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
107
|
+
// Terminal turn (no gateway tools, or no runner): forward any buffered tool calls to the
|
|
108
|
+
// client (open/delta/close each at its own freshly-allocated index), then finish.
|
|
109
|
+
for (const t of buffered) {
|
|
110
|
+
const index = next++;
|
|
111
|
+
res.write(frame("content_block_start", { type: "content_block_start", index, content_block: { type: "tool_use", id: t.id, name: t.name, input: {} } }));
|
|
112
|
+
if (t.args)
|
|
113
|
+
res.write(frame("content_block_delta", { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: t.args } }));
|
|
114
|
+
res.write(frame("content_block_stop", { type: "content_block_stop", index }));
|
|
74
115
|
}
|
|
116
|
+
finalStop = buffered.length ? "tool_use" : turnStop;
|
|
117
|
+
break;
|
|
75
118
|
}
|
|
76
|
-
// Close every opened block (ascending Anthropic index) before the terminal frames.
|
|
77
|
-
for (const index of [...openedOrder].sort((a, b) => a - b))
|
|
78
|
-
res.write(frame("content_block_stop", { type: "content_block_stop", index }));
|
|
79
119
|
// Report real usage (agent-maestro shape): split cached tokens out of input so Claude Code's
|
|
80
|
-
// context bar is accurate.
|
|
81
|
-
|
|
82
|
-
const inputTokens = Math.max(0,
|
|
83
|
-
const deltaUsage = { input_tokens: inputTokens, output_tokens:
|
|
84
|
-
res.write(frame("message_delta", { type: "message_delta", delta: { stop_reason:
|
|
120
|
+
// context bar is accurate. promptTokens is the last turn's (largest, includes tool results);
|
|
121
|
+
// output is summed across turns.
|
|
122
|
+
const inputTokens = Math.max(0, lastPrompt - lastCached);
|
|
123
|
+
const deltaUsage = { input_tokens: inputTokens, output_tokens: sumCompletion, cache_read_input_tokens: lastCached };
|
|
124
|
+
res.write(frame("message_delta", { type: "message_delta", delta: { stop_reason: finalStop === "tool_use" ? "tool_use" : finalStop === "length" ? "max_tokens" : "end_turn" }, usage: deltaUsage }));
|
|
85
125
|
res.write(frame("message_stop", { type: "message_stop" }));
|
|
86
126
|
res.end();
|
|
87
127
|
metric(200);
|
|
88
128
|
}
|
|
89
129
|
else {
|
|
90
|
-
|
|
130
|
+
// Non-stream: same gateway loop without SSE — run gateway tools and re-complete until the
|
|
131
|
+
// model answers with text (or a client tool), capped identically.
|
|
132
|
+
let resp = await provider.complete(canon);
|
|
133
|
+
for (let iter = 0; runner && iter < MAX_TOOL_ITERS; iter++) {
|
|
134
|
+
const toolUses = resp.content.filter((b) => b.type === "tool_use");
|
|
135
|
+
const gatewayUses = toolUses.filter((b) => isGatewayTool(b.name));
|
|
136
|
+
if (!gatewayUses.length)
|
|
137
|
+
break; // no gateway work left — client tools / text are terminal
|
|
138
|
+
// Run the gateway tools, feed results back, and continue. Any client tools in the SAME turn
|
|
139
|
+
// ride along in the assistant message and remain in the final resp for the client to handle.
|
|
140
|
+
canon.messages.push({ role: "assistant", content: resp.content });
|
|
141
|
+
const results = [];
|
|
142
|
+
for (const u of gatewayUses)
|
|
143
|
+
results.push({ type: "tool_result", toolUseId: u.id, content: await runner(u.name, u.input) });
|
|
144
|
+
canon.messages.push({ role: "tool", content: results });
|
|
145
|
+
resp = await provider.complete(canon);
|
|
146
|
+
}
|
|
147
|
+
// Invariant: never forward a gateway tool_use to the client (it can't handle it). If the cap
|
|
148
|
+
// was hit with gateway calls still pending, strip them — better a partial answer than a stall.
|
|
149
|
+
if (runner)
|
|
150
|
+
resp = { ...resp, content: resp.content.filter((b) => b.type !== "tool_use" || !isGatewayTool(b.name)) };
|
|
151
|
+
res.json(canonicalToAnthropicResponse(resp));
|
|
91
152
|
metric(200);
|
|
92
153
|
}
|
|
93
154
|
}
|
package/dist/worker/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { CopilotAdapter } from "../providers/copilot/adapter.js";
|
|
|
4
4
|
import { CopilotTokenStore } from "../providers/copilot/token.js";
|
|
5
5
|
import { fetchCopilotModels } from "../providers/copilot/models.js";
|
|
6
6
|
import { readGhToken } from "../shared/creds.js";
|
|
7
|
+
import { readWebIqKey } from "../shared/webiq-key.js";
|
|
8
|
+
import { makeGatewayRunner } from "../core/server-tools.js";
|
|
7
9
|
import { dataDir } from "../shared/paths.js";
|
|
8
10
|
import { defaultConfig } from "../shared/config.js";
|
|
9
11
|
function send(msg) { if (process.send)
|
|
@@ -20,7 +22,10 @@ const tokenStore = new CopilotTokenStore(gh);
|
|
|
20
22
|
const router = new Router([new CopilotAdapter(tokenStore)], cfg.modelMap);
|
|
21
23
|
// Load the live model list so the router can fuzzy-match near-miss ids (e.g. dated Anthropic ids).
|
|
22
24
|
void tokenStore.get().then((t) => fetchCopilotModels(t)).then((ids) => router.setAvailableModels(ids)).catch(() => { });
|
|
23
|
-
|
|
25
|
+
// Gateway-run web_search / web_fetch: reads the WebIQ key lazily per call (env or data dir), so
|
|
26
|
+
// setting it via /web-search-support takes effect without restarting the worker.
|
|
27
|
+
const gatewayRunner = makeGatewayRunner(() => readWebIqKey(dataDir()));
|
|
28
|
+
const app = createWorkerApp(router, (m) => send({ type: "request-metric", ...m }), gatewayRunner);
|
|
24
29
|
const server = app.listen(port, host, () => send({ type: "ready", port }));
|
|
25
30
|
const hb = setInterval(() => send({ type: "heartbeat", ts: Date.now() }), 5_000);
|
|
26
31
|
process.on("message", (m) => { if (m?.type === "shutdown") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { openaiRequestToCanonical, canonicalToOpenAIResponse, canonicalChunkToOpenAISSE } from "../core/openai-inbound.js";
|
|
3
|
+
import { responsesRequestToCanonical, canonicalToResponsesResponse, ResponsesSSE } from "../core/responses-inbound.js";
|
|
3
4
|
import { errorHint } from "./errors.js";
|
|
4
5
|
import { CopilotAuthError } from "../providers/copilot/token.js";
|
|
5
6
|
export function mountOpenAI(app, router, onMetric) {
|
|
@@ -46,4 +47,65 @@ export function mountOpenAI(app, router, onMetric) {
|
|
|
46
47
|
metric(status, message);
|
|
47
48
|
}
|
|
48
49
|
});
|
|
50
|
+
// OpenAI Responses API — Codex speaks ONLY this after codex#7782 removed wire_api="chat". Codex
|
|
51
|
+
// POSTs {base_url}/responses, so with base_url …/openai the route is /openai/responses. Same
|
|
52
|
+
// canonical pipeline as chat/completions; the Responses translator handles the item-centric shape.
|
|
53
|
+
app.post("/openai/responses", async (req, res) => {
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
const canon = responsesRequestToCanonical(req.body);
|
|
56
|
+
canon.model = router.resolveModel(canon.model);
|
|
57
|
+
const provider = router.pick(canon.model);
|
|
58
|
+
const metric = (status, error) => onMetric({ endpoint: "/openai/responses", model: canon.model, status, latencyMs: Date.now() - start, error });
|
|
59
|
+
try {
|
|
60
|
+
if (canon.stream) {
|
|
61
|
+
res.setHeader("content-type", "text/event-stream");
|
|
62
|
+
res.setHeader("cache-control", "no-cache");
|
|
63
|
+
const sse = new ResponsesSSE(`resp_${randomUUID().replace(/-/g, "")}`, canon.model);
|
|
64
|
+
res.write(sse.start());
|
|
65
|
+
const argsByIdx = new Map();
|
|
66
|
+
let usage;
|
|
67
|
+
let finish = "stop";
|
|
68
|
+
for await (const chunk of provider.stream(canon)) {
|
|
69
|
+
if (chunk.done) {
|
|
70
|
+
finish = chunk.finishReason ?? "stop";
|
|
71
|
+
usage = chunk.usage;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (chunk.kind === "text")
|
|
75
|
+
for (const f of sse.text(chunk.delta))
|
|
76
|
+
res.write(f);
|
|
77
|
+
else if (chunk.kind === "tool_use_start")
|
|
78
|
+
for (const f of sse.toolStart(chunk.index, chunk.id, chunk.name))
|
|
79
|
+
res.write(f);
|
|
80
|
+
else if (chunk.kind === "tool_use_delta") {
|
|
81
|
+
argsByIdx.set(chunk.index, (argsByIdx.get(chunk.index) ?? "") + chunk.argsDelta);
|
|
82
|
+
for (const f of sse.toolArgs(chunk.index, chunk.argsDelta))
|
|
83
|
+
res.write(f);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const f of sse.finish(usage, finish, argsByIdx))
|
|
87
|
+
res.write(f);
|
|
88
|
+
res.end();
|
|
89
|
+
metric(200);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
res.json(canonicalToResponsesResponse(await provider.complete(canon)));
|
|
93
|
+
metric(200);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
98
|
+
const hint = errorHint(raw);
|
|
99
|
+
const message = hint ? `${raw}\n${hint}` : raw;
|
|
100
|
+
const status = err instanceof CopilotAuthError ? 401 : 502;
|
|
101
|
+
if (!res.headersSent) {
|
|
102
|
+
res.status(status).json({ error: { type: "error", message } });
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
|
|
106
|
+
res.end();
|
|
107
|
+
}
|
|
108
|
+
metric(status, message);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
49
111
|
}
|
package/dist/worker/server.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { mountOpenAI } from "./openai-server.js";
|
|
3
3
|
import { mountAnthropic } from "./anthropic-server.js";
|
|
4
|
-
export function createWorkerApp(router, onMetric) {
|
|
4
|
+
export function createWorkerApp(router, onMetric, gatewayRunner) {
|
|
5
5
|
const app = express();
|
|
6
6
|
app.use(express.json({ limit: "20mb" }));
|
|
7
7
|
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
|
8
8
|
mountOpenAI(app, router, onMetric);
|
|
9
|
-
mountAnthropic(app, router, onMetric);
|
|
9
|
+
mountAnthropic(app, router, onMetric, gatewayRunner);
|
|
10
10
|
return app;
|
|
11
11
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-reverse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|