File size: 8,968 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
/**
 * Identity header builders β€” emit the ZCode desktop client's companion
 * headers so the proxy is indistinguishable from the official client at the
 * fingerprinting layer.
 *
 * TWO distinct bundle functions are mirrored (ZCode 3.11.2, `_reverse/zcode.cjs`):
 *
 *   1. `csn` = buildCliZCodeSourceHeaders (CLI LLM path, wrapped by `x4i`
 *      which appends `X-ZCode-Agent: "glm"` as the LAST header) β€” used for
 *      every LLM completion request β†’ {@link buildLlmIdentityHeaders}.
 *      Shape: HTTP-Referer, User-Agent, [X-ZCode-App-Version], X-Title,
 *      X-Release-Channel (always), X-Client-Language (always, "unknown"
 *      fallback), X-Client-Timezone (always, "unknown" fallback),
 *      [X-Platform], X-Os-Category (when platform resolves), [X-Os-Version],
 *      X-ZCode-Agent ("glm", last). NO X-Device-Mid β€” the CLI LLM path never
 *      carried it.
 *
 *   2. `HRt` = buildZCodeSourceHeadersFromContext (host-side control-plane
 *      fetches: endpoint-routing configs, signing gate, claim/billing) β€”
 *      used by {@link buildIdentityHeaders}. Shape keeps the historical
 *      `pio`-derived order with conditional language/timezone and the
 *      optional X-Device-Mid (server-required on the claim/billing plane
 *      since the 0828 campaign). Consumers: endpoint-routing.ts,
 *      client-signing.ts, claim/client.ts, routes-quota.ts, async bridge.
 *
 * Both gate header values through the bundle's `fio` printable-ASCII rule;
 * `n = fio(...)` validates appVersion and, when it fails, drops
 * X-ZCode-App-Version entirely and falls the User-Agent back to
 * `ZCode/unknown`.
 *
 * Runtime values are read via env overrides (matching the existing
 * ZCODE_IDENTITY_PLATFORM/ARCH/RELEASE pattern) so the Android entry can emit
 * desktop-Linux identity without changing this module:
 *   - ZCODE_IDENTITY_RELEASE_CHANNEL
 *   - ZCODE_IDENTITY_CLIENT_LANGUAGE   (default: Intl locale, e.g. "zh-CN")
 *   - ZCODE_IDENTITY_CLIENT_TIMEZONE   (default: Intl timezone, e.g. "Asia/Shanghai")
 *   - ZCODE_IDENTITY_DEVICE_MID        (no default; omitted unless set)
 *
 * @see _reverse/NOTEPAD.md "How Credential is Used for LLM Calls"
 */
import os from "node:os";
import type { ProxyIdentity } from "../config/types.js";

/** Printable-ASCII gate copied from the ZCode bundle's `fio` helper. */
const ASCII_PRINTABLE = /^[\x20-\x7e]+$/;

/** Resolve the appVersion the way `fio` does: trimmed + printable ASCII, else undefined. */
function resolveAppVersion(raw: string | undefined): string | undefined {
  if (typeof raw !== "string") return undefined;
  const v = raw.trim();
  return v.length > 0 && ASCII_PRINTABLE.test(v) ? v : undefined;
}

/** Normalize a header value: trimmed + printable ASCII, else undefined. */
export function normalizePrintableHeaderValue(raw: string | undefined): string | undefined {
  if (typeof raw !== "string") return undefined;
  const v = raw.trim();
  return v.length > 0 && ASCII_PRINTABLE.test(v) ? v : undefined;
}

function normalizeOsCategory(platform: NodeJS.Platform): string {
  switch (platform) {
    case "darwin":
      return "macos";
    case "win32":
      return "windows";
    default:
      return "linux";
  }
}

/** Mirrors the bundle's `lsa()` / `V8i()`: Intl locale, wrapped in try/catch. */
function resolveClientLanguage(): string | undefined {
  const override = normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_CLIENT_LANGUAGE);
  if (override) return override;
  try {
    return Intl.DateTimeFormat().resolvedOptions().locale || undefined;
  } catch {
    return undefined;
  }
}

/** Mirrors the bundle's `csa()`: Intl timezone, wrapped in try/catch. */
function resolveClientTimezone(): string | undefined {
  const override = normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_CLIENT_TIMEZONE);
  if (override) return override;
  try {
    return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
  } catch {
    return undefined;
  }
}

interface ResolvedIdentityValues {
  n?: string;
  platform?: string;
  platformForCategory: NodeJS.Platform;
  arch?: string;
  release?: string;
  releaseChannel: string;
  clientLanguage?: string;
  clientTimezone?: string;
  deviceMid?: string;
}

/** Shared env/config resolution for both builders (values only β€” ordering differs per builder). */
function resolveIdentityValues(id: ProxyIdentity): ResolvedIdentityValues {
  // Env overrides (ZCODE_IDENTITY_PLATFORM/ARCH/RELEASE) let the Android entry
  // emit desktop-Linux identity headers without changing this module.
  return {
    n: resolveAppVersion(id.appVersion),
    platform: normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_PLATFORM ?? process.platform),
    platformForCategory: (process.env.ZCODE_IDENTITY_PLATFORM ?? process.platform) as NodeJS.Platform,
    arch: normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_ARCH ?? os.arch()),
    release: normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_RELEASE ?? os.release()),
    // bundle IL(): ZCODE_ENV==="test" ? "test" : "production" β€” always resolves.
    // Mirror that default; ZCODE_IDENTITY_RELEASE_CHANNEL stays an explicit override.
    releaseChannel: normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_RELEASE_CHANNEL)
      ?? (process.env.ZCODE_ENV?.trim().toLowerCase() === "test" ? "test" : "production"),
    clientLanguage: resolveClientLanguage(),
    clientTimezone: resolveClientTimezone(),
    // env (Android NodeRunner injection) wins over the config.yaml value (desktop
    // persistence) β€” both are UUIDv4 generated once and reused forever.
    deviceMid: normalizePrintableHeaderValue(process.env.ZCODE_IDENTITY_DEVICE_MID)
      ?? normalizePrintableHeaderValue(id.deviceMid),
  };
}

/**
 * Identity headers for LLM completion requests β€” mirrors the bundle's CLI
 * source-headers builder `csn` (buildCliZCodeSourceHeaders) + the `x4i`
 * wrapper that appends `X-ZCode-Agent: "glm"` LAST. Differences vs the
 * control-plane set (buildIdentityHeaders): language/timezone are ALWAYS
 * emitted (falling back to "unknown"), X-Release-Channel sits right after
 * X-Title, X-ZCode-Agent is the final header, and X-Device-Mid is NEVER sent.
 * Pure function.
 */
export function buildLlmIdentityHeaders(id: ProxyIdentity): Record<string, string> {
  const v = resolveIdentityValues(id);
  return {
    "HTTP-Referer": id.refererOrigin,
    "User-Agent": `ZCode/${v.n ?? "unknown"}`,
    ...(v.n ? { "X-ZCode-App-Version": v.n } : {}),
    "X-Title": `Z Code@${id.sourceTitle}`,
    "X-Release-Channel": v.releaseChannel,
    "X-Client-Language": v.clientLanguage ?? "unknown",
    "X-Client-Timezone": v.clientTimezone ?? "unknown",
    ...(v.platform && v.arch ? { "X-Platform": `${v.platform}-${v.arch}` } : {}),
    ...(v.platform ? { "X-Os-Category": normalizeOsCategory(v.platformForCategory) } : {}),
    ...(v.release ? { "X-Os-Version": v.release } : {}),
    "X-ZCode-Agent": "glm",
  };
}

/**
 * Control-plane identity headers β€” mirrors the host-side bundle builder
 * `HRt` (buildZCodeSourceHeadersFromContext), reached us via the historical
 * `pio` shape. Used by endpoint-routing, client-signing gate, claim/billing
 * and the async bridge β€” NOT for LLM completion requests (use
 * {@link buildLlmIdentityHeaders} there).
 *
 * Order (with X-ZCode-Agent kept between X-Title and X-Platform):
 *   HTTP-Referer, User-Agent, [X-ZCode-App-Version], X-Title, X-ZCode-Agent,
 *   [X-Platform], [X-Release-Channel], [X-Client-Language], [X-Client-Timezone],
 *   [X-Os-Category], [X-Os-Version], [X-Device-Mid]
 *
 * Returns `Record<string, string>` rather than a fixed interface because
 * several headers are conditionally omitted.
 */
export function buildIdentityHeaders(id: ProxyIdentity): Record<string, string> {
  const v = resolveIdentityValues(id);
  return {
    "HTTP-Referer": id.refererOrigin,
    "User-Agent": `ZCode/${v.n ?? "unknown"}`,
    ...(v.n ? { "X-ZCode-App-Version": v.n } : {}),
    "X-Title": `Z Code@${id.sourceTitle}`,
    "X-ZCode-Agent": "glm",
    ...(v.platform && v.arch ? { "X-Platform": `${v.platform}-${v.arch}` } : {}),
    ...(v.releaseChannel ? { "X-Release-Channel": v.releaseChannel } : {}),
    ...(v.clientLanguage ? { "X-Client-Language": v.clientLanguage } : {}),
    ...(v.clientTimezone ? { "X-Client-Timezone": v.clientTimezone } : {}),
    ...(v.platform ? { "X-Os-Category": normalizeOsCategory(v.platformForCategory) } : {}),
    ...(v.release ? { "X-Os-Version": v.release } : {}),
    ...(v.deviceMid ? { "X-Device-Mid": v.deviceMid } : {}),
  };
}

/**
 * Cache key for process-wide singletons that embed a `ProxyIdentity`
 * (endpoint routing, client signing): two configs producing the same key can
 * share the same service instance.
 */
export function identityCacheKey(identity: ProxyIdentity): string {
  return JSON.stringify([identity.appVersion, identity.sourceTitle, identity.refererOrigin, identity.deviceMid ?? ""]);
}