File size: 8,275 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | /**
* Provider endpoint routing — mirrors the ZCode 3.7+ `ProviderEndpointRoutingService`.
*
* The desktop client periodically fetches `GET {zcodeApiBase}/api/v1/agent/configs`
* and rewrites provider request URLs according to the returned
* `data.proxyEndpoint.mapping` table (`from` → `to`, exact normalized-URL match).
* As of 2026-08-19 the server maps the coding-plan Anthropic endpoints to
* `zcode.z.ai/api/v1/ultra[-zai]/...`; the table is server-controlled and may
* grow at any time, so resolution is generic.
*
* Failure semantics are strictly fail-open: any fetch/parse error keeps the
* previous snapshot (or none) and requests go to their original URL after a
* cooldown. See `_reverse/NOTEPAD.md` "Server-side endpoint remapping".
*/
import { buildIdentityHeaders, identityCacheKey } from "./identity.js";
import type { ProxyIdentity } from "../config/types.js";
const DEFAULT_ORIGIN = "https://zcode.z.ai";
const CONFIG_PATH = "/api/v1/agent/configs";
const SUCCESS_TTL_MS = 300_000;
const FAILURE_COOLDOWN_MS = 30_000;
const REQUEST_TIMEOUT_MS = 3_000;
const MAX_MAPPING_ENTRIES = 256;
export interface EndpointRoutingOptions {
/** Origin of the agent-configs endpoint. */
origin?: string;
identity: ProxyIdentity;
/** Resolves the `x-api-key` sent on the config fetch (coding-plan credential). */
credential?: () => string | undefined;
fetchImpl?: typeof fetch;
now?: () => number;
successTtlMs?: number;
failureCooldownMs?: number;
requestTimeoutMs?: number;
onSnapshot?: (entries: number) => void;
}
export interface RoutedUrl {
routed: boolean;
url: string;
}
interface RoutingSnapshot {
expiresAt: number;
mapping: Map<string, string>;
}
function normalizePath(pathname: string): string {
if (pathname === "/") return "/";
return pathname.replace(/\/+$/u, "") || "/";
}
function routingKey(url: URL): string {
const port = url.port || "443";
return `${url.protocol}//${url.hostname.toLowerCase()}:${port}${normalizePath(url.pathname)}`;
}
function parseMappingUrl(value: unknown, field: "from" | "to"): URL {
if (typeof value !== "string") throw new Error(`mapping.${field} must be a string`);
const parsed = new URL(value);
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(`mapping.${field} URL is not a plain https URL`);
}
return parsed;
}
export class EndpointRoutingService {
private readonly configUrl: string;
private readonly identity: ProxyIdentity;
private readonly credential?: () => string | undefined;
private readonly fetchImpl: typeof fetch;
private readonly now: () => number;
private readonly successTtlMs: number;
private readonly failureCooldownMs: number;
private readonly requestTimeoutMs: number;
private readonly onSnapshot?: (entries: number) => void;
private snapshot: RoutingSnapshot | undefined;
private retryAfter = 0;
private refreshPromise: Promise<void> | undefined;
constructor(opts: EndpointRoutingOptions) {
this.configUrl = `${(opts.origin?.trim() || DEFAULT_ORIGIN).replace(/\/+$/u, "")}${CONFIG_PATH}`;
this.identity = opts.identity;
this.credential = opts.credential;
this.fetchImpl = opts.fetchImpl ?? fetch;
this.now = opts.now ?? Date.now;
this.successTtlMs = opts.successTtlMs ?? SUCCESS_TTL_MS;
this.failureCooldownMs = opts.failureCooldownMs ?? FAILURE_COOLDOWN_MS;
this.requestTimeoutMs = opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
this.onSnapshot = opts.onSnapshot;
}
/** True when at least one successful snapshot has been fetched. */
hasSnapshot(): boolean {
return this.snapshot !== undefined;
}
/**
* Resolve a request URL through the mapping table. Never throws: any error
* resolves to `{ routed: false, url }` so the caller keeps the original URL.
* The optional `credential` (coding-plan key) is attached as `x-api-key` on
* a mapping refresh fetch, mirroring the client's `sourceHeaders`.
*/
async resolve(url: string, credential?: string): Promise<RoutedUrl> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { routed: false, url };
}
try {
await this.ensureFresh(credential);
} catch {
// fail-open: resolve without a snapshot
}
const target = this.snapshot?.mapping.get(routingKey(parsed));
if (!target) return { routed: false, url };
const rewritten = new URL(target);
rewritten.search = parsed.search;
return { routed: true, url: rewritten.href };
}
private async ensureFresh(credential?: string): Promise<void> {
const now = this.now();
if ((this.snapshot && this.snapshot.expiresAt > now) || this.retryAfter > now) return;
const pending = this.refreshPromise ?? this.beginRefresh(credential);
await pending;
}
private beginRefresh(credential?: string): Promise<void> {
const promise = this.refresh(credential).finally(() => {
if (this.refreshPromise === promise) this.refreshPromise = undefined;
});
this.refreshPromise = promise;
return promise;
}
private async refresh(credential?: string): Promise<void> {
// QSt (bundle) builds the config-fetch identity set WITHOUT X-ZCode-Agent;
// Pvo appends x-api-key + Accept. Mirror that set exactly.
const identityHeaders = Object.fromEntries(
Object.entries(buildIdentityHeaders(this.identity)).filter(([name]) => name !== "X-ZCode-Agent"),
);
const headers: Record<string, string> = {
...identityHeaders,
Accept: "application/json",
};
const key = credential ?? this.credential?.();
if (key) headers["x-api-key"] = key;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
try {
const resp = await this.fetchImpl(this.configUrl, {
method: "GET",
headers,
redirect: "manual",
signal: controller.signal,
});
if (resp.status < 200 || resp.status >= 300) throw new Error(`agent_configs_http_${resp.status}`);
const parsed = await resp.json() as unknown;
const envelope = parsed as { code?: unknown; data?: unknown };
if (!envelope || typeof envelope !== "object" || envelope.code !== 0) {
throw new Error("agent_configs_nonzero_code");
}
const data = envelope.data as { proxyEndpoint?: { mapping?: unknown } } | undefined;
const entries = data?.proxyEndpoint?.mapping;
const list = Array.isArray(entries) ? entries : [];
if (list.length > MAX_MAPPING_ENTRIES) throw new Error("agent_configs_too_many_mappings");
const mapping = new Map<string, string>();
for (const entry of list) {
const raw = entry as { from?: unknown; to?: unknown };
const from = parseMappingUrl(raw.from, "from");
const to = parseMappingUrl(raw.to, "to");
const key = routingKey(from);
if (mapping.has(key)) throw new Error("agent_configs_duplicate_from");
mapping.set(key, to.href);
}
this.snapshot = { expiresAt: this.now() + this.successTtlMs, mapping };
this.retryAfter = 0;
this.onSnapshot?.(mapping.size);
} catch {
this.retryAfter = this.now() + this.failureCooldownMs;
} finally {
clearTimeout(timer);
}
}
}
let defaultRouting: EndpointRoutingService | null = null;
let defaultRoutingKey = "";
/**
* Process-wide routing service, shared across requests (snapshot cache).
* Recreated when the relevant config values change (Android `setConfig`) —
* keyed on the full identity because the service embeds it in config-fetch
* headers. Returns `null` when disabled.
*/
export function getDefaultEndpointRouting(config: {
endpointRouting: { enabled: boolean; origin: string };
identity: ProxyIdentity;
}): EndpointRoutingService | null {
if (!config.endpointRouting.enabled) return null;
const key = `${config.endpointRouting.origin}\n${identityCacheKey(config.identity)}`;
if (!defaultRouting || key !== defaultRoutingKey) {
defaultRouting = new EndpointRoutingService({
origin: config.endpointRouting.origin,
identity: config.identity,
});
defaultRoutingKey = key;
}
return defaultRouting;
}
|