feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router): - server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/ - dashboard_runs.router renamed in place to proxy: an older build on the same DB fails on that column, so dev and prod sharing one DB update together - a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages); Test probes Claude /v1/messages and Codex /v1/responses - Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker Updates: - every tab reloads onto the new UI build when its WebSocket reconnects to a server of another version (lib/newBuildReload.ts, once per version) - Update now stops waiting on a plugin chain that failed or pulled nothing (the old server never goes down), and reloads as soon as a new version answers Also: - a pane whose command exits at once says so (EPANEEXITED) instead of "Command failed: tmux set-option … mouse on" - the pane drag preview draws where the pane actually lands
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Tests for newBuildReload: after a reconnect the tab reloads once when
|
||||
* the server's version is not this bundle's, never on the first connect,
|
||||
* never for the same version, and not twice for one version.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { eventBus } from "../eventBus";
|
||||
import { reloadOnNewBuild } from "../newBuildReload";
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function setup(serverVersion: string) {
|
||||
const reload = vi.fn();
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
};
|
||||
const off = reloadOnNewBuild({
|
||||
bundle: "2.5.4",
|
||||
health: () => Promise.resolve({ version: serverVersion }),
|
||||
reload,
|
||||
storage,
|
||||
});
|
||||
return { reload, off };
|
||||
}
|
||||
|
||||
let off: (() => void) | undefined;
|
||||
afterEach(() => off?.());
|
||||
|
||||
describe("reloadOnNewBuild", () => {
|
||||
it("reloads once after a reconnect onto a new version", async () => {
|
||||
const s = setup("2.5.5");
|
||||
off = s.off;
|
||||
eventBus.setConnected(true); // first connect: nothing to compare against yet
|
||||
await flush();
|
||||
expect(s.reload).not.toHaveBeenCalled();
|
||||
eventBus.setConnected(false);
|
||||
eventBus.setConnected(true);
|
||||
await flush();
|
||||
expect(s.reload).toHaveBeenCalledTimes(1);
|
||||
eventBus.setConnected(false); // same version again: a failed UI build must not loop
|
||||
eventBus.setConnected(true);
|
||||
await flush();
|
||||
expect(s.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays put when the server is this bundle's version", async () => {
|
||||
const s = setup("2.5.4");
|
||||
off = s.off;
|
||||
eventBus.setConnected(false);
|
||||
eventBus.setConnected(true);
|
||||
await flush();
|
||||
expect(s.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+53
-44
@@ -2099,13 +2099,6 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||
* install. No response ever carries a password — only `hasToken`. */
|
||||
gitCredentials: {
|
||||
@@ -2219,37 +2212,44 @@ export const api = {
|
||||
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
// ──────────────────────────────── AI Proxy API ──────────────────────────────
|
||||
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* new session can run on instead of the vendor's own API. Managed in the
|
||||
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiProxies: {
|
||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
update: (name: string, body: Partial<AiProxyInput>) =>
|
||||
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
||||
* before a profile that cannot reach anything is saved onto a lane.
|
||||
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
|
||||
* for Claude Code and for Codex, before a session finds out it does not.
|
||||
*/
|
||||
test: (body: AiRouterTestInput) =>
|
||||
request<AiRouterTestResult>("/ai-routers/test", {
|
||||
test: (body: AiProxyTestInput) =>
|
||||
request<AiProxyTestResult>("/ai-proxies/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also removes the session files that
|
||||
* DELETE /api/ai-proxies/:name - also removes the session files that
|
||||
* copy its key.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
},
|
||||
@@ -3307,10 +3307,10 @@ export interface CcProfileUsage {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||
* var Claude Code reads; a proxy that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
export interface AiProxyModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
@@ -3318,7 +3318,6 @@ export interface AiRouterModels {
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
/** A saved git login, as the server reports it: never the password. */
|
||||
export interface GitCredential {
|
||||
protocol: "http" | "https";
|
||||
@@ -3362,8 +3361,8 @@ export interface AiTool {
|
||||
resume: boolean;
|
||||
/** Offers the per-run "Bypass permissions" switch. */
|
||||
bypass: boolean;
|
||||
/** Can use an AI Router profile (Claude, Codex); others are native only. */
|
||||
routerCapable: boolean;
|
||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||
proxyCapable: boolean;
|
||||
/** What Install runs; null when the catalog names no install. */
|
||||
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
|
||||
/** What Update runs; `self` is the tool's own updater with these args. */
|
||||
@@ -3421,37 +3420,47 @@ export interface AiToolJob {
|
||||
log: string;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
|
||||
export interface AiProxy {
|
||||
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
/** "" for `native`, which has no model of its own. */
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
/** Empty for `native`. */
|
||||
models: Partial<AiProxyModels>;
|
||||
/** Claude model id the proxy's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
||||
* words — truncated server-side, never the key. */
|
||||
export interface AiRouterTestResult {
|
||||
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
|
||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||
* own words — truncated server-side, never the key. */
|
||||
export interface AiProxyProbe {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
|
||||
/** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
|
||||
* Codex's `/v1/responses`, probed side by side. */
|
||||
export interface AiProxyTestResult {
|
||||
claude: AiProxyProbe;
|
||||
codex: AiProxyProbe;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-proxies/test. `name` lets the server reuse the stored
|
||||
* key for a profile being edited — but only while `baseUrl` still matches the
|
||||
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||
export interface AiRouterTestInput {
|
||||
export interface AiProxyTestInput {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
@@ -3459,14 +3468,14 @@ export interface AiRouterTestInput {
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
export interface AiProxyInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
@@ -4124,8 +4133,8 @@ export interface RunStartArgs {
|
||||
resumeLast?: boolean;
|
||||
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
|
||||
bypass?: boolean;
|
||||
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
|
||||
router?: string;
|
||||
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file newBuildReload.ts
|
||||
* @description Reloads the tab onto the new UI build after the server was
|
||||
* updated. Every update — the dashboard's own Update button in any tab,
|
||||
* `/agenthub-update`, a restart onto a newer plugin — drops the WebSocket; when
|
||||
* it comes back, `GET /api/health` names the server's version, and a version
|
||||
* other than this bundle's `__APP_VERSION__` means the page is stale. At most
|
||||
* one reload per version (sessionStorage), so a server whose UI build failed
|
||||
* cannot make the tab reload on every reconnect.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { eventBus } from "./eventBus";
|
||||
|
||||
const KEY = "agenthub.reloadedFor";
|
||||
|
||||
export function reloadOnNewBuild({
|
||||
bundle = __APP_VERSION__,
|
||||
health = () => fetch("/api/health").then((r) => r.json() as Promise<{ version?: string }>),
|
||||
reload = () => window.location.reload(),
|
||||
storage = window.sessionStorage,
|
||||
}: {
|
||||
bundle?: string;
|
||||
health?: () => Promise<{ version?: string }>;
|
||||
reload?: () => void;
|
||||
storage?: Pick<Storage, "getItem" | "setItem">;
|
||||
} = {}): () => void {
|
||||
let wasDown = false;
|
||||
return eventBus.onConnection((up) => {
|
||||
if (!up) {
|
||||
wasDown = true;
|
||||
return;
|
||||
}
|
||||
if (!wasDown) return;
|
||||
wasDown = false;
|
||||
health()
|
||||
.then(({ version }) => {
|
||||
if (!version || version === bundle || storage.getItem(KEY) === version) return;
|
||||
storage.setItem(KEY, version);
|
||||
reload();
|
||||
})
|
||||
.catch(() => {
|
||||
/* the next reconnect asks again */
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -259,7 +259,7 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
|
||||
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
|
||||
|
||||
@@ -2652,10 +2652,9 @@ export interface Lane {
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
tool: LaneTool;
|
||||
/** Name of the AI Router profile this lane runs against, or null for the
|
||||
* vendor's own API. Unlike `tool` this IS changeable after creation, but
|
||||
* only through `POST /api/lanes/:id/router/apply` — the column and the
|
||||
* lane's own config on disk must move together. */
|
||||
/** Legacy: the per-workspace AI Proxy (then "AI Router") profile. A proxy
|
||||
* is a session's choice now and the server empties this at every start, so
|
||||
* it is always null. */
|
||||
router_profile: string | null;
|
||||
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
||||
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||
|
||||
Reference in New Issue
Block a user