@timo972/cc-router 0.12.1 → 0.12.2-rc.0

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 (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -1
  4. package/dist/cli/cmd-accounts.js +116 -65
  5. package/dist/cli/cmd-setup.js +100 -30
  6. package/dist/cli/cmd-status.js +14 -2
  7. package/dist/cli/cmd-telemetry.js +43 -32
  8. package/dist/cli/index.js +15 -1
  9. package/dist/config/directory.js +14 -0
  10. package/dist/config/telemetry.js +192 -41
  11. package/dist/providers/anthropic/usage-refresher.js +35 -1
  12. package/dist/providers/model-discovery.js +17 -11
  13. package/dist/providers/openai/device-oauth.js +88 -31
  14. package/dist/providers/openai/token-refresher.js +12 -2
  15. package/dist/providers/openai/usage-fetch.js +19 -2
  16. package/dist/proxy/anthropic-messages-route.js +144 -5
  17. package/dist/proxy/anthropic-proxy.js +10 -0
  18. package/dist/proxy/anthropic-response-capture.js +6 -19
  19. package/dist/proxy/openai-ingress.js +98 -1
  20. package/dist/proxy/server.js +35 -22
  21. package/dist/proxy/token-refresher.js +12 -2
  22. package/dist/proxy/usage-capture.js +41 -4
  23. package/dist/telemetry/contracts.js +129 -0
  24. package/dist/telemetry/facade.js +654 -0
  25. package/dist/telemetry/otel-exporters.js +289 -0
  26. package/dist/telemetry/posthog-client.js +398 -0
  27. package/dist/telemetry/privacy.js +567 -0
  28. package/dist/telemetry/runtime.js +306 -0
  29. package/dist/telemetry/setup-diagnostics.js +239 -0
  30. package/dist/utils/token-extractor.js +79 -11
  31. package/dist/utils/token-validator.js +25 -9
  32. package/docs/README.md +34 -0
  33. package/docs/telemetry.md +167 -0
  34. package/package.json +12 -2
  35. package/dist/utils/telemetry.js +0 -88
package/dist/cli/index.js CHANGED
@@ -14,6 +14,8 @@ import { registerLogs } from "./cmd-logs.js";
14
14
  import { registerModels } from "./cmd-models.js";
15
15
  import { registerCliTargets } from "./cmd-cli-targets.js";
16
16
  import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
17
+ import { recordApplicationStart, shutdownTelemetryWithin } from "../telemetry/facade.js";
18
+ import { isTelemetryTracingActive } from "../telemetry/runtime.js";
17
19
  const program = new Command();
18
20
  program
19
21
  .name("cc-router")
@@ -63,4 +65,16 @@ if (!process.env["NO_UPDATE_NOTIFIER"] && !process.env["CI"]) {
63
65
  }
64
66
  }).catch(() => { });
65
67
  }
66
- program.parse();
68
+ // Claimed only once a command action runs: Commander exits synchronously for
69
+ // --version/--help before any action, which would otherwise consume the
70
+ // one-time first-start claim without ever draining it.
71
+ program.hook("preAction", () => { recordApplicationStart(); });
72
+ // Short-lived commands get a bounded flush of whatever they queued. Commands
73
+ // that call process.exit() early simply lose their in-flight telemetry. A
74
+ // `start` that is serving requests keeps its runtime: startServer() resolves
75
+ // once it is listening, and its signal handler owns telemetry shutdown.
76
+ void program.parseAsync().finally(() => {
77
+ if (isTelemetryTracingActive())
78
+ return undefined;
79
+ return shutdownTelemetryWithin(500);
80
+ });
@@ -0,0 +1,14 @@
1
+ import { chmodSync, existsSync, mkdirSync } from "fs";
2
+ import { CONFIG_DIR } from "./paths.js";
3
+ const SECRET_DIR_MODE = 0o700;
4
+ export function ensureConfigDir() {
5
+ if (!existsSync(CONFIG_DIR)) {
6
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: SECRET_DIR_MODE });
7
+ return;
8
+ }
9
+ // Tighten an existing dir that may predate this hardening. No-op on Windows.
10
+ try {
11
+ chmodSync(CONFIG_DIR, SECRET_DIR_MODE);
12
+ }
13
+ catch { /* best effort */ }
14
+ }
@@ -1,64 +1,215 @@
1
- import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
2
1
  import { randomUUID } from "crypto";
2
+ import { linkSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
3
+ import { ensureConfigDir } from "./directory.js";
3
4
  import { TELEMETRY_PATH } from "./paths.js";
4
- import { ensureConfigDir } from "./manager.js";
5
+ /** Records written before consent generations existed keep this fixed marker. */
6
+ const LEGACY_GENERATION = "legacy";
7
+ let pendingFirstStartInstallId;
8
+ let firstStartClaimed = false;
9
+ function errorCode(error) {
10
+ return typeof error === "object" && error !== null && typeof error.code === "string"
11
+ ? error.code
12
+ : undefined;
13
+ }
5
14
  function defaultState() {
6
15
  return {
7
- // Opt-in: telemetry stays off until the user explicitly enables it with
8
- // `cc-router telemetry on`. Nothing is sent on first run.
9
- enabled: false,
16
+ enabled: true,
10
17
  installId: randomUUID(),
11
18
  firstRunAt: new Date().toISOString(),
19
+ consentGeneration: randomUUID(),
12
20
  };
13
21
  }
14
- // Read the telemetry state, creating and persisting a fresh one on first run.
15
- // Malformed files are treated as missing so a corrupted file can't crash the CLI.
16
- export function loadTelemetryState() {
17
- if (!existsSync(TELEMETRY_PATH)) {
18
- const state = defaultState();
19
- writeTelemetryState(state);
20
- return state;
22
+ function parseState(raw) {
23
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
24
+ return undefined;
25
+ const candidate = raw;
26
+ if (typeof candidate.installId !== "string" || candidate.installId.length === 0)
27
+ return undefined;
28
+ if (typeof candidate.firstRunAt !== "string" || candidate.firstRunAt.length === 0)
29
+ return undefined;
30
+ if (candidate.enabled !== undefined && typeof candidate.enabled !== "boolean")
31
+ return undefined;
32
+ const hasGeneration = Object.prototype.hasOwnProperty.call(candidate, "consentGeneration");
33
+ if (hasGeneration && (typeof candidate.consentGeneration !== "string" || !candidate.consentGeneration)) {
34
+ return undefined;
21
35
  }
36
+ return {
37
+ enabled: candidate.enabled ?? true,
38
+ installId: candidate.installId,
39
+ firstRunAt: candidate.firstRunAt,
40
+ // A complete pre-generation record is supported and never rewritten on read.
41
+ consentGeneration: hasGeneration ? candidate.consentGeneration : LEGACY_GENERATION,
42
+ };
43
+ }
44
+ /** undefined means missing or malformed (both repairable); an unreadable file throws. */
45
+ function readState() {
46
+ let raw;
22
47
  try {
23
- const raw = JSON.parse(readFileSync(TELEMETRY_PATH, "utf-8"));
24
- // Fill any missing fields to keep the file forward-compatible. Missing
25
- // `enabled` defaults to OFF (opt-in).
26
- const state = {
27
- enabled: raw.enabled ?? false,
28
- installId: raw.installId ?? randomUUID(),
29
- firstRunAt: raw.firstRunAt ?? new Date().toISOString(),
30
- };
31
- if (!raw.installId) {
32
- writeTelemetryState(state);
33
- }
34
- return state;
48
+ raw = readFileSync(TELEMETRY_PATH, "utf8");
49
+ }
50
+ catch (error) {
51
+ if (errorCode(error) === "ENOENT")
52
+ return undefined;
53
+ // Fail closed: a record we cannot read may hold an opt-out, so it is
54
+ // never replaced with an enabled default. Callers treat this as disabled.
55
+ throw new Error(`Telemetry state is unreadable: ${TELEMETRY_PATH}`, { cause: error });
56
+ }
57
+ try {
58
+ return parseState(JSON.parse(raw));
35
59
  }
36
60
  catch {
37
- const state = defaultState();
38
- writeTelemetryState(state);
39
- return state;
61
+ return undefined;
62
+ }
63
+ }
64
+ /** Publish atomically: nobody ever reads a partially written state file. */
65
+ function writeState(state) {
66
+ ensureConfigDir();
67
+ const candidate = `${TELEMETRY_PATH}.${process.pid}.${randomUUID()}.tmp`;
68
+ writeFileSync(candidate, JSON.stringify(state, null, 2), { mode: 0o600 });
69
+ try {
70
+ renameSync(candidate, TELEMETRY_PATH);
71
+ }
72
+ catch (error) {
73
+ const code = errorCode(error);
74
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES")) {
75
+ try {
76
+ unlinkSync(candidate);
77
+ }
78
+ catch { /* the unique candidate is inert */ }
79
+ throw error;
80
+ }
81
+ renameSync(candidate, TELEMETRY_PATH);
40
82
  }
41
83
  }
42
- // Atomic write: .tmp + rename, same pattern as writeAccountsAtomic
43
- export function writeTelemetryState(state) {
84
+ /**
85
+ * Publish the initial state exclusively. A hard link fails with EEXIST when
86
+ * another process published first (possibly an explicit opt-out that landed
87
+ * between our read and our write), and that winner is adopted rather than
88
+ * replaced. Only an existing file that cannot be parsed is repaired.
89
+ */
90
+ function createState() {
44
91
  ensureConfigDir();
45
- const tmp = TELEMETRY_PATH + ".tmp";
46
- writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
47
- renameSync(tmp, TELEMETRY_PATH);
92
+ const fresh = defaultState();
93
+ const candidate = `${TELEMETRY_PATH}.${process.pid}.${randomUUID()}.init.tmp`;
94
+ writeFileSync(candidate, JSON.stringify(fresh, null, 2), { mode: 0o600 });
95
+ let published = false;
96
+ try {
97
+ linkSync(candidate, TELEMETRY_PATH);
98
+ published = true;
99
+ }
100
+ catch (error) {
101
+ if (errorCode(error) !== "EEXIST") {
102
+ try {
103
+ unlinkSync(candidate);
104
+ }
105
+ catch { /* the unique candidate is inert */ }
106
+ throw error;
107
+ }
108
+ }
109
+ finally {
110
+ if (published) {
111
+ try {
112
+ unlinkSync(candidate);
113
+ }
114
+ catch { /* the unique candidate is inert */ }
115
+ }
116
+ }
117
+ if (!published) {
118
+ try {
119
+ unlinkSync(candidate);
120
+ }
121
+ catch { /* the unique candidate is inert */ }
122
+ const winner = readState();
123
+ if (winner)
124
+ return winner;
125
+ // Present but unparseable: not a consent record, so replacing it loses nothing.
126
+ writeState(fresh);
127
+ }
128
+ if (!firstStartClaimed)
129
+ pendingFirstStartInstallId = fresh.installId;
130
+ return fresh;
131
+ }
132
+ // Missing or malformed state is (re)initialized enabled; a supported legacy
133
+ // record is normalized in memory only; an unreadable file throws (see readState).
134
+ export function getTelemetrySnapshot() {
135
+ const state = readState() ?? createState();
136
+ const environmentDisabled = process.env["DO_NOT_TRACK"] === "1" || process.env["CC_ROUTER_TELEMETRY"] === "0";
137
+ return { state, environmentDisabled, enabled: !environmentDisabled && state.enabled };
48
138
  }
49
- // Returns true only if the user has not opted out through any mechanism:
50
- // - DO_NOT_TRACK=1 (de-facto standard)
51
- // - CC_ROUTER_TELEMETRY=0 (project-specific override)
52
- // - `cc-router telemetry off` (persisted enabled: false)
139
+ /** Persist one explicit choice. The fresh UUID is the consent authority. */
140
+ export function updateTelemetryConsent(enabled) {
141
+ const current = readState() ?? defaultState();
142
+ const next = { ...current, enabled, consentGeneration: randomUUID() };
143
+ writeState(next);
144
+ return next;
145
+ }
146
+ /** True only if the user has not opted out through any mechanism. */
53
147
  export function isTelemetryEnabled() {
54
- if (process.env["DO_NOT_TRACK"] === "1")
55
- return false;
56
- if (process.env["CC_ROUTER_TELEMETRY"] === "0")
57
- return false;
58
148
  try {
59
- return loadTelemetryState().enabled;
149
+ return getTelemetrySnapshot().enabled;
60
150
  }
61
151
  catch {
62
152
  return false;
63
153
  }
64
154
  }
155
+ /**
156
+ * Bind a runtime to the consent generation observed at startup. Any later
157
+ * generation means an explicit choice occurred, so the runtime permanently
158
+ * disables itself and must be restarted before telemetry can resume.
159
+ */
160
+ export function createTelemetryConsentGate(getSnapshot = getTelemetrySnapshot, onLatch) {
161
+ let first;
162
+ try {
163
+ first = getSnapshot();
164
+ }
165
+ catch {
166
+ first = undefined;
167
+ }
168
+ const acceptedGeneration = first?.state.consentGeneration;
169
+ let latched = acceptedGeneration === undefined;
170
+ let latchReported = false;
171
+ const latch = () => {
172
+ latched = true;
173
+ if (latchReported)
174
+ return undefined;
175
+ latchReported = true;
176
+ try {
177
+ onLatch?.();
178
+ }
179
+ catch { /* consent never depends on cleanup callbacks */ }
180
+ return undefined;
181
+ };
182
+ return {
183
+ get latched() { return latched; },
184
+ getSnapshot() {
185
+ if (latched)
186
+ return undefined;
187
+ let snapshot;
188
+ try {
189
+ snapshot = getSnapshot();
190
+ }
191
+ catch {
192
+ return latch();
193
+ }
194
+ if (snapshot.state.consentGeneration !== acceptedGeneration)
195
+ return latch();
196
+ return snapshot.enabled ? snapshot : undefined;
197
+ },
198
+ };
199
+ }
200
+ // Claim the one first-start event belonging to fresh state created by this
201
+ // process. Reading an existing state file in a later process never qualifies.
202
+ export function claimTelemetryFirstStart() {
203
+ const pending = pendingFirstStartInstallId;
204
+ if (!pending)
205
+ return undefined;
206
+ pendingFirstStartInstallId = undefined;
207
+ firstStartClaimed = true;
208
+ try {
209
+ const snapshot = getTelemetrySnapshot();
210
+ return snapshot.state.installId === pending ? snapshot : undefined;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ }
@@ -1,5 +1,6 @@
1
1
  import { UsageRefresher } from "../../proxy/usage-refresher.js";
2
2
  import { fetchAnthropicUsage } from "./usage.js";
3
+ import { httpOutcome, recordRuntimeError, recordSafeLog, recordUpstreamStatus, withTelemetrySpan, } from "../../telemetry/facade.js";
3
4
  /**
4
5
  * The Anthropic instantiation of the shared usage scheduler (see
5
6
  * proxy/usage-refresher.ts for the timing/identity guarantees): fetches the
@@ -9,8 +10,41 @@ import { fetchAnthropicUsage } from "./usage.js";
9
10
  export class AnthropicUsageRefresher extends UsageRefresher {
10
11
  constructor(pool, options = {}) {
11
12
  const now = options.now ?? Date.now;
13
+ const fetchUsage = options.fetchUsage ?? fetchAnthropicUsage;
12
14
  super(pool, {
13
- fetchUsage: options.fetchUsage ?? fetchAnthropicUsage,
15
+ fetchUsage: account => withTelemetrySpan("provider.usage_refresh", { provider: "anthropic" }, async (span) => {
16
+ let result;
17
+ try {
18
+ result = await fetchUsage(account);
19
+ }
20
+ catch (error) {
21
+ recordRuntimeError(error, { operation: "provider.usage_refresh", provider: "anthropic" });
22
+ throw error;
23
+ }
24
+ if (!result.ok) {
25
+ if (result.status !== undefined) {
26
+ recordUpstreamStatus("provider.usage_refresh", "anthropic", result.status);
27
+ span.fail({ httpStatusCode: result.status, outcome: httpOutcome(result.status) });
28
+ }
29
+ else {
30
+ // fetchAnthropicUsage resolves transport failures instead of
31
+ // throwing, so the unsampled failure log is emitted here.
32
+ const reason = result.reason === "timeout" ? "timeout"
33
+ : result.reason === "network" ? "network_failure"
34
+ : "other";
35
+ const outcome = reason === "timeout" ? "timeout" : "upstream_error";
36
+ recordSafeLog({
37
+ operation: "provider.usage_refresh",
38
+ provider: "anthropic",
39
+ severity: "error",
40
+ reason,
41
+ outcome,
42
+ });
43
+ span.fail({ outcome });
44
+ }
45
+ }
46
+ return result;
47
+ }),
14
48
  cancelledResult: () => ({ ok: false, reason: "network" }),
15
49
  applyResult: (account, result) => {
16
50
  if (result.ok) {
@@ -1,3 +1,4 @@
1
+ import { classifyExpectedRuntimeFailure, httpOutcome, withTelemetrySpan } from "../telemetry/facade.js";
1
2
  export const ANTHROPIC_MODELS_ENDPOINT = "https://api.anthropic.com/v1/models";
2
3
  export const OPENAI_CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0";
3
4
  export const MODEL_DISCOVERY_TIMEOUT_MS = 3_000;
@@ -12,7 +13,7 @@ export function normalizeModelIds(payload) {
12
13
  return [...ids];
13
14
  }
14
15
  export async function fetchAnthropicModels(account, fetchImpl = fetch) {
15
- return fetchModels(ANTHROPIC_MODELS_ENDPOINT, {
16
+ return fetchModels("anthropic", ANTHROPIC_MODELS_ENDPOINT, {
16
17
  method: "GET",
17
18
  headers: {
18
19
  authorization: `Bearer ${account.tokens.accessToken}`,
@@ -23,7 +24,7 @@ export async function fetchAnthropicModels(account, fetchImpl = fetch) {
23
24
  }, fetchImpl);
24
25
  }
25
26
  export async function fetchOpenAICodexModels(account, fetchImpl = fetch) {
26
- return fetchModels(OPENAI_CODEX_MODELS_ENDPOINT, {
27
+ return fetchModels("openai", OPENAI_CODEX_MODELS_ENDPOINT, {
27
28
  method: "GET",
28
29
  headers: {
29
30
  authorization: `Bearer ${account.accessToken}`,
@@ -32,16 +33,21 @@ export async function fetchOpenAICodexModels(account, fetchImpl = fetch) {
32
33
  signal: AbortSignal.timeout(MODEL_DISCOVERY_TIMEOUT_MS),
33
34
  }, fetchImpl);
34
35
  }
35
- async function fetchModels(url, init, fetchImpl) {
36
- try {
37
- const res = await fetchImpl(url, init);
38
- if (!res.ok)
36
+ function fetchModels(provider, url, init, fetchImpl) {
37
+ return withTelemetrySpan("model.discovery", { provider }, async (span) => {
38
+ try {
39
+ const res = await fetchImpl(url, init);
40
+ if (!res.ok) {
41
+ span.fail({ httpStatusCode: res.status, outcome: httpOutcome(res.status) });
42
+ return [];
43
+ }
44
+ return normalizeModelIds(await res.json());
45
+ }
46
+ catch (error) {
47
+ span.fail({ outcome: classifyExpectedRuntimeFailure(error) === "timeout" ? "timeout" : "upstream_error" });
39
48
  return [];
40
- return normalizeModelIds(await res.json());
41
- }
42
- catch {
43
- return [];
44
- }
49
+ }
50
+ });
45
51
  }
46
52
  function getModelValues(payload) {
47
53
  if (Array.isArray(payload))
@@ -1,4 +1,5 @@
1
1
  import { createOpenAIAccountRecord } from "./account-record.js";
2
+ import { SetupDiagnosticError, classifyHttpSetupFailure, classifyNetworkSetupFailure, } from "../../telemetry/setup-diagnostics.js";
2
3
  const DEFAULT_ISSUER = "https://auth.openai.com";
3
4
  export const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
4
5
  const DEFAULT_SCOPE = "openid profile email offline_access";
@@ -12,13 +13,37 @@ function clientIdOf(opts) {
12
13
  function fetchOf(opts) {
13
14
  return opts.fetchImpl ?? fetch;
14
15
  }
16
+ /** A malformed body is attributed to the stage that received it; the message is unchanged. */
17
+ async function parseJsonAt(stage, res) {
18
+ try {
19
+ return await res.json();
20
+ }
21
+ catch (error) {
22
+ throw new SetupDiagnosticError(error instanceof Error ? error.message : String(error), { stage, reason: "unexpected_response_shape", expected: true, httpStatusCode: res.status }, { cause: error });
23
+ }
24
+ }
15
25
  function parseAccessTokenExpiry(accessToken) {
16
26
  const [, payload] = accessToken.split(".");
17
- if (!payload)
18
- throw new Error("OpenAI access token is not a JWT");
19
- const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
27
+ if (!payload) {
28
+ throw new SetupDiagnosticError("OpenAI access token is not a JWT", {
29
+ stage: "access_token_parse",
30
+ reason: "unexpected_response_shape",
31
+ expected: false,
32
+ });
33
+ }
34
+ let claims;
35
+ try {
36
+ claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
37
+ }
38
+ catch (error) {
39
+ throw new SetupDiagnosticError(error instanceof Error ? error.message : String(error), { stage: "access_token_parse", reason: "unexpected_response_shape", expected: true }, { cause: error });
40
+ }
20
41
  if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) {
21
- throw new Error("OpenAI access token JWT does not contain a numeric exp claim");
42
+ throw new SetupDiagnosticError("OpenAI access token JWT does not contain a numeric exp claim", {
43
+ stage: "access_token_parse",
44
+ reason: "unexpected_response_shape",
45
+ expected: true,
46
+ });
22
47
  }
23
48
  return claims.exp * 1000;
24
49
  }
@@ -32,18 +57,29 @@ async function readError(res) {
32
57
  }
33
58
  export async function requestOpenAIDeviceCode(opts = {}) {
34
59
  const issuer = issuerOf(opts);
35
- const res = await fetchOf(opts)(`${issuer}/api/accounts/deviceauth/usercode`, {
36
- method: "POST",
37
- headers: { "Content-Type": "application/json" },
38
- body: JSON.stringify({ client_id: clientIdOf(opts) }),
39
- });
60
+ let res;
61
+ try {
62
+ res = await fetchOf(opts)(`${issuer}/api/accounts/deviceauth/usercode`, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ client_id: clientIdOf(opts) }),
66
+ });
67
+ }
68
+ catch (error) {
69
+ throw classifyNetworkSetupFailure("device_code_request", error);
70
+ }
40
71
  if (!res.ok) {
41
- throw new Error(`OpenAI device code request failed (${res.status}): ${await readError(res)}`);
72
+ throw classifyHttpSetupFailure("device_code_request", res.status, `OpenAI device code request failed (${res.status}): ${await readError(res)}`);
42
73
  }
43
- const body = await res.json();
74
+ const body = await parseJsonAt("device_code_request", res);
44
75
  const userCode = body.user_code ?? body.usercode;
45
76
  if (!body.device_auth_id || !userCode) {
46
- throw new Error("OpenAI device code response is missing device_auth_id or user_code");
77
+ throw new SetupDiagnosticError("OpenAI device code response is missing device_auth_id or user_code", {
78
+ stage: "device_code_request",
79
+ reason: "unexpected_response_shape",
80
+ expected: true,
81
+ httpStatusCode: res.status,
82
+ });
47
83
  }
48
84
  return {
49
85
  verificationUrl: `${issuer}/codex/device`,
@@ -59,26 +95,37 @@ async function pollAuthorizationCode(opts) {
59
95
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60
96
  const started = now();
61
97
  while (now() - started <= timeoutMs) {
62
- const res = await fetchOf(opts)(`${issuer}/api/accounts/deviceauth/token`, {
63
- method: "POST",
64
- headers: { "Content-Type": "application/json" },
65
- body: JSON.stringify({
66
- device_auth_id: opts.deviceCode.deviceAuthId,
67
- user_code: opts.deviceCode.userCode,
68
- }),
69
- });
98
+ let res;
99
+ try {
100
+ res = await fetchOf(opts)(`${issuer}/api/accounts/deviceauth/token`, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({
104
+ device_auth_id: opts.deviceCode.deviceAuthId,
105
+ user_code: opts.deviceCode.userCode,
106
+ }),
107
+ });
108
+ }
109
+ catch (error) {
110
+ throw classifyNetworkSetupFailure("authorization_polling", error);
111
+ }
70
112
  if (res.ok)
71
- return await res.json();
113
+ return await parseJsonAt("authorization_polling", res);
72
114
  if (res.status !== 403 && res.status !== 404) {
73
- throw new Error(`OpenAI device authorization failed (${res.status}): ${await readError(res)}`);
115
+ throw classifyHttpSetupFailure("authorization_polling", res.status, `OpenAI device authorization failed (${res.status}): ${await readError(res)}`);
74
116
  }
75
117
  await sleep(Math.max(1, opts.deviceCode.intervalSeconds) * 1000);
76
118
  }
77
- throw new Error("OpenAI device authorization timed out");
119
+ throw new SetupDiagnosticError("OpenAI device authorization timed out", {
120
+ stage: "authorization_polling",
121
+ reason: "timeout",
122
+ expected: true,
123
+ });
78
124
  }
79
125
  export async function exchangeOpenAIDeviceCodeForTokens(opts) {
80
126
  const issuer = issuerOf(opts);
81
127
  const code = await pollAuthorizationCode(opts);
128
+ opts.onStageCompleted?.("authorization_polling");
82
129
  const form = new URLSearchParams({
83
130
  grant_type: "authorization_code",
84
131
  code: code.authorization_code,
@@ -86,24 +133,34 @@ export async function exchangeOpenAIDeviceCodeForTokens(opts) {
86
133
  client_id: clientIdOf(opts),
87
134
  code_verifier: code.code_verifier,
88
135
  });
89
- const res = await fetchOf(opts)(`${issuer}/oauth/token`, {
90
- method: "POST",
91
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
92
- body: form.toString(),
93
- });
136
+ let res;
137
+ try {
138
+ res = await fetchOf(opts)(`${issuer}/oauth/token`, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
141
+ body: form.toString(),
142
+ });
143
+ }
144
+ catch (error) {
145
+ throw classifyNetworkSetupFailure("token_exchange", error);
146
+ }
94
147
  if (!res.ok) {
95
- throw new Error(`OpenAI token exchange failed (${res.status}): ${await readError(res)}`);
148
+ throw classifyHttpSetupFailure("token_exchange", res.status, `OpenAI token exchange failed (${res.status}): ${await readError(res)}`);
96
149
  }
97
- const tokens = await res.json();
150
+ const tokens = await parseJsonAt("token_exchange", res);
151
+ opts.onStageCompleted?.("token_exchange");
152
+ const expiresAt = parseAccessTokenExpiry(tokens.access_token);
153
+ opts.onStageCompleted?.("access_token_parse");
98
154
  return {
99
155
  idToken: tokens.id_token,
100
156
  accessToken: tokens.access_token,
101
157
  refreshToken: tokens.refresh_token,
102
- expiresAt: parseAccessTokenExpiry(tokens.access_token),
158
+ expiresAt,
103
159
  };
104
160
  }
105
161
  export async function loginOpenAIWithDeviceCode(opts) {
106
162
  const deviceCode = await requestOpenAIDeviceCode(opts);
163
+ opts.onStageCompleted?.("device_code_request");
107
164
  opts.onDeviceCode?.(deviceCode);
108
165
  const tokens = await exchangeOpenAIDeviceCodeForTokens({ ...opts, deviceCode });
109
166
  return createOpenAIAccountRecord({
@@ -3,6 +3,7 @@ import { createHeaderDeadline } from "../../proxy/transport-timing.js";
3
3
  import { logError } from "../../proxy/logger.js";
4
4
  import { createCorrelationId, formatTransportDiagnostic, safeCauseCode, } from "../../proxy/transport-diagnostics.js";
5
5
  import { DEFAULT_CLIENT_ID } from "./device-oauth.js";
6
+ import { classifyExpectedRuntimeFailure, httpOutcome, recordRuntimeError, recordUpstreamStatus, withTelemetrySpan, } from "../../telemetry/facade.js";
6
7
  const TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
7
8
  const REFRESH_BUFFER_MS = 10 * 60 * 1000;
8
9
  const CHECK_INTERVAL_MS = 5 * 60 * 1000;
@@ -71,7 +72,12 @@ export async function refreshOpenAISubscriptionToken(account) {
71
72
  const existing = refreshLocks.get(account);
72
73
  if (existing)
73
74
  return existing;
74
- const promise = doRefresh(account);
75
+ const promise = withTelemetrySpan("oauth.refresh", { provider: "openai" }, async (span) => {
76
+ const refreshed = await doRefresh(account, span);
77
+ if (!refreshed)
78
+ span.fail();
79
+ return refreshed;
80
+ });
75
81
  refreshLocks.set(account, promise);
76
82
  try {
77
83
  return await promise;
@@ -180,7 +186,7 @@ function logRefreshFailure(account, correlationId, status, error) {
180
186
  causeCode: safeCauseCode(error),
181
187
  }));
182
188
  }
183
- async function doRefresh(account) {
189
+ async function doRefresh(account, span) {
184
190
  const body = new URLSearchParams({
185
191
  grant_type: "refresh_token",
186
192
  refresh_token: account.refreshToken,
@@ -208,6 +214,8 @@ async function doRefresh(account) {
208
214
  }
209
215
  catch { /* intentionally do not retain response bodies */ }
210
216
  markRefreshFailure(account, rejectIsPermanent(res.status, payload));
217
+ recordUpstreamStatus("oauth.refresh", "openai", res.status);
218
+ span.fail({ httpStatusCode: res.status, outcome: httpOutcome(res.status) });
211
219
  logRefreshFailure(account, correlationId, res.status);
212
220
  return false;
213
221
  }
@@ -217,6 +225,8 @@ async function doRefresh(account) {
217
225
  // Network failure (or malformed response body) must resolve to `false`,
218
226
  // exactly like a non-ok HTTP response — never propagate as a rejection.
219
227
  markRefreshFailure(account, false);
228
+ recordRuntimeError(error, { operation: "oauth.refresh", provider: "openai" });
229
+ span.fail({ outcome: classifyExpectedRuntimeFailure(error) === "timeout" ? "timeout" : "upstream_error" });
220
230
  logRefreshFailure(account, correlationId, responseStatus, error);
221
231
  return false;
222
232
  }