@bitkyc08/opencodex 2.6.7-preview.20260629 → 2.6.8-preview.20260630

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/gui/dist/assets/{index-CSI5RHdZ.css → index-DIBiVVC0.css} +1 -1
  2. package/gui/dist/assets/index-DT-bDaop.js +9 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/azure.ts +2 -2
  6. package/src/adapters/base.ts +6 -1
  7. package/src/adapters/google-antigravity-replay.ts +136 -0
  8. package/src/adapters/google-antigravity-wire.ts +94 -0
  9. package/src/adapters/google-errors.ts +95 -0
  10. package/src/adapters/google-http.ts +109 -0
  11. package/src/adapters/google-tool-schema.ts +89 -0
  12. package/src/adapters/google-truncation.ts +13 -0
  13. package/src/adapters/google.ts +163 -20
  14. package/src/adapters/kiro-tools.ts +55 -2
  15. package/src/cli-status.ts +4 -0
  16. package/src/cli.ts +21 -0
  17. package/src/codex-history-provider.ts +195 -22
  18. package/src/codex-plugins-doctor.ts +242 -0
  19. package/src/config.ts +1 -0
  20. package/src/lib/gcp-adc.ts +292 -0
  21. package/src/oauth/google-antigravity.ts +225 -0
  22. package/src/oauth/index.ts +19 -1
  23. package/src/oauth/store.ts +1 -0
  24. package/src/oauth/types.ts +2 -0
  25. package/src/provider-context-cap.ts +30 -1
  26. package/src/providers/antigravity-models.ts +30 -0
  27. package/src/providers/derive.ts +6 -0
  28. package/src/providers/registry.ts +7 -0
  29. package/src/redact.ts +26 -0
  30. package/src/router.ts +6 -0
  31. package/src/server.ts +45 -9
  32. package/src/star-prompt.ts +13 -0
  33. package/src/types.ts +12 -0
  34. package/src/update-notify.ts +257 -0
  35. package/src/update.ts +25 -11
  36. package/src/usage-summary.ts +30 -3
  37. package/src/web-search/loop.ts +1 -1
  38. package/gui/dist/assets/index-Cd-RQxYT.js +0 -9
@@ -1,5 +1,6 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models";
3
+ import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS } from "./antigravity-models";
3
4
 
4
5
  export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
5
6
  export type MetadataModelIdNormalize = "case-insensitive";
@@ -34,6 +35,9 @@ export interface ProviderRegistryEntry {
34
35
  jawcodeBundle?: string;
35
36
  extraMetadataAliases?: string[];
36
37
  metadataModelIdNormalize?: MetadataModelIdNormalize;
38
+ googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
39
+ project?: string;
40
+ location?: string;
37
41
  }
38
42
 
39
43
  export type ProviderConfigSeed = Pick<
@@ -43,6 +47,7 @@ export type ProviderConfigSeed = Pick<
43
47
  | "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
44
48
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
45
49
  | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "escapeBuiltinToolNames"
50
+ | "googleMode" | "project" | "location"
46
51
  >;
47
52
 
48
53
 
@@ -260,6 +265,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
260
265
  { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter" },
261
266
  { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
262
267
  { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro", jawcodeBundle: "google", extraMetadataAliases: ["gemini"] },
268
+ { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
269
+ { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
263
270
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
264
271
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank", reasoningEffortMap: OLLAMA_REASONING_MAP },
265
272
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
package/src/redact.ts CHANGED
@@ -69,3 +69,29 @@ export function redactUrlForLog(url: string): string {
69
69
  return redactSecretString(url.split("?")[0] ?? url);
70
70
  }
71
71
  }
72
+
73
+ const USER_HOME_PATH_PATTERNS: Array<[RegExp, string]> = [
74
+ // Windows: C:\Users\<name>\... -> C:\Users\[USER]\...
75
+ [/([A-Za-z]:\\Users\\)[^\\/]+/gi, "$1[USER]"],
76
+ // POSIX: /Users/<name>/... (macOS) and /home/<name>/... (Linux)
77
+ [/(\/(?:Users|home)\/)[^/]+/gi, "$1[USER]"],
78
+ ];
79
+
80
+ // Path segments whose name alone looks sensitive. Masked so a configured path
81
+ // cannot surface a secret-flavored substring in diagnostics or logs.
82
+ const SENSITIVE_SEGMENT_PATTERN = /(^|[\\/])([^\\/]*(?:secret|password|passwd|token|api[-_]?key|apikey|credential|email)[^\\/]*)(?=[\\/]|$)/gi;
83
+
84
+ /**
85
+ * Mask the username segment of an absolute home path so diagnostics can print
86
+ * paths without leaking the OS account name, and mask any path segment whose
87
+ * name looks sensitive (token/secret/password/credential/email/...). Path-focused
88
+ * and secret-safe: also runs {@link redactSecretString} for token-shaped values.
89
+ */
90
+ export function redactUserPath(path: string): string {
91
+ let masked = path;
92
+ for (const [pattern, replacement] of USER_HOME_PATH_PATTERNS) {
93
+ masked = masked.replace(pattern, replacement);
94
+ }
95
+ masked = masked.replace(SENSITIVE_SEGMENT_PATTERN, (_m, sep: string) => `${sep}[REDACTED]`);
96
+ return redactSecretString(masked);
97
+ }
package/src/router.ts CHANGED
@@ -94,6 +94,12 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
94
94
  baseUrl: registryEntry.baseUrl,
95
95
  authMode: canonicalAuthMode,
96
96
  apiKey: resolveEnvValue(provider.apiKey),
97
+ // Backfill the Google wire mode + Vertex project/location from the registry when the user
98
+ // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes
99
+ // through the correct branch (CCA/Vertex) instead of falling back to AI Studio.
100
+ ...(provider.googleMode === undefined && registryEntry.googleMode !== undefined ? { googleMode: registryEntry.googleMode } : {}),
101
+ ...(provider.project === undefined && registryEntry.project !== undefined ? { project: registryEntry.project } : {}),
102
+ ...(provider.location === undefined && registryEntry.location !== undefined ? { location: registryEntry.location } : {}),
97
103
  ...(provider.contextWindow === undefined && registryEntry.contextWindow !== undefined ? { contextWindow: registryEntry.contextWindow } : {}),
98
104
  ...(provider.reasoningEfforts === undefined && registryEntry.reasoningEfforts !== undefined ? { reasoningEfforts: registryEntry.reasoningEfforts } : {}),
99
105
  ...(provider.escapeBuiltinToolNames === undefined && registryEntry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: registryEntry.escapeBuiltinToolNames } : {}),
package/src/server.ts CHANGED
@@ -35,7 +35,7 @@ import { parseRequest } from "./responses/parser";
35
35
  import { routeModel } from "./router";
36
36
  import { namespacedToolName } from "./types";
37
37
  import {
38
- clearLoginState, getLoginStatus, getValidAccessToken, isOAuthProvider,
38
+ clearLoginState, getLoginStatus, getOAuthCredentialProjectId, getValidAccessToken, isOAuthProvider,
39
39
  listOAuthProviders, reconcileOAuthProviders, startLoginFlow, UnsupportedOAuthProviderError, upsertOAuthProvider,
40
40
  } from "./oauth/index";
41
41
  import type { CatalogModel } from "./codex-catalog";
@@ -48,7 +48,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-pr
48
48
  import { deriveProviderPresets } from "./providers/derive";
49
49
  import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "./types";
50
50
  import type { OcxUsage } from "./types";
51
- import { DEFAULT_PROVIDER_CONTEXT_CAP, providerContextCap, providerContextCaps, setProviderContextCap } from "./provider-context-cap";
51
+ import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "./provider-context-cap";
52
52
  import {
53
53
  appendUsageEntry,
54
54
  readUsageEntries,
@@ -423,6 +423,12 @@ async function handleResponses(
423
423
  if (route.provider.authMode === "oauth") {
424
424
  try {
425
425
  route.provider = { ...route.provider, apiKey: await getValidAccessToken(route.providerName) };
426
+ // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
427
+ // CCA envelope; the server injects only the bare token, so pull project from the credential.
428
+ if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
429
+ const projectId = getOAuthCredentialProjectId(route.providerName);
430
+ if (projectId) route.provider = { ...route.provider, project: projectId };
431
+ }
426
432
  } catch (err) {
427
433
  if (err instanceof UnsupportedOAuthProviderError) {
428
434
  return formatErrorResponse(
@@ -449,7 +455,7 @@ async function handleResponses(
449
455
  const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
450
456
 
451
457
  if ("passthrough" in adapter && adapter.passthrough) {
452
- const request = adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
458
+ const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
453
459
  // Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
454
460
  // consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
455
461
  // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
@@ -589,7 +595,7 @@ async function handleResponses(
589
595
  const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
590
596
  const connectMs = config.connectTimeoutMs ?? 100_000;
591
597
 
592
- const request = adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
598
+ const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
593
599
  if (typeof request.usageLog?.inputTokens === "number") {
594
600
  logCtx.usageLogInputTokens = request.usageLog.inputTokens;
595
601
  }
@@ -1802,12 +1808,44 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1802
1808
  }
1803
1809
 
1804
1810
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
1805
- return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, caps: providerContextCaps(config) });
1811
+ return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
1806
1812
  }
1807
1813
 
1808
1814
  if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
1809
- let body: { provider?: unknown; enabled?: unknown };
1815
+ let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
1810
1816
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
1817
+ const { saveConfig: save } = await import("./config");
1818
+ const { clearModelCache } = await import("./model-cache");
1819
+ const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
1820
+
1821
+ // Branch 1: set the global cap value and re-point every enabled provider to it.
1822
+ if (body.value !== undefined) {
1823
+ if (typeof body.value !== "number" || !Number.isFinite(body.value) || body.value <= 0) {
1824
+ return jsonResponse({ error: "value must be a positive number" }, 400);
1825
+ }
1826
+ const affected = Object.keys(providerContextCaps(config));
1827
+ setGlobalContextCapValue(config, body.value);
1828
+ save(config);
1829
+ for (const provider of affected) clearModelCache(provider);
1830
+ await refreshCodexCatalogBestEffort();
1831
+ return respond();
1832
+ }
1833
+
1834
+ // Branch 2: enable/clear the cap for every provider at once.
1835
+ if (body.setAll !== undefined) {
1836
+ if (typeof body.setAll !== "boolean") {
1837
+ return jsonResponse({ error: "setAll must be a boolean" }, 400);
1838
+ }
1839
+ const before = Object.keys(providerContextCaps(config));
1840
+ const names = Object.keys(config.providers);
1841
+ setAllProviderContextCaps(config, names, body.setAll);
1842
+ save(config);
1843
+ for (const provider of new Set([...before, ...names])) clearModelCache(provider);
1844
+ await refreshCodexCatalogBestEffort();
1845
+ return respond();
1846
+ }
1847
+
1848
+ // Branch 3: existing per-provider toggle (enable writes the current global value).
1811
1849
  if (typeof body.provider !== "string" || typeof body.enabled !== "boolean") {
1812
1850
  return jsonResponse({ error: "provider string and enabled boolean are required" }, 400);
1813
1851
  }
@@ -1819,12 +1857,10 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1819
1857
  return jsonResponse({ error: "unknown provider" }, 404);
1820
1858
  }
1821
1859
  setProviderContextCap(config, provider, body.enabled);
1822
- const { saveConfig: save } = await import("./config");
1823
1860
  save(config);
1824
- const { clearModelCache } = await import("./model-cache");
1825
1861
  clearModelCache(provider);
1826
1862
  await refreshCodexCatalogBestEffort();
1827
- return jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, caps: providerContextCaps(config) });
1863
+ return respond();
1828
1864
  }
1829
1865
 
1830
1866
  // Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
@@ -8,6 +8,19 @@ const REPO = "lidge-jun/opencodex";
8
8
  /** Fires exactly once from the first interactive `ocx start`. */
9
9
  const MARKER = ".star-prompted";
10
10
 
11
+ /**
12
+ * True once the one-time star prompt has already fired (marker written). The
13
+ * update prompt uses this to yield on a user's very first run so two prompts
14
+ * never stack on a fresh install.
15
+ */
16
+ export function hasStarPromptRun(): boolean {
17
+ try {
18
+ return existsSync(join(getConfigDir(), MARKER));
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
11
24
  function ghAvailable(): boolean {
12
25
  const r = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
13
26
  return !r.error && r.status === 0;
package/src/types.ts CHANGED
@@ -207,6 +207,8 @@ export interface OcxConfig {
207
207
  disabledModels?: string[];
208
208
  /** Provider-level Codex-visible context caps. Values only lower known model context windows. */
209
209
  providerContextCaps?: Record<string, number>;
210
+ /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */
211
+ contextCapValue?: number;
210
212
  /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */
211
213
  hostname?: string;
212
214
  /** Upstream stall timeout (seconds). After this many seconds of no upstream data, emits response.incomplete. Default 90. Min 1. */
@@ -326,6 +328,16 @@ export interface OcxProviderConfig {
326
328
  * attached images are described by a gpt vision model and replaced with text before the call.
327
329
  */
328
330
  noVisionModels?: string[];
331
+ /**
332
+ * Google adapter mode. "ai-studio" (default) = Generative Language API + x-goog-api-key.
333
+ * "vertex" = Vertex AI project/location endpoints with GCP ADC (or x-goog-api-key).
334
+ * "cloud-code-assist" = Google Antigravity (Cloud Code Assist) OAuth + CCA envelope.
335
+ */
336
+ googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
337
+ /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */
338
+ project?: string;
339
+ /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */
340
+ location?: string;
329
341
  }
330
342
 
331
343
  export interface CodexAccount {
@@ -0,0 +1,257 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { atomicWriteFile, getConfigDir } from "./config";
6
+ import { hasStarPromptRun } from "./star-prompt";
7
+ import {
8
+ type Channel,
9
+ currentVersion,
10
+ detectInstall,
11
+ latestVersion,
12
+ runUpdate,
13
+ updateCommandStr,
14
+ updateTag,
15
+ } from "./update";
16
+
17
+ const VERSION_FILENAME = "version.json";
18
+ const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs
19
+ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
20
+
21
+ export interface VersionCache {
22
+ latest_version: string;
23
+ /** ISO-8601 (RFC3339) timestamp of the last successful registry check. */
24
+ last_checked_at: string;
25
+ dismissed_version?: string;
26
+ tag: Channel;
27
+ }
28
+
29
+ function versionFilePath(): string {
30
+ return join(getConfigDir(), VERSION_FILENAME);
31
+ }
32
+
33
+ /**
34
+ * Read the cached version info. Returns null on any error or when the cached
35
+ * channel differs from the current one (so a stable<->preview switch re-fetches
36
+ * instead of comparing across channels).
37
+ */
38
+ export function readVersionCache(channel: Channel): VersionCache | null {
39
+ try {
40
+ const raw = readFileSync(versionFilePath(), "utf8");
41
+ const parsed = JSON.parse(raw) as Partial<VersionCache>;
42
+ if (typeof parsed.latest_version !== "string" || typeof parsed.last_checked_at !== "string") return null;
43
+ if (parsed.tag !== channel) return null;
44
+ return {
45
+ latest_version: parsed.latest_version,
46
+ last_checked_at: parsed.last_checked_at,
47
+ dismissed_version: typeof parsed.dismissed_version === "string" ? parsed.dismissed_version : undefined,
48
+ tag: parsed.tag,
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function writeVersionCache(cache: VersionCache): void {
56
+ try {
57
+ atomicWriteFile(versionFilePath(), `${JSON.stringify(cache)}\n`);
58
+ } catch {
59
+ /* best-effort; never block startup */
60
+ }
61
+ }
62
+
63
+ function parseStable(v: string): [number, number, number] | null {
64
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
65
+ if (!m) return null;
66
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
67
+ }
68
+
69
+ function parsePreview(v: string): [number, number, number, number] | null {
70
+ const m = /^(\d+)\.(\d+)\.(\d+)-preview\.(\d+)$/.exec(v.trim());
71
+ if (!m) return null;
72
+ return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
73
+ }
74
+
75
+ function gt(a: number[], b: number[]): boolean {
76
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
77
+ const av = a[i] ?? 0;
78
+ const bv = b[i] ?? 0;
79
+ if (av !== bv) return av > bv;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Channel-aware "is latest newer than current?".
86
+ * - latest channel: compare maj.min.pat only; prereleases are never "newer"
87
+ * (parity with codex-rs), so stable users are not pushed onto previews.
88
+ * - preview channel: preview-vs-preview compares the trailing -preview.N; a
89
+ * stable release with a strictly higher base counts as newer (O3), while a
90
+ * stable release with the same base as the current preview does not.
91
+ */
92
+ export function isNewer(latest: string, current: string, channel: Channel): boolean {
93
+ if (channel === "latest") {
94
+ const l = parseStable(latest);
95
+ const c = parseStable(current);
96
+ if (!l || !c) return false;
97
+ return gt(l, c);
98
+ }
99
+ // preview channel
100
+ const lPre = parsePreview(latest);
101
+ const cPre = parsePreview(current);
102
+ if (lPre && cPre) return gt(lPre, cPre);
103
+
104
+ const lStable = parseStable(latest);
105
+ if (lStable && cPre) {
106
+ // Stable release vs current preview: newer only if the base is strictly
107
+ // higher than the preview's base (equal base would be a downgrade nag).
108
+ return gt(lStable, [cPre[0], cPre[1], cPre[2]]);
109
+ }
110
+ const cStable = parseStable(current);
111
+ if (lStable && cStable) return gt(lStable, cStable);
112
+ return false;
113
+ }
114
+
115
+ export function isSourceBuildVersion(v: string): boolean {
116
+ return v.trim() === "0.0.0";
117
+ }
118
+
119
+ /** The interactive/TTY + install-method gate shared with the star prompt. */
120
+ function interactiveGuardOk(): boolean {
121
+ return !(process.env.OCX_SERVICE || !process.stdin.isTTY || !process.stdout.isTTY);
122
+ }
123
+
124
+ /**
125
+ * Decide whether this run should even consider showing the prompt. Returns the
126
+ * channel + current version when eligible, else null. Eligibility requires a
127
+ * real global install, a non-source version, the interactive guard, and that
128
+ * the one-time star prompt has already run (first-run yield, O1).
129
+ */
130
+ export function shouldConsider(): { channel: Channel; current: string } | null {
131
+ if (detectInstall() === "source") return null;
132
+ const current = currentVersion();
133
+ if (current === "?" || isSourceBuildVersion(current)) return null;
134
+ if (!interactiveGuardOk()) return null;
135
+ if (!hasStarPromptRun()) return null; // yield on the very first run
136
+ return { channel: updateTag(current), current };
137
+ }
138
+
139
+ /** The cached upgrade version to surface, honoring the user's dismissal. */
140
+ export function getUpgradeVersionForPopup(
141
+ cache: VersionCache | null,
142
+ current: string,
143
+ channel: Channel,
144
+ ): string | null {
145
+ if (!cache) return null;
146
+ if (!isNewer(cache.latest_version, current, channel)) return null;
147
+ if (cache.dismissed_version === cache.latest_version) return null;
148
+ return cache.latest_version;
149
+ }
150
+
151
+ function cacheIsStale(cache: VersionCache | null): boolean {
152
+ if (!cache) return true;
153
+ const checked = Date.parse(cache.last_checked_at);
154
+ if (!Number.isFinite(checked)) return true;
155
+ return Date.now() - checked > REFRESH_INTERVAL_MS;
156
+ }
157
+
158
+ /**
159
+ * If the cache is missing or older than 20h, kick off a detached helper to
160
+ * refresh it without blocking this (soon-to-be daemon) process. Fire-and-forget.
161
+ */
162
+ export function triggerBackgroundRefreshIfStale(channel: Channel, cache: VersionCache | null): void {
163
+ if (!cacheIsStale(cache)) return;
164
+ try {
165
+ const entry = process.argv[1];
166
+ if (!entry || !existsSync(entry)) return;
167
+ const child = spawn(process.execPath, [entry, "__refresh-version", channel], {
168
+ detached: true,
169
+ stdio: "ignore",
170
+ windowsHide: true,
171
+ env: { ...process.env, OCX_SERVICE: "1" }, // never let the helper prompt
172
+ });
173
+ child.unref();
174
+ } catch {
175
+ /* best-effort */
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Body of the hidden `__refresh-version` subcommand: fetch the latest version
181
+ * for the channel and persist it. Only advances `last_checked_at` on success so
182
+ * a failed fetch retries on the next start.
183
+ */
184
+ export async function refreshVersionCache(channel: Channel): Promise<void> {
185
+ const latest = latestVersion(channel);
186
+ if (!latest) return; // do not dirty the cache or advance the timestamp
187
+ const prev = readVersionCache(channel);
188
+ writeVersionCache({
189
+ latest_version: latest,
190
+ last_checked_at: new Date().toISOString(),
191
+ dismissed_version: prev?.dismissed_version,
192
+ tag: channel,
193
+ });
194
+ }
195
+
196
+ /** Persist a dismissal so this exact version stops prompting. */
197
+ function dismissVersion(channel: Channel, version: string): void {
198
+ const cache = readVersionCache(channel);
199
+ if (!cache) return;
200
+ writeVersionCache({ ...cache, dismissed_version: version });
201
+ }
202
+
203
+ function renderPrompt(current: string, latest: string, channel: Channel): string {
204
+ const command = updateCommandStr(detectInstall(), channel);
205
+ return [
206
+ "",
207
+ ` \x1b[38;5;141m✨ Update available!\x1b[0m \x1b[2m${current} -> ${latest}\x1b[0m`,
208
+ "",
209
+ ` \x1b[2mRelease notes:\x1b[0m ${RELEASE_NOTES_URL}`,
210
+ "",
211
+ ` 1) Update now (runs \`${command}\`)`,
212
+ " 2) Skip",
213
+ " 3) Skip until next version",
214
+ "",
215
+ " [1/2/3] (default 1): ",
216
+ ].join("\n");
217
+ }
218
+
219
+ /**
220
+ * Interactive-only update prompt for `ocx start`. Must be called BEFORE the
221
+ * server binds a port / writes a PID, because "Update now" installs globally
222
+ * and exits. No-op for service/daemon/non-TTY runs and source checkouts.
223
+ * Never throws.
224
+ */
225
+ export async function maybeShowUpdatePrompt(): Promise<void> {
226
+ try {
227
+ const eligible = shouldConsider();
228
+ if (!eligible) return;
229
+ const { channel, current } = eligible;
230
+
231
+ const cache = readVersionCache(channel);
232
+ triggerBackgroundRefreshIfStale(channel, cache);
233
+
234
+ const latest = getUpgradeVersionForPopup(cache, current, channel);
235
+ if (!latest) return;
236
+
237
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
238
+ let answer = "";
239
+ try {
240
+ answer = (await rl.question(renderPrompt(current, latest, channel))).trim();
241
+ } finally {
242
+ rl.close();
243
+ }
244
+
245
+ const choice = answer === "" ? "1" : answer;
246
+ if (choice === "1") {
247
+ await runUpdate();
248
+ console.log("\nRestart the proxy: ocx start");
249
+ process.exit(0);
250
+ } else if (choice === "3") {
251
+ dismissVersion(channel, latest);
252
+ }
253
+ // "2" (or anything else) -> Skip: continue this run unchanged.
254
+ } catch {
255
+ /* never let the update prompt disrupt startup */
256
+ }
257
+ }
package/src/update.ts CHANGED
@@ -3,18 +3,19 @@ import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
- const PKG = "@bitkyc08/opencodex";
6
+ export const PKG = "@bitkyc08/opencodex";
7
7
  const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src
8
8
 
9
- type Installer = "bun" | "npm" | "source";
9
+ export type Installer = "bun" | "npm" | "source";
10
+ export type Channel = "latest" | "preview";
10
11
 
11
12
  /** Infer how opencodex is installed from the running module's path. */
12
- function detectInstall(): Installer {
13
+ export function detectInstall(): Installer {
13
14
  if (!HERE.includes("node_modules")) return "source"; // a git checkout, not a global install
14
15
  return HERE.includes(".bun") ? "bun" : "npm";
15
16
  }
16
17
 
17
- function currentVersion(): string {
18
+ export function currentVersion(): string {
18
19
  try {
19
20
  return (JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version as string) ?? "?";
20
21
  } catch {
@@ -22,18 +23,34 @@ function currentVersion(): string {
22
23
  }
23
24
  }
24
25
 
25
- function updateTag(current: string): string {
26
+ export function updateTag(current: string): Channel {
26
27
  const tagIndex = process.argv.indexOf("--tag");
27
- if (tagIndex !== -1 && process.argv[tagIndex + 1]) return process.argv[tagIndex + 1];
28
+ const explicit = tagIndex !== -1 ? process.argv[tagIndex + 1] : undefined;
29
+ if (explicit === "preview" || explicit === "latest") return explicit;
28
30
  return current.includes("-preview.") ? "preview" : "latest";
29
31
  }
30
32
 
31
33
  /** Latest published version from the registry (best-effort; null if npm isn't available). */
32
- function latestVersion(tag: string): string | null {
34
+ export function latestVersion(tag: string): string | null {
33
35
  const r = spawnSync("npm", ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true });
34
36
  return r.status === 0 ? r.stdout.trim() : null;
35
37
  }
36
38
 
39
+ /** The global-install command opencodex would run to update on this channel. */
40
+ export function updateCommand(installer: Installer, tag: Channel): { bin: string; args: string[] } {
41
+ const bin = installer === "bun" ? "bun" : "npm";
42
+ const args = installer === "bun"
43
+ ? ["add", "-g", `${PKG}@${tag}`]
44
+ : ["install", "-g", `${PKG}@${tag}`];
45
+ return { bin, args };
46
+ }
47
+
48
+ /** Human-readable form of {@link updateCommand}, used in the update prompt label. */
49
+ export function updateCommandStr(installer: Installer, tag: Channel): string {
50
+ const { bin, args } = updateCommand(installer, tag);
51
+ return `${bin} ${args.join(" ")}`;
52
+ }
53
+
37
54
  /**
38
55
  * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
39
56
  * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
@@ -55,10 +72,7 @@ export async function runUpdate(): Promise<void> {
55
72
  return;
56
73
  }
57
74
 
58
- const bin = installer === "bun" ? "bun" : "npm";
59
- const cmdArgs = installer === "bun"
60
- ? ["add", "-g", `${PKG}@${tag}`]
61
- : ["install", "-g", `${PKG}@${tag}`];
75
+ const { bin, args: cmdArgs } = updateCommand(installer, tag);
62
76
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
63
77
 
64
78
  const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });
@@ -22,6 +22,14 @@ export interface UsageDay {
22
22
  requests: number;
23
23
  reportedRequests: number;
24
24
  totalTokens: number;
25
+ models: UsageDayModel[];
26
+ }
27
+
28
+ export interface UsageDayModel {
29
+ model: string;
30
+ provider: string;
31
+ requests: number;
32
+ totalTokens: number;
25
33
  }
26
34
 
27
35
  export interface UsageModel {
@@ -124,24 +132,43 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
124
132
  const window = rangeWindow(range, now);
125
133
  const days = range === "all" ? dayCountForAllRange(entries, now) : window.days;
126
134
  const grid = new Map<string, UsageDay>();
135
+ // Per-day model breakdown accumulator, keyed by day then provider/model, so the 7d bar chart can
136
+ // render a per-model stacked bar with a hover tooltip without a second pass over the entries.
137
+ const dayModels = new Map<string, Map<string, UsageDayModel>>();
138
+ const bumpDayModel = (dayKey: string, entry: PersistedUsageEntry): void => {
139
+ let models = dayModels.get(dayKey);
140
+ if (!models) { models = new Map(); dayModels.set(dayKey, models); }
141
+ const mKey = `${entry.provider}/${entry.model}`;
142
+ let m = models.get(mKey);
143
+ if (!m) { m = { model: entry.model, provider: entry.provider, requests: 0, totalTokens: 0 }; models.set(mKey, m); }
144
+ m.requests += 1;
145
+ if (typeof entry.totalTokens === "number") m.totalTokens += entry.totalTokens;
146
+ else if (entry.usage) m.totalTokens += entry.usage.inputTokens + entry.usage.outputTokens;
147
+ };
127
148
  for (let i = days - 1; i >= 0; i--) {
128
149
  const key = localDateKey(now - i * DAY_MS);
129
- grid.set(key, { date: key, requests: 0, reportedRequests: 0, totalTokens: 0 });
150
+ grid.set(key, { date: key, requests: 0, reportedRequests: 0, totalTokens: 0, models: [] });
130
151
  }
131
152
  for (const entry of entries) {
132
153
  const key = localDateKey(entry.timestamp);
133
154
  let day = grid.get(key);
134
155
  if (!day) {
135
- day = { date: key, requests: 0, reportedRequests: 0, totalTokens: 0 };
156
+ day = { date: key, requests: 0, reportedRequests: 0, totalTokens: 0, models: [] };
136
157
  grid.set(key, day);
137
158
  }
138
159
  day.requests += 1;
139
160
  if (entry.usageStatus === "reported") day.reportedRequests += 1;
140
161
  if (typeof entry.totalTokens === "number") day.totalTokens += entry.totalTokens;
141
162
  else if (entry.usage) day.totalTokens += entry.usage.inputTokens + entry.usage.outputTokens;
163
+ bumpDayModel(key, entry);
142
164
  }
143
165
  void since;
144
- return [...grid.values()].sort((a, b) => a.date.localeCompare(b.date));
166
+ const out = [...grid.values()].sort((a, b) => a.date.localeCompare(b.date));
167
+ for (const day of out) {
168
+ const models = dayModels.get(day.date);
169
+ if (models) day.models = [...models.values()].sort((a, b) => b.requests - a.requests);
170
+ }
171
+ return out;
145
172
  }
146
173
 
147
174
  function buildModels(entries: PersistedUsageEntry[], totalRequests: number): UsageModel[] {
@@ -130,7 +130,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
130
130
  ...parsed, stream: false,
131
131
  context: { ...parsed.context, messages, tools: forceAnswer ? toolsNoWebSearch : allTools },
132
132
  };
133
- const request = adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
133
+ const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
134
134
  let resp: Response;
135
135
  try {
136
136
  resp = adapter.fetchResponse