Skip to content

Commit 1690b0e

Browse files
Custom engine: connect an OpenAI-compatible Responses endpoint (#238)
* Custom engine (issue #209): OpenAI-compatible endpoints through the Codex provider passthrough Ported onto main after #218 and the engineBin refactor: the registry's binary lookup goes through engineBin.ts (custom resolves the codex binary), customEngineStatus reuses detectCli as its binary probe, and the panel's custom tab (hidden until the host announces a saved endpoint config), engine mark and slash guards live in the panel modules with jsdom coverage; the bundle is regenerated. * fix: validate custom engine settings Reject whitespace-only model ids at save time, keep invalid-URL errors from echoing the pasted input (it can contain a key), and correct the keyless-auth comments. The approval policy change that shipped alongside this in the spike now lives in #221 and is not part of this commit. * Custom engine: one status derivation for every host and surface customEngineStatus answers in CliReport's own vocabulary (missing, no-login, ok) so the CLI, the VS Code host and the chat session stop re-deriving "codex present AND config saved" each on their own; the unread binaryOk/configured fields and the unused keyless-placeholder export are gone, and the VS Code host reuses the boot probe instead of running a second one. * custom engine: pin provider when starting or resuming threads * custom engine: trim unverified presets and unused panel handlers * core: handle Codex MCP consent and current context usage * custom engine: wrap the Settings connection description * custom engine: show context count without an unverified limit * Revert "webview: prevent focus from scrolling the tab pager" This reverts commit be31653. * fix: preserve custom readiness across runtime status updates * fix: keep custom context capacity unknown across interfaces * build: regenerate custom panel after upstream integration --------- Co-authored-by: RemilioNubilio <275382225+RemilioNubilio@users.noreply.github.com> Co-authored-by: remilio nubilio <nubsvault@gmail.com> Co-authored-by: NubsCarson <192162056+NubsCarson@users.noreply.github.com>
1 parent 3b33d5e commit 1690b0e

56 files changed

Lines changed: 1480 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Custom engine config, device-local. Mirrors saveCodexApiKey: the endpoint config
2+
// (base URL + API key + model) lives in tokens/custom-engine.json with 0o600 perms,
3+
// per device, never synced. spawn.ts turns it into -c model_providers overrides on
4+
// the codex binary; the key rides the CUSTOM_ENGINE_API_KEY env var, never argv.
5+
6+
import { readFile, writeFile, rm } from "node:fs/promises";
7+
import { tokenFile, tokensDir, ensureDir } from "../core/paths.js";
8+
import { engineBinary } from "../runtime/engineRegistry.js";
9+
import { detectCli, type CliReport, type CliStatus } from "../runtime/detect.js";
10+
11+
export interface CustomEngineConfig {
12+
baseUrl: string;
13+
apiKey: string;
14+
model: string;
15+
presetId: string;
16+
label?: string;
17+
}
18+
19+
export interface CustomEnginePreset {
20+
id: string;
21+
label: string;
22+
baseUrl: string;
23+
defaultModel: string;
24+
}
25+
26+
// OpenRouter has been exercised through the Responses API. Other compatible local
27+
// or hosted endpoints can be configured manually without implying provider support.
28+
export const CUSTOM_ENGINE_PRESETS: CustomEnginePreset[] = [
29+
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", defaultModel: "" },
30+
{ id: "manual", label: "Manual", baseUrl: "", defaultModel: "" },
31+
];
32+
33+
// Connect-UI warning copy lives in the browser-safe leaf (customEngineMeta) so the
34+
// SPA form can import it without this file's node:fs dependency; re-exported here so
35+
// node hosts keep one import site for everything custom-engine.
36+
export { CUSTOM_ENGINE_EGRESS_WARNING, CUSTOM_ENGINE_TOOL_WARNING } from "./customEngineMeta.js";
37+
38+
const PROVIDER = "custom-engine";
39+
40+
// The base URL rides codex argv as a -c override, where ps can read it, so anything
41+
// secret-shaped is stripped before it is stored: userinfo (user:pass@) and the query
42+
// string. The trailing slash is trimmed so the same endpoint always stores one form.
43+
function normalizeBaseUrl(raw: string): string {
44+
let url: URL;
45+
try {
46+
url = new URL(raw.trim());
47+
} catch {
48+
throw new Error("The custom engine base URL is not a valid URL.");
49+
}
50+
if (url.protocol !== "http:" && url.protocol !== "https:") {
51+
throw new Error(`The custom engine base URL must be http or https, got "${url.protocol.slice(0, -1)}".`);
52+
}
53+
// origin + pathname drops userinfo, query, and fragment in one move.
54+
return (url.origin + url.pathname).replace(/\/+$/, "");
55+
}
56+
57+
export async function saveCustomEngineConfig(cfg: CustomEngineConfig): Promise<void> {
58+
// Reject a blank model at the door, not only at spawn (customProviderFlags), so a
59+
// bad config never even reaches disk.
60+
if (!cfg.model.trim()) {
61+
throw new Error("The custom engine needs a model id; codex would otherwise silently use its own default model.");
62+
}
63+
const normalized: CustomEngineConfig = { ...cfg, baseUrl: normalizeBaseUrl(cfg.baseUrl), model: cfg.model.trim() };
64+
await ensureDir(tokensDir());
65+
const path = tokenFile(PROVIDER);
66+
// Unlink before write so the new file is always created with 0o600: if the file
67+
// already existed with looser permissions a plain writeFile would not downgrade them.
68+
await rm(path, { force: true });
69+
await writeFile(path, JSON.stringify(normalized), { mode: 0o600 });
70+
}
71+
72+
export async function loadCustomEngineConfig(): Promise<CustomEngineConfig | null> {
73+
try {
74+
const data = JSON.parse(await readFile(tokenFile(PROVIDER), "utf8")) as CustomEngineConfig;
75+
return data.baseUrl ? data : null;
76+
} catch {
77+
return null;
78+
}
79+
}
80+
81+
export async function clearCustomEngineConfig(): Promise<void> {
82+
await rm(tokenFile(PROVIDER), { force: true });
83+
}
84+
85+
export async function hasCustomEngine(): Promise<boolean> {
86+
return (await loadCustomEngineConfig()) !== null;
87+
}
88+
89+
// The custom engine's status in CliReport's own vocabulary, so a custom row reads like the
90+
// claude and codex rows everywhere: "missing" = no codex binary (custom runs through it),
91+
// "no-login" = the binary is there but no endpoint is saved (the saved config IS its
92+
// sign-in), "ok" = a custom session can actually spawn. One derivation for every host
93+
// and surface instead of each re-deriving "codex present AND config saved". Pass a
94+
// CliReport when one is already in hand; without one detectCli runs, the same probe
95+
// behind every engine status line (it resolves the binary through engineBin.ts, so a
96+
// GUI-launched host without a shell PATH still finds it).
97+
export async function customEngineStatus(report?: CliReport): Promise<CliStatus> {
98+
const status = (report ?? await detectCli())[engineBinary("custom")];
99+
if (status === "missing" || status === "node-missing") return status;
100+
return (await hasCustomEngine()) ? "ok" : "no-login";
101+
}
102+
103+
// Masked view for the UI: endpoint host + last 4 chars of the key, rest dotted (like
104+
// maskedHeliusKey). null when no config is stored; a keyless local endpoint shows
105+
// just the host. The full key never leaves the host as plain text.
106+
export async function maskedCustomEngine(): Promise<string | null> {
107+
const cfg = await loadCustomEngineConfig();
108+
if (!cfg) return null;
109+
let host = cfg.baseUrl;
110+
try {
111+
host = new URL(cfg.baseUrl).host;
112+
} catch {
113+
// not a parseable URL: show the raw value
114+
}
115+
if (!cfg.apiKey) return host;
116+
const tail = cfg.apiKey.slice(-4);
117+
return `${host} · ${cfg.apiKey.length <= 4 ? tail : "••••" + tail}`;
118+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Custom engine warning copy, split from customEngineAuth because that file needs
2+
// node:fs while the SPA connect form must render these strings verbatim from a
3+
// browser bundle. This leaf stays node-free (like engineRegistry) so every surface
4+
// reads the one definition instead of hardcoding drifting copies.
5+
6+
// Shown verbatim by any connect UI before a custom endpoint is saved.
7+
export const CUSTOM_ENGINE_EGRESS_WARNING =
8+
"Custom engines send your prompts, files, and tool output to the endpoint you configure. Only connect endpoints you trust with that data.";
9+
export const CUSTOM_ENGINE_TOOL_WARNING =
10+
"The endpoint and selected model must support the OpenAI Responses API and tool calling. Chat Completions-only endpoints need a compatible adapter.";

‎packages/core/src/account/sessionLog.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// This file is the single source of the storage format — change it here only.
77

88
import type { ChatMessage, CanonicalSession } from "../runtime/contract.js";
9+
import { coerceEngineKey, type EngineKey } from "../runtime/engineRegistry.js";
910
import { encryptForWallet, decryptForWallet, type SessionKey } from "../core/crypto.js";
1011

1112
// A log record: either session meta (first line) or one chat message.
@@ -45,7 +46,7 @@ export async function decodeLog(
4546
if (!text) return null;
4647

4748
let sessionId = "";
48-
let cli: "claude" | "codex" = "claude";
49+
let cli: EngineKey = "claude";
4950
let title = "";
5051
let ts = 0;
5152
let lastDevice: { id: string; label: string } | undefined = undefined;
@@ -59,7 +60,7 @@ export async function decodeLog(
5960
const rec = JSON.parse(new TextDecoder().decode(plain)) as LogRecord;
6061
if (rec.kind === "meta") {
6162
sessionId = rec.sessionId;
62-
cli = rec.cli as "claude" | "codex";
63+
cli = coerceEngineKey(rec.cli); // unknown cli strings read as codex-like
6364
title = rec.title;
6465
ts = rec.ts;
6566
lastDevice = rec.lastDevice;

‎packages/core/src/account/store.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const PERF = !!process.env.AGENTNET_PERF;
3131
function toSessionMeta(
3232
sessionId: string,
3333
title: string,
34-
cli: "claude" | "codex",
34+
cli: SessionMeta["cli"],
3535
ts: number,
3636
lastDevice?: SessionMeta["lastDevice"],
3737
model?: string,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { expect, it } from "vitest";
2+
import { contextNotice } from "./contextNotice.js";
3+
4+
it("retains the standard engine breakdown and supplied window", () => {
5+
expect(contextNotice("claude", 10000, 100000)).toContain("10,000 / 100,000 (10%)");
6+
expect(contextNotice("codex")).toContain("256,000");
7+
});
8+
it("Custom never presents a Codex fallback as provider capacity", () => {
9+
expect(contextNotice("custom", 12000, 256000)).toBe("Context (custom): 12,000 tokens used. Provider context limit unknown.");
10+
expect(contextNotice("custom")).toContain("usage not reported yet");
11+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { EngineKey } from "../runtime/engineRegistry.js";
2+
3+
// Shared by the terminal and host-dispatched /context command.
4+
export function contextNotice(cli: EngineKey, used?: number, reportedWindow?: number): string {
5+
const fmt = (n: number) => Math.round(n).toLocaleString("en-US");
6+
// Custom runs through Codex, whose reported window may be its own fallback.
7+
// It is not evidence of the configured provider's capacity or compaction policy.
8+
if (cli === "custom") {
9+
return used === undefined
10+
? "Context: usage not reported yet. Provider context limit unknown."
11+
: `Context (custom): ${fmt(used)} tokens used. Provider context limit unknown.`;
12+
}
13+
const window = reportedWindow ?? (cli === "claude" ? 200_000 : 256_000);
14+
if (used === undefined) return `Context: 0 / ${fmt(window)} tokens. Send a message to measure usage.`;
15+
const free = Math.max(0, window - used);
16+
const pct = Math.round((used / window) * 100);
17+
const threshold = Math.max(0, window - 33_000);
18+
const tpct = Math.round((threshold / window) * 100);
19+
return `Context window (${cli})\n`
20+
+ ` used ${fmt(used)} / ${fmt(window)} (${pct}%)\n`
21+
+ ` free ${fmt(free)}\n`
22+
+ ` auto-compact at ~${fmt(threshold)} (${tpct}%)`;
23+
}

‎packages/core/src/chat/modelOptions.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export type EngineKey = "claude" | "codex";
1+
import type { EngineKey } from "../runtime/engineRegistry.js";
2+
3+
export type { EngineKey } from "../runtime/engineRegistry.js";
24

35
export type ChatModelOption = {
46
value?: string;
@@ -48,8 +50,25 @@ export const CHAT_MODEL_OPTIONS: Record<EngineKey, ChatModelOption[]> = {
4850
description: "General GPT model · exact value: gpt-5.5",
4951
},
5052
],
53+
// The custom engine has no catalog: its one model is whatever the saved endpoint
54+
// config names, which lives on the host side. Surfaces build the entry with
55+
// customModelOption from the stored config and push it over their live channel.
56+
custom: [],
5157
};
5258

59+
// Picker entry for the configured custom-endpoint model. Empty only for a legacy
60+
// config saved before saveCustomEngineConfig required a model id; spawn rejects
61+
// those configs, so an empty list here matches an engine that cannot run.
62+
export function customModelOption(model: string, label?: string): ChatModelOption[] {
63+
if (!model) return [];
64+
return [{
65+
value: model,
66+
chipLabel: model,
67+
label: model,
68+
description: (label ? label + " · " : "") + "configured endpoint model",
69+
}];
70+
}
71+
5372
export function findChatModelOption(cli: EngineKey, model?: string): ChatModelOption | undefined {
5473
const opts = CHAT_MODEL_OPTIONS[cli];
5574
return opts.find((opt) => (opt.value ?? "default") === (model ?? "default"));

‎packages/core/src/chat/session.spec.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
99
import { join } from "node:path";
1010
import { tmpdir } from "node:os";
1111

12-
function fakeHandle(id: string, cli: "claude" | "codex") {
12+
function fakeHandle(id: string, cli: "claude" | "codex" | "custom") {
1313
const usageCbs: Array<(n: number, window?: number) => void> = [];
1414
const compactCbs: Array<() => void> = [];
1515
const msgCbs: Array<(msg: any) => void> = [];
@@ -674,3 +674,21 @@ describe("chat/session — feed anchor re-prime after a bumping blog comment (is
674674
expect(getBlogFeed).toHaveBeenCalledWith(undefined, "latest", true);
675675
});
676676
});
677+
678+
describe("Custom context reporting", () => {
679+
it("never derives provider capacity or compaction from a Codex window", async () => {
680+
const { handles, fromUI, transport, chat } = harness();
681+
try {
682+
fromUI({ type: "platform", cli: "custom" });
683+
fromUI({ type: "slashCommand", command: "context" });
684+
await flush();
685+
expect(transport.send).toHaveBeenCalledWith({ type: "notice", text: "Context: usage not reported yet. Provider context limit unknown." });
686+
fromUI({ type: "send", text: "fixture" });
687+
await flush();
688+
handles[0].emitUsage(12000, 256000);
689+
fromUI({ type: "slashCommand", command: "context" });
690+
await flush();
691+
expect(transport.send).toHaveBeenCalledWith({ type: "notice", text: "Context (custom): 12,000 tokens used. Provider context limit unknown." });
692+
} finally { chat.stop(); }
693+
});
694+
});

0 commit comments

Comments
 (0)