coze_lab 0.1.43 → 0.1.45

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.
@@ -35,360 +35,22 @@ import os
35
35
  import sys
36
36
  import hashlib
37
37
  import time
38
- import urllib.request
39
- import urllib.error
40
38
  from datetime import datetime
41
39
  from pathlib import Path
42
40
  from typing import Optional, List, Dict, Any
43
41
 
44
- # --- Token refresh --------------------------------------------------------
45
- _COZELOOP_CLIENT_ID = "08972682140163281554629748278108.app.coze"
46
- _COZE_API = "https://api.coze.cn"
47
- _REFRESH_THRESHOLD = 10 * 60
48
- _FORCE_REFRESH_COOLDOWN_MS = 60 * 1000
49
- _REFRESH_LOCK_STALE = 30
50
- _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
51
- _OTEL_SUFFIX = "/v1/loop/opentelemetry"
42
+ # --- Token resolution -----------------------------------------------------
52
43
 
53
- # 实时 tool span 上报的批量间隔(秒):距上次上报不足此值则本次不发,把已结束的 tool span
54
- # 攒到下次一起发(最多每 5s 一批)。终态(Stop/SubagentStop)不受限,立即收尾。
55
- REALTIME_BATCH_INTERVAL = float(os.environ.get("COZELOOP_REALTIME_BATCH_INTERVAL", "5"))
56
-
57
-
58
- # --- coze-context parsing -------------------------------------------------
59
- # User messages may embed a block like:
60
- # <coze-context>
61
- # account_id: 0
62
- # agent_id: 7644920552473395499
63
- # session_id: 7644919579054997796
64
- # message_id: 04dd5246-...
65
- # </coze-context>
66
- # We parse its key:value pairs and inject them into the trace.
67
-
68
- _COZE_CTX_OPEN = "<coze-context>"
69
- _COZE_CTX_CLOSE = "</coze-context>"
70
-
71
-
72
- def parse_coze_context(text: str) -> Dict[str, str]:
73
- """Extract the LAST <coze-context> block's key:value pairs from text.
74
-
75
- Returns {} if no block is present. Tag keys are prefixed with
76
- 'coze_' by the caller; here we return raw keys as written.
77
- """
78
- if not text or _COZE_CTX_OPEN not in text:
79
- return {}
80
- # Take the last occurrence (latest context wins).
81
- open_idx = text.rfind(_COZE_CTX_OPEN)
82
- close_idx = text.find(_COZE_CTX_CLOSE, open_idx)
83
- if close_idx == -1:
84
- return {}
85
- body = text[open_idx + len(_COZE_CTX_OPEN):close_idx]
86
- # The block may arrive with real newlines, OR with literal backslash-n
87
- # (e.g. when the whole message is an embedded JSON string that was never
88
- # un-escaped). Normalize both forms before splitting into lines.
89
- body = body.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
90
- result: Dict[str, str] = {}
91
- for line in body.splitlines():
92
- line = line.strip()
93
- if not line or ":" not in line:
94
- continue
95
- key, _, value = line.partition(":")
96
- key = key.strip()
97
- value = value.strip()
98
- if key:
99
- result[key] = value
100
- return result
101
-
102
-
103
- def coze_context_tags(text: str) -> Dict[str, str]:
104
- """Return coze-context kv as trace tags, prefixed with 'coze_'."""
105
- return {f"coze_{k}": v for k, v in parse_coze_context(text).items()}
106
-
107
-
108
- def turn_coze_context(turn: Dict[str, Any]) -> Dict[str, str]:
109
- """Extract coze-context from a grouped turn with fallbacks for Codex rollout shapes."""
110
- texts = [turn.get("user_message_text", "")]
111
- for msg in turn.get("input_messages", []):
112
- if isinstance(msg, dict):
113
- texts.append(str(msg.get("content", "")))
114
- user_payload = turn.get("user_message")
115
- if isinstance(user_payload, dict):
116
- texts.append(extract_message_content_text(user_payload))
117
- for text in texts:
118
- ctx = parse_coze_context(text)
119
- if ctx:
120
- return ctx
121
- return {}
122
-
123
-
124
- # --- trace upload failure / logid capture ---------------------------------
125
- def _extract_logid(msg: str) -> str:
126
- """Pull the server logid out of an SDK error message, if present.
127
-
128
- SDK failure messages embed it as 'logid=XXXX' (sometimes within brackets).
129
- """
130
- if not msg:
131
- return ""
132
- marker = "logid="
133
- idx = msg.find(marker)
134
- if idx == -1:
135
- return ""
136
- rest = msg[idx + len(marker):]
137
- logid = []
138
- for ch in rest:
139
- if ch.isalnum():
140
- logid.append(ch)
141
- else:
142
- break
143
- return "".join(logid)
144
-
145
-
146
- def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
147
- """Return a trace_finish_event_processor that surfaces failures + logid.
148
-
149
- The CozeLoop SDK calls this for each flush event; on failure we print the
150
- server logid to stderr so it can be handed to platform support for tracing
151
- the root cause (e.g. via `bytedcli log get-logid-log <logid>`).
152
- """
153
- def _processor(info):
154
- try:
155
- if not getattr(info, "is_event_fail", False):
156
- hook_log("upload success")
157
- return
158
- detail = getattr(info, "detail_msg", "") or ""
159
- if upload_events is not None:
160
- upload_events.append(detail or "trace export failed")
161
- logid = _extract_logid(detail)
162
- if logid:
163
- hook_log(f"upload failed logid={logid} detail={detail[:500]}")
164
- print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
165
- else:
166
- hook_log(f"upload failed detail={detail[:500]}")
167
- print(f"[CozeLoop] 上报失败: {detail[:300]}", file=sys.stderr)
168
- except Exception:
169
- pass
170
- return _processor
171
-
172
-
173
-
174
- def _get_credentials_path() -> Path:
175
- return Path.home() / ".cozeloop" / "credentials.json"
176
-
177
- def _get_refresh_state_path() -> Path:
178
- return Path.home() / ".cozeloop" / "refresh-state.json"
179
-
180
- def _get_refresh_lock_path() -> Path:
181
- return Path.home() / ".cozeloop" / "refresh-state.lock"
182
-
183
- def _load_credentials():
184
- path = _get_credentials_path()
185
- if not path.exists():
186
- return None
187
- try:
188
- return json.loads(path.read_text())
189
- except Exception:
190
- return None
191
-
192
- def _save_credentials(creds):
193
- path = _get_credentials_path()
194
- path.parent.mkdir(parents=True, exist_ok=True)
195
- path.write_text(json.dumps(creds, indent=2))
196
- os.chmod(path, 0o600)
197
-
198
- def _load_refresh_state():
199
- path = _get_refresh_state_path()
200
- if not path.exists():
201
- return {}
202
- try:
203
- return json.loads(path.read_text())
204
- except Exception:
205
- return {}
206
-
207
- def _save_refresh_state(patch):
208
- path = _get_refresh_state_path()
209
- try:
210
- path.parent.mkdir(parents=True, exist_ok=True)
211
- state = _load_refresh_state()
212
- state.update(patch)
213
- path.write_text(json.dumps(state, indent=2))
214
- os.chmod(path, 0o600)
215
- except Exception:
216
- pass
217
-
218
- def _with_refresh_lock(fn):
219
- lock_path = _get_refresh_lock_path()
220
- lock_path.parent.mkdir(parents=True, exist_ok=True)
221
- for _ in range(24):
222
- fd = None
223
- try:
224
- fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
225
- try:
226
- return fn()
227
- finally:
228
- try:
229
- os.close(fd)
230
- except Exception:
231
- pass
232
- try:
233
- lock_path.unlink()
234
- except Exception:
235
- pass
236
- except FileExistsError:
237
- try:
238
- if time.time() - lock_path.stat().st_mtime > _REFRESH_LOCK_STALE:
239
- lock_path.unlink()
240
- continue
241
- except Exception:
242
- pass
243
- time.sleep(0.25)
244
- hook_log("forced refresh skipped because refresh lock is busy")
245
- return None
246
-
247
- def _refresh_token(refresh_tok: str):
248
- try:
249
- payload = json.dumps({
250
- "grant_type": "refresh_token",
251
- "client_id": _COZELOOP_CLIENT_ID,
252
- "refresh_token": refresh_tok,
253
- }).encode()
254
- req = urllib.request.Request(
255
- f"{_COZE_API}/api/permission/oauth2/token",
256
- data=payload,
257
- headers={
258
- "Content-Type": "application/json",
259
- },
260
- )
261
- with urllib.request.urlopen(req, timeout=10) as resp:
262
- data = json.loads(resp.read())
263
- if data.get("access_token"):
264
- existing = _load_credentials() or {}
265
- creds = {
266
- "access_token": data["access_token"],
267
- "refresh_token": data.get("refresh_token", refresh_tok),
268
- "expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
269
- "workspace_id": existing.get("workspace_id", ""),
270
- }
271
- _save_credentials(creds)
272
- return creds["access_token"]
273
- except Exception:
274
- pass
275
- return None
276
-
277
- def force_refresh_token_after_upload_failure(reason: str = "upload_failure", current_token: Optional[str] = None):
278
- is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
279
- if is_cloud:
280
- hook_log("upload failure token refresh skipped in cloud mode")
281
- return None
282
-
283
- creds = _load_credentials()
284
- if not creds or not creds.get("refresh_token"):
285
- hook_log("upload failure token refresh skipped; no local refresh_token")
286
- return None
287
-
288
- cached = creds.get("access_token")
289
- now_ms = int(time.time() * 1000)
290
- last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
291
- if last_ms and now_ms - last_ms < _FORCE_REFRESH_COOLDOWN_MS:
292
- if cached and cached != current_token:
293
- hook_log("forced refresh throttled; reuse newer cached token")
294
- return cached
295
- hook_log(f"forced refresh throttled ageMs={now_ms - last_ms}")
296
- return None
297
-
298
- def _locked_refresh():
299
- locked_creds = _load_credentials()
300
- if not locked_creds or not locked_creds.get("refresh_token"):
301
- hook_log("forced refresh skipped in lock; credentials missing")
302
- return None
303
- locked_cached = locked_creds.get("access_token")
304
- locked_now_ms = int(time.time() * 1000)
305
- locked_last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
306
- if locked_last_ms and locked_now_ms - locked_last_ms < _FORCE_REFRESH_COOLDOWN_MS:
307
- if locked_cached and locked_cached != current_token:
308
- hook_log("forced refresh already done by another process; reuse token")
309
- return locked_cached
310
- hook_log(f"forced refresh skipped in lock ageMs={locked_now_ms - locked_last_ms}")
311
- return None
312
- _save_refresh_state({
313
- "last_forced_refresh_ms": locked_now_ms,
314
- "last_reason": (reason or "upload_failure")[:120],
315
- })
316
- hook_log(f"forced token refresh after upload failure reason={(reason or 'upload_failure')[:120]}")
317
- refreshed = _refresh_token(locked_creds["refresh_token"])
318
- if refreshed:
319
- hook_log("forced token refresh OK")
320
- else:
321
- hook_log("forced token refresh FAILED")
322
- return refreshed
323
-
324
- return _with_refresh_lock(_locked_refresh)
325
-
326
-
327
- def _normalize_api_base_url(url: str) -> str:
328
- base = (url or "").strip().rstrip("/")
329
- if not base:
330
- return base
331
- # 剥除已知 loop 路径后缀,还原纯 API base。
332
- # 关键:含 /api 的变体必须连 /api 整体剥掉,否则残留 /api 会拼出 /api/v1/loop/... → 后端 400。
333
- # 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免误匹配短后缀。
334
- suffixes = (
335
- "/api/v1/loop/opentelemetry/v1/traces",
336
- _OTEL_SUFFIX + "/v1/traces",
337
- "/api/v1/loop/opentelemetry",
338
- _OTEL_SUFFIX,
339
- "/api/v1",
340
- "/v1",
341
- )
342
- for suffix in suffixes:
343
- if base.endswith(suffix):
344
- return base[:-len(suffix)].rstrip("/")
345
- return base
346
-
347
-
348
- def get_api_base_url() -> str:
349
- return _normalize_api_base_url(
350
- os.environ.get("COZELOOP_API_BASE_URL", "")
351
- )
352
-
353
-
354
- def _token_from_credentials():
355
- creds = _load_credentials()
356
- if not creds:
357
- return None
358
- # expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 _REFRESH_THRESHOLD(秒) 比较。
359
- remaining = (creds.get("expires_at", 0) - time.time() * 1000) / 1000
360
- if remaining > _REFRESH_THRESHOLD:
361
- return creds.get("access_token")
362
- if creds.get("refresh_token"):
363
- new_token = _refresh_token(creds["refresh_token"])
364
- if new_token:
365
- return new_token
366
- return None
367
-
368
-
369
- def get_fresh_token():
44
+ def get_fresh_token() -> Optional[str]:
45
+ """Return the token injected by onboard. Do not read local credentials."""
370
46
  env_token = os.environ.get("COZELOOP_API_TOKEN")
371
47
  env_coze_token = os.environ.get("COZE_API_TOKEN")
372
- is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
373
- if is_cloud:
374
- return env_token or env_coze_token or _token_from_credentials()
375
-
376
- # Local onboard used to write a short-lived access token into cozeloop.env.
377
- # If credentials exist but cannot be refreshed, do not fall back to that stale
378
- # env token; failing closed keeps state from advancing on a known-bad token.
379
- creds = _load_credentials()
380
- if creds:
381
- return _token_from_credentials()
382
- if env_token and not env_coze_token:
383
- token = _token_from_credentials()
384
- if token:
385
- return token
386
- return env_token
387
48
  if env_token:
388
49
  return env_token
389
- if env_coze_token:
50
+ if os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes"):
390
51
  return env_coze_token
391
- return _token_from_credentials()
52
+ return None
53
+
392
54
  # -------------------------------------------------------------------------
393
55
 
394
56
  # --- SDK Import ---
@@ -1089,7 +751,6 @@ def _make_model_message(role: str, content: str = "", tool_calls: list = None,
1089
751
 
1090
752
  def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
1091
753
  history_context: Optional[List[Dict[str, Any]]] = None,
1092
- retry_on_auth_failure: bool = True,
1093
754
  coze_tags_override: Optional[Dict[str, str]] = None) -> Optional[List[Dict[str, Any]]]:
1094
755
  """Send conversation turns to CozeLoop for tracing.
1095
756
 
@@ -1119,14 +780,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1119
780
  f"api_base_url={bool(get_api_base_url())}"
1120
781
  )
1121
782
  print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
1122
- creds = _load_credentials()
1123
- # 云端模式:sandbox 注入的 COZELOOP_WORKSPACE_ID 必须优先于 credentials.json。
1124
- # 否则残留的本地 credentials.json workspace_id 会覆盖云端注入,导致 trace 上报错位。
1125
- is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
1126
- if is_cloud:
1127
- workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or (creds or {}).get("workspace_id") or _DEFAULT_WORKSPACE_ID
1128
- else:
1129
- workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
783
+ workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
1130
784
  os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
1131
785
  hook_log(f"workspace_id={workspace_id}")
1132
786
  upload_events: List[str] = []
@@ -1548,19 +1202,6 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1548
1202
 
1549
1203
  if upload_events:
1550
1204
  hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
1551
- if retry_on_auth_failure:
1552
- new_token = force_refresh_token_after_upload_failure(upload_events[-1], token)
1553
- if new_token:
1554
- os.environ["COZELOOP_API_TOKEN"] = new_token
1555
- hook_log("retry upload once after forced token refresh")
1556
- return send_turns_to_cozeloop(
1557
- turns,
1558
- session_id,
1559
- model_name,
1560
- history_context,
1561
- retry_on_auth_failure=False,
1562
- coze_tags_override=coze_tags_override,
1563
- )
1564
1205
  return None
1565
1206
 
1566
1207
  return ctx
@@ -3,10 +3,9 @@ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trac
3
3
  import { Resource } from "@opentelemetry/resources";
4
4
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/semantic-conventions";
5
5
  import { hostname } from "os";
6
- import { basename, join } from "path";
6
+ import { basename } from "path";
7
7
  import { createRequire } from "node:module";
8
- import { readFileSync, writeFileSync, mkdirSync, appendFileSync, openSync, closeSync, unlinkSync, statSync } from "fs";
9
- import { homedir } from "os";
8
+ import { appendFileSync } from "fs";
10
9
  import http from "http";
11
10
  import https from "https";
12
11
 
@@ -21,183 +20,14 @@ const CLIENT_USER_AGENT = {
21
20
  source: "openapi",
22
21
  };
23
22
 
24
- // ── Token refresh helpers ─────────────────────────────────────────────────
25
- const _CLIENT_ID = "08972682140163281554629748278108.app.coze";
23
+ // ── Token helpers ──────────────────────────────────────────────────────────
26
24
  const _COZE_API = "https://api.coze.cn";
27
- const _REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
28
- const _FORCE_REFRESH_COOLDOWN_MS = 60 * 1000;
29
- const _REFRESH_LOCK_STALE_MS = 30 * 1000;
30
- const _CREDS_PATH = join(homedir(), ".cozeloop", "credentials.json");
31
- const _REFRESH_STATE_PATH = join(homedir(), ".cozeloop", "refresh-state.json");
32
- const _REFRESH_LOCK_PATH = join(homedir(), ".cozeloop", "refresh-state.lock");
33
-
34
- function _loadCreds() {
35
- try { return JSON.parse(readFileSync(_CREDS_PATH, "utf8")); }
36
- catch { return null; }
37
- }
38
-
39
- function _saveCreds(c) {
40
- try {
41
- mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
42
- writeFileSync(_CREDS_PATH, JSON.stringify(c, null, 2), { mode: 0o600 });
43
- } catch { /* non-fatal */ }
44
- }
45
-
46
- function _loadRefreshState() {
47
- try { return JSON.parse(readFileSync(_REFRESH_STATE_PATH, "utf8")); }
48
- catch { return {}; }
49
- }
50
-
51
- function _saveRefreshState(patch) {
52
- try {
53
- mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
54
- const state = { ..._loadRefreshState(), ...patch };
55
- writeFileSync(_REFRESH_STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
56
- } catch { /* non-fatal */ }
57
- }
58
-
59
- function _authorizationFromCreds(creds) {
60
- return creds?.access_token ? `Bearer ${creds.access_token}` : null;
61
- }
62
-
63
- function _sleep(ms) {
64
- return new Promise(resolve => setTimeout(resolve, ms));
65
- }
66
-
67
- async function _withRefreshLock(logFile, fn) {
68
- mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
69
- for (let i = 0; i < 24; i++) {
70
- let fd = null;
71
- try {
72
- fd = openSync(_REFRESH_LOCK_PATH, "wx", 0o600);
73
- try {
74
- return await fn();
75
- }
76
- finally {
77
- try { if (fd !== null) closeSync(fd); } catch { /* ignore */ }
78
- try { unlinkSync(_REFRESH_LOCK_PATH); } catch { /* ignore */ }
79
- }
80
- }
81
- catch (err) {
82
- if (err?.code !== "EEXIST") {
83
- fileLog(logFile, `[auth] refresh lock error=${err?.message || err}`);
84
- return null;
85
- }
86
- try {
87
- const ageMs = Date.now() - statSync(_REFRESH_LOCK_PATH).mtimeMs;
88
- if (ageMs > _REFRESH_LOCK_STALE_MS) {
89
- unlinkSync(_REFRESH_LOCK_PATH);
90
- continue;
91
- }
92
- } catch { /* ignore */ }
93
- await _sleep(250);
94
- }
95
- }
96
- fileLog(logFile, "[auth] refresh lock busy; skip forced refresh");
97
- return null;
98
- }
99
-
100
- async function _refreshToken(refreshTok) {
101
- return new Promise((resolve) => {
102
- const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
103
- const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
104
- method: "POST",
105
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
106
- }, (res) => {
107
- let buf = "";
108
- res.on("data", c => buf += c);
109
- res.on("end", () => {
110
- try {
111
- const d = JSON.parse(buf);
112
- if (d.access_token) {
113
- const existing = _loadCreds() ?? {};
114
- const creds = {
115
- access_token: d.access_token,
116
- refresh_token: d.refresh_token ?? refreshTok,
117
- expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
118
- workspace_id: existing.workspace_id ?? "",
119
- };
120
- _saveCreds(creds);
121
- resolve(creds.access_token);
122
- } else { resolve(null); }
123
- } catch { resolve(null); }
124
- });
125
- });
126
- req.on("error", () => resolve(null));
127
- req.setTimeout(10000, () => { req.destroy(); resolve(null); });
128
- req.write(body);
129
- req.end();
130
- });
131
- }
132
-
133
- async function _forceRefreshToken(currentAuthorization, opts = {}) {
134
- const logFile = opts.logFile;
135
- const creds = _loadCreds();
136
- if (!creds?.refresh_token) {
137
- fileLog(logFile, "[auth] upload failed but no local refresh_token is available");
138
- return null;
139
- }
140
-
141
- const cachedAuth = _authorizationFromCreds(creds);
142
- const now = Date.now();
143
- const state = _loadRefreshState();
144
- const lastForced = Number(state.last_forced_refresh_ms || 0);
145
- if (lastForced && now - lastForced < _FORCE_REFRESH_COOLDOWN_MS) {
146
- if (cachedAuth && cachedAuth !== currentAuthorization) {
147
- fileLog(logFile, `[auth] forced refresh throttled; reuse newer cached token tokenLength=${cachedAuth.length}`);
148
- return cachedAuth;
149
- }
150
- fileLog(logFile, `[auth] forced refresh throttled ageMs=${now - lastForced}`);
151
- return null;
152
- }
153
-
154
- return _withRefreshLock(logFile, async () => {
155
- const lockedCreds = _loadCreds();
156
- if (!lockedCreds?.refresh_token) {
157
- fileLog(logFile, "[auth] forced refresh skipped; credentials disappeared");
158
- return null;
159
- }
160
- const lockedCachedAuth = _authorizationFromCreds(lockedCreds);
161
- const lockedState = _loadRefreshState();
162
- const lockedLastForced = Number(lockedState.last_forced_refresh_ms || 0);
163
- const lockedNow = Date.now();
164
- if (lockedLastForced && lockedNow - lockedLastForced < _FORCE_REFRESH_COOLDOWN_MS) {
165
- if (lockedCachedAuth && lockedCachedAuth !== currentAuthorization) {
166
- fileLog(logFile, `[auth] forced refresh already done by another process; reuse tokenLength=${lockedCachedAuth.length}`);
167
- return lockedCachedAuth;
168
- }
169
- fileLog(logFile, `[auth] forced refresh skipped in lock ageMs=${lockedNow - lockedLastForced}`);
170
- return null;
171
- }
172
-
173
- _saveRefreshState({
174
- last_forced_refresh_ms: lockedNow,
175
- last_reason: String(opts.reason || "upload_failure").slice(0, 120),
176
- });
177
- fileLog(logFile, `[auth] forced token refresh after local upload failure reason=${String(opts.reason || "upload_failure").slice(0, 120)}`);
178
- const newToken = await _refreshToken(lockedCreds.refresh_token);
179
- if (!newToken) {
180
- fileLog(logFile, "[auth] forced token refresh FAILED");
181
- return null;
182
- }
183
- const freshAuth = `Bearer ${newToken}`;
184
- fileLog(logFile, `[auth] forced token refresh OK tokenLength=${freshAuth.length}`);
185
- return freshAuth;
186
- });
187
- }
188
25
 
189
26
  async function getRefreshedToken(currentAuthorization, opts = {}) {
190
- if (opts.disableLocalCredentials) return currentAuthorization;
191
- if (opts.force) return _forceRefreshToken(currentAuthorization, opts);
192
- const creds = _loadCreds();
193
- if (!creds) return currentAuthorization; // no creds file, keep as-is
194
- const remaining = (creds.expires_at ?? 0) - Date.now();
195
- if (remaining > _REFRESH_THRESHOLD_MS) return `Bearer ${creds.access_token}`;
196
- if (creds.refresh_token) {
197
- const newToken = await _refreshToken(creds.refresh_token);
198
- if (newToken) return `Bearer ${newToken}`;
199
- }
200
- return null;
27
+ if (opts.force) {
28
+ fileLog(opts.logFile, "[auth] token refresh disabled; keep configured authorization");
29
+ }
30
+ return currentAuthorization;
201
31
  }
202
32
  // ─────────────────────────────────────────────────────────────────────────
203
33
 
@@ -444,19 +274,19 @@ class CozeloopIngestExporter {
444
274
  throw err;
445
275
  }
446
276
  this.headers = { ...this.headers, Authorization: freshAuth };
447
- fileLog(this.logFile, `[ingest] retry after token refresh url=${this.url} spans=${body.spans.length}`);
277
+ fileLog(this.logFile, `[ingest] retry after authorization update url=${this.url} spans=${body.spans.length}`);
448
278
  try {
449
279
  await this.postBody(body, true);
450
280
  }
451
281
  catch (retryErr) {
452
- throw new Error(`retry after token refresh failed: ${retryErr?.message || retryErr}`);
282
+ throw new Error(`retry after authorization update failed: ${retryErr?.message || retryErr}`);
453
283
  }
454
284
  }
455
285
  }
456
286
  async refreshAuthorizationAfterFailure(err) {
457
287
  if (typeof this.onAuthFailure !== "function")
458
288
  return null;
459
- fileLog(this.logFile, `[auth] local upload failure triggers refresh attempt err=${String(err?.message || err).slice(0, 300)}`);
289
+ fileLog(this.logFile, `[auth] upload failure; token refresh disabled err=${String(err?.message || err).slice(0, 300)}`);
460
290
  try {
461
291
  const current = this.headers?.Authorization || "";
462
292
  const fresh = await this.onAuthFailure(current, err);
@@ -565,6 +395,7 @@ export class CozeloopExporter {
565
395
  async refreshAuthIfNeeded() {
566
396
  const fresh = await getRefreshedToken(this.config.authorization, {
567
397
  disableLocalCredentials: this.config.disableLocalCredentials,
398
+ tokenSource: this.config.tokenSource,
568
399
  });
569
400
  if (fresh && fresh !== this.config.authorization) {
570
401
  this.api.logger.info("[CozeloopTrace] Token refreshed, re-initializing exporter...");
@@ -578,13 +409,13 @@ export class CozeloopExporter {
578
409
  this.tracer = null;
579
410
  }
580
411
  } else if (fresh === null) {
581
- this.api.logger.error("[CozeloopTrace] Local credentials exist but token refresh failed; refusing to reuse stale authorization.");
582
- this.config.authorization = "";
412
+ this.api.logger.info("[CozeloopTrace] Token refresh disabled; keeping configured authorization.");
583
413
  }
584
414
  }
585
415
  async refreshAuthAfterUploadFailure(currentAuthorization, err) {
586
416
  const fresh = await getRefreshedToken(currentAuthorization || this.config.authorization, {
587
417
  disableLocalCredentials: this.config.disableLocalCredentials,
418
+ tokenSource: this.config.tokenSource,
588
419
  force: true,
589
420
  reason: err?.message || "upload_failure",
590
421
  logFile: this.config.logFile,
@@ -540,6 +540,7 @@ const cozeloopTracePlugin = {
540
540
  batchInterval: pluginConfig.batchInterval || 500,
541
541
  enabledHooks: pluginConfig.enabledHooks,
542
542
  disableLocalCredentials: pluginConfig.disableLocalCredentials === true,
543
+ tokenSource: pluginConfig.tokenSource,
543
544
  logFile: pluginConfig.logFile,
544
545
  };
545
546
  const exporter = new CozeloopExporter(api, config);
@@ -1011,7 +1012,7 @@ const cozeloopTracePlugin = {
1011
1012
  }
1012
1013
  if (shouldHookEnabled("session_start")) {
1013
1014
  on("session_start", async (event, hookCtx) => {
1014
- // Refresh token if expiring soon (< 10 min)
1015
+ // No local refresh fallback; exporter keeps configured authorization.
1015
1016
  await exporter.refreshAuthIfNeeded();
1016
1017
  const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
1017
1018
  if (config.debug) {