@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.
- package/CHANGELOG.md +18 -0
- package/Dockerfile +1 -0
- package/README.md +1 -1
- package/dist/cli/cmd-accounts.js +116 -65
- package/dist/cli/cmd-setup.js +100 -30
- package/dist/cli/cmd-status.js +14 -2
- package/dist/cli/cmd-telemetry.js +43 -32
- package/dist/cli/index.js +15 -1
- package/dist/config/directory.js +14 -0
- package/dist/config/telemetry.js +192 -41
- package/dist/providers/anthropic/usage-refresher.js +35 -1
- package/dist/providers/model-discovery.js +17 -11
- package/dist/providers/openai/device-oauth.js +88 -31
- package/dist/providers/openai/token-refresher.js +12 -2
- package/dist/providers/openai/usage-fetch.js +19 -2
- package/dist/proxy/anthropic-messages-route.js +144 -5
- package/dist/proxy/anthropic-proxy.js +10 -0
- package/dist/proxy/anthropic-response-capture.js +6 -19
- package/dist/proxy/openai-ingress.js +98 -1
- package/dist/proxy/server.js +35 -22
- package/dist/proxy/token-refresher.js +12 -2
- package/dist/proxy/usage-capture.js +41 -4
- package/dist/telemetry/contracts.js +129 -0
- package/dist/telemetry/facade.js +654 -0
- package/dist/telemetry/otel-exporters.js +289 -0
- package/dist/telemetry/posthog-client.js +398 -0
- package/dist/telemetry/privacy.js +567 -0
- package/dist/telemetry/runtime.js +306 -0
- package/dist/telemetry/setup-diagnostics.js +239 -0
- package/dist/utils/token-extractor.js +79 -11
- package/dist/utils/token-validator.js +25 -9
- package/docs/README.md +34 -0
- package/docs/telemetry.md +167 -0
- package/package.json +12 -2
- 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
|
-
|
|
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
|
+
}
|
package/dist/config/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
113
|
+
return await parseJsonAt("authorization_polling", res);
|
|
72
114
|
if (res.status !== 403 && res.status !== 404) {
|
|
73
|
-
throw
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
148
|
+
throw classifyHttpSetupFailure("token_exchange", res.status, `OpenAI token exchange failed (${res.status}): ${await readError(res)}`);
|
|
96
149
|
}
|
|
97
|
-
const tokens = await res
|
|
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
|
|
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 =
|
|
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
|
}
|