coze_lab 0.1.44 → 0.1.46

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,365 +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
- if os.environ.get("COZELOOP_TOKEN_SOURCE") == "agent_config.patToken":
283
- hook_log("upload failure token refresh skipped for agent config patToken")
284
- return None
285
-
286
- creds = _load_credentials()
287
- if not creds or not creds.get("refresh_token"):
288
- hook_log("upload failure token refresh skipped; no local refresh_token")
289
- return None
290
-
291
- cached = creds.get("access_token")
292
- now_ms = int(time.time() * 1000)
293
- last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
294
- if last_ms and now_ms - last_ms < _FORCE_REFRESH_COOLDOWN_MS:
295
- if cached and cached != current_token:
296
- hook_log("forced refresh throttled; reuse newer cached token")
297
- return cached
298
- hook_log(f"forced refresh throttled ageMs={now_ms - last_ms}")
299
- return None
300
-
301
- def _locked_refresh():
302
- locked_creds = _load_credentials()
303
- if not locked_creds or not locked_creds.get("refresh_token"):
304
- hook_log("forced refresh skipped in lock; credentials missing")
305
- return None
306
- locked_cached = locked_creds.get("access_token")
307
- locked_now_ms = int(time.time() * 1000)
308
- locked_last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
309
- if locked_last_ms and locked_now_ms - locked_last_ms < _FORCE_REFRESH_COOLDOWN_MS:
310
- if locked_cached and locked_cached != current_token:
311
- hook_log("forced refresh already done by another process; reuse token")
312
- return locked_cached
313
- hook_log(f"forced refresh skipped in lock ageMs={locked_now_ms - locked_last_ms}")
314
- return None
315
- _save_refresh_state({
316
- "last_forced_refresh_ms": locked_now_ms,
317
- "last_reason": (reason or "upload_failure")[:120],
318
- })
319
- hook_log(f"forced token refresh after upload failure reason={(reason or 'upload_failure')[:120]}")
320
- refreshed = _refresh_token(locked_creds["refresh_token"])
321
- if refreshed:
322
- hook_log("forced token refresh OK")
323
- else:
324
- hook_log("forced token refresh FAILED")
325
- return refreshed
326
-
327
- return _with_refresh_lock(_locked_refresh)
328
-
329
-
330
- def _normalize_api_base_url(url: str) -> str:
331
- base = (url or "").strip().rstrip("/")
332
- if not base:
333
- return base
334
- # 剥除已知 loop 路径后缀,还原纯 API base。
335
- # 关键:含 /api 的变体必须连 /api 整体剥掉,否则残留 /api 会拼出 /api/v1/loop/... → 后端 400。
336
- # 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免误匹配短后缀。
337
- suffixes = (
338
- "/api/v1/loop/opentelemetry/v1/traces",
339
- _OTEL_SUFFIX + "/v1/traces",
340
- "/api/v1/loop/opentelemetry",
341
- _OTEL_SUFFIX,
342
- "/api/v1",
343
- "/v1",
344
- )
345
- for suffix in suffixes:
346
- if base.endswith(suffix):
347
- return base[:-len(suffix)].rstrip("/")
348
- return base
349
-
350
-
351
- def get_api_base_url() -> str:
352
- return _normalize_api_base_url(
353
- os.environ.get("COZELOOP_API_BASE_URL", "")
354
- )
355
-
356
-
357
- def _token_from_credentials():
358
- creds = _load_credentials()
359
- if not creds:
360
- return None
361
- # expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 _REFRESH_THRESHOLD(秒) 比较。
362
- remaining = (creds.get("expires_at", 0) - time.time() * 1000) / 1000
363
- if remaining > _REFRESH_THRESHOLD:
364
- return creds.get("access_token")
365
- if creds.get("refresh_token"):
366
- new_token = _refresh_token(creds["refresh_token"])
367
- if new_token:
368
- return new_token
369
- return None
370
-
371
-
372
- def get_fresh_token():
44
+ def get_fresh_token() -> Optional[str]:
45
+ """Return the token injected by onboard. Do not read local credentials."""
373
46
  env_token = os.environ.get("COZELOOP_API_TOKEN")
374
47
  env_coze_token = os.environ.get("COZE_API_TOKEN")
375
- is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
376
- if os.environ.get("COZELOOP_TOKEN_SOURCE") == "agent_config.patToken" and env_token:
377
- return env_token
378
- if is_cloud:
379
- return env_token or env_coze_token or _token_from_credentials()
380
-
381
- # Local onboard used to write a short-lived access token into cozeloop.env.
382
- # If credentials exist but cannot be refreshed, do not fall back to that stale
383
- # env token; failing closed keeps state from advancing on a known-bad token.
384
- creds = _load_credentials()
385
- if creds:
386
- return _token_from_credentials()
387
- if env_token and not env_coze_token:
388
- token = _token_from_credentials()
389
- if token:
390
- return token
391
- return env_token
392
48
  if env_token:
393
49
  return env_token
394
- if env_coze_token:
50
+ if os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes"):
395
51
  return env_coze_token
396
- return _token_from_credentials()
52
+ return None
53
+
397
54
  # -------------------------------------------------------------------------
398
55
 
399
56
  # --- SDK Import ---
@@ -1094,7 +751,6 @@ def _make_model_message(role: str, content: str = "", tool_calls: list = None,
1094
751
 
1095
752
  def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
1096
753
  history_context: Optional[List[Dict[str, Any]]] = None,
1097
- retry_on_auth_failure: bool = True,
1098
754
  coze_tags_override: Optional[Dict[str, str]] = None) -> Optional[List[Dict[str, Any]]]:
1099
755
  """Send conversation turns to CozeLoop for tracing.
1100
756
 
@@ -1124,14 +780,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1124
780
  f"api_base_url={bool(get_api_base_url())}"
1125
781
  )
1126
782
  print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
1127
- creds = _load_credentials()
1128
- # 云端模式:sandbox 注入的 COZELOOP_WORKSPACE_ID 必须优先于 credentials.json。
1129
- # 否则残留的本地 credentials.json workspace_id 会覆盖云端注入,导致 trace 上报错位。
1130
- is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
1131
- if is_cloud:
1132
- workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or (creds or {}).get("workspace_id") or _DEFAULT_WORKSPACE_ID
1133
- else:
1134
- 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
1135
784
  os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
1136
785
  hook_log(f"workspace_id={workspace_id}")
1137
786
  upload_events: List[str] = []
@@ -1553,19 +1202,6 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1553
1202
 
1554
1203
  if upload_events:
1555
1204
  hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
1556
- if retry_on_auth_failure:
1557
- new_token = force_refresh_token_after_upload_failure(upload_events[-1], token)
1558
- if new_token:
1559
- os.environ["COZELOOP_API_TOKEN"] = new_token
1560
- hook_log("retry upload once after forced token refresh")
1561
- return send_turns_to_cozeloop(
1562
- turns,
1563
- session_id,
1564
- model_name,
1565
- history_context,
1566
- retry_on_auth_failure=False,
1567
- coze_tags_override=coze_tags_override,
1568
- )
1569
1205
  return None
1570
1206
 
1571
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,184 +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.tokenSource === "agent_config.patToken") return currentAuthorization;
191
- if (opts.disableLocalCredentials) return currentAuthorization;
192
- if (opts.force) return _forceRefreshToken(currentAuthorization, opts);
193
- const creds = _loadCreds();
194
- if (!creds) return currentAuthorization; // no creds file, keep as-is
195
- const remaining = (creds.expires_at ?? 0) - Date.now();
196
- if (remaining > _REFRESH_THRESHOLD_MS) return `Bearer ${creds.access_token}`;
197
- if (creds.refresh_token) {
198
- const newToken = await _refreshToken(creds.refresh_token);
199
- if (newToken) return `Bearer ${newToken}`;
200
- }
201
- return null;
27
+ if (opts.force) {
28
+ fileLog(opts.logFile, "[auth] token refresh disabled; keep configured authorization");
29
+ }
30
+ return currentAuthorization;
202
31
  }
203
32
  // ─────────────────────────────────────────────────────────────────────────
204
33
 
@@ -445,19 +274,19 @@ class CozeloopIngestExporter {
445
274
  throw err;
446
275
  }
447
276
  this.headers = { ...this.headers, Authorization: freshAuth };
448
- 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}`);
449
278
  try {
450
279
  await this.postBody(body, true);
451
280
  }
452
281
  catch (retryErr) {
453
- throw new Error(`retry after token refresh failed: ${retryErr?.message || retryErr}`);
282
+ throw new Error(`retry after authorization update failed: ${retryErr?.message || retryErr}`);
454
283
  }
455
284
  }
456
285
  }
457
286
  async refreshAuthorizationAfterFailure(err) {
458
287
  if (typeof this.onAuthFailure !== "function")
459
288
  return null;
460
- 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)}`);
461
290
  try {
462
291
  const current = this.headers?.Authorization || "";
463
292
  const fresh = await this.onAuthFailure(current, err);
@@ -580,8 +409,7 @@ export class CozeloopExporter {
580
409
  this.tracer = null;
581
410
  }
582
411
  } else if (fresh === null) {
583
- this.api.logger.error("[CozeloopTrace] Local credentials exist but token refresh failed; refusing to reuse stale authorization.");
584
- this.config.authorization = "";
412
+ this.api.logger.info("[CozeloopTrace] Token refresh disabled; keeping configured authorization.");
585
413
  }
586
414
  }
587
415
  async refreshAuthAfterUploadFailure(currentAuthorization, err) {
@@ -1012,7 +1012,7 @@ const cozeloopTracePlugin = {
1012
1012
  }
1013
1013
  if (shouldHookEnabled("session_start")) {
1014
1014
  on("session_start", async (event, hookCtx) => {
1015
- // Refresh token if expiring soon (< 10 min)
1015
+ // No local refresh fallback; exporter keeps configured authorization.
1016
1016
  await exporter.refreshAuthIfNeeded();
1017
1017
  const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
1018
1018
  if (config.debug) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cozeloop-trace",
3
3
  "name": "OpenClaw CozeLoop Trace",
4
- "version": "0.1.19",
4
+ "version": "0.1.20",
5
5
  "description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
6
6
  "type": "plugin",
7
7
  "entry": "./dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "disableLocalCredentials": {
54
54
  "type": "boolean",
55
55
  "default": false,
56
- "description": "Disable ~/.cozeloop credentials refresh and use configured authorization only"
56
+ "description": "Deprecated compatibility flag; plugin always uses configured authorization only"
57
57
  },
58
58
  "logFile": {
59
59
  "type": "string",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",