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;
}