coze_lab 0.1.44 → 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.
- package/README.md +48 -58
- package/index.js +95 -405
- package/package.json +1 -2
- package/scripts/claude-code/cozeloop_hook.py +6 -244
- package/scripts/codex/cozeloop_hook.py +7 -371
- package/scripts/openclaw/dist/cozeloop-exporter.js +11 -183
- package/scripts/openclaw/dist/index.js +1 -1
- package/scripts/openclaw/openclaw.plugin.json +2 -2
- package/scripts/openclaw/package.json +1 -1
- package/scripts/shared/cozeloop_refresh.py +0 -57
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
50
|
+
if os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes"):
|
|
395
51
|
return env_coze_token
|
|
396
|
-
return
|
|
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
|
-
|
|
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
|
|
6
|
+
import { basename } from "path";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
|
-
import {
|
|
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
|
|
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.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
|
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]
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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": "
|
|
56
|
+
"description": "Deprecated compatibility flag; plugin always uses configured authorization only"
|
|
57
57
|
},
|
|
58
58
|
"logFile": {
|
|
59
59
|
"type": "string",
|