codebee 0.1.23 → 0.1.25

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.
@@ -1,188 +1,188 @@
1
- # -*- coding: utf-8 -*-
2
- """端口/进程扫描与项目归属推断(借鉴 leftopen 38★)。
3
-
4
- 三个机制:
5
- - **端口→进程→项目归属推断**:netstat/ss 拿端口→PID,再从进程 CWD 向上走找
6
- .git/package.json 等项目根——知道该进程属于哪个项目/用户
7
- - **本地 vs LAN 区分**:127.0.0.1 与 0.0.0.0/LAN 的安全边界
8
- - **温和关闭**:SIGTERM only(Windows taskkill /PID 不带 /F),关闭前重验 PID
9
-
10
- 跨平台(Windows netstat + PowerShell / POSIX ss + /proc)纯标准库。
11
- """
12
- from __future__ import annotations
13
-
14
- import json
15
- import logging
16
- import os
17
- import re
18
- import subprocess
19
-
20
- log = logging.getLogger(__name__)
21
-
22
- _PROJECT_MARKERS = (".git", "package.json", "pyproject.toml", "Cargo.toml",
23
- "go.mod", "pom.xml", "build.gradle", ".codebee")
24
- _SYSTEM_PROCS = {"system", "idle", "kernel", "svchost", "launchd", "init",
25
- "systemd", "sshd", "explorer", "finder", "windowserver"}
26
-
27
-
28
- def _project_from_cwd(cwd):
29
- """从 CWD 向上走到项目根(含标志文件的最深目录名)。"""
30
- if not cwd:
31
- return ""
32
- cur = os.path.abspath(cwd)
33
- origin = cur
34
- home = os.path.abspath(os.path.expanduser("~"))
35
- while cur and cur != os.path.dirname(cur):
36
- # 家目录及以上散落的标志文件(package.json 等)是环境噪音不是项目;
37
- # 只有进程就跑在家目录本身时才认它为归属。
38
- if cur == home and cur != origin:
39
- return ""
40
- for marker in _PROJECT_MARKERS:
41
- if os.path.exists(os.path.join(cur, marker)):
42
- return os.path.basename(cur)
43
- cur = os.path.dirname(cur)
44
- return ""
45
-
46
-
47
- def _proc_detail(pid):
48
- """POSIX:读 /proc/<pid>/cwd 和 comm 获取进程详情。"""
49
- detail = {"name": "", "project": ""}
50
- try:
51
- cwd = os.readlink("/proc/%d/cwd" % pid)
52
- with open("/proc/%d/comm" % pid) as f:
53
- detail["name"] = f.read().strip()
54
- proj = _project_from_cwd(cwd)
55
- if proj:
56
- detail["project"] = proj
57
- except (OSError, PermissionError):
58
- pass
59
- return detail
60
-
61
-
62
- def _parse_ss(output):
63
- """解析 ss/netstat -tlnp 输出为端口条目列表。"""
64
- ports, pid_map = [], {}
65
- for ln in output.splitlines():
66
- m = re.search(r":(\d{4,5})\s", ln)
67
- if not m:
68
- continue
69
- port = int(m.group(1))
70
- pm = re.search(r"pid=(\d+)", ln)
71
- pid = int(pm.group(1)) if pm else 0
72
- local_only = "127.0.0.1" in ln or "[::1]" in ln or "localhost" in ln
73
- if pid and pid not in pid_map:
74
- pid_map[pid] = _proc_detail(pid)
75
- ports.append({"port": port, "pid": pid, "local_only": local_only,
76
- "process": pid_map.get(pid, {}).get("name", ""),
77
- "project": pid_map.get(pid, {}).get("project", "")})
78
- dedup = {}
79
- for p in ports:
80
- key = p["port"]
81
- if key not in dedup or (p["pid"] and not dedup[key]["pid"]):
82
- dedup[key] = p
83
- return sorted(dedup.values(), key=lambda x: x["port"])
84
-
85
-
86
- def _ports_linux():
87
- """Linux/macOS:ss 优先,netstat 兜底。"""
88
- try:
89
- proc = subprocess.run(["ss", "-tlnp"], capture_output=True, timeout=15)
90
- if proc.returncode == 0 and proc.stdout:
91
- return _parse_ss(proc.stdout.decode("utf-8", "replace"))
92
- except (OSError, subprocess.TimeoutExpired):
93
- pass
94
- try:
95
- proc = subprocess.run(["netstat", "-tlnp"], capture_output=True, timeout=15)
96
- if proc.returncode == 0 and proc.stdout:
97
- return _parse_ss(proc.stdout.decode("utf-8", "replace"))
98
- except (OSError, subprocess.TimeoutExpired):
99
- pass
100
- return []
101
-
102
-
103
- def _ports_windows(with_names=True):
104
- """Windows:netstat -ano -p TCP 拿端口,PowerShell 一次性补进程名。"""
105
- try:
106
- proc = subprocess.run(
107
- ["C:\\Windows\\System32\\netstat.exe", "-ano", "-p", "TCP"],
108
- capture_output=True, timeout=15)
109
- except (OSError, subprocess.TimeoutExpired):
110
- return []
111
- ports = []
112
- for ln in proc.stdout.decode("utf-8", "replace").splitlines():
113
- parts = ln.split()
114
- if len(parts) < 5 or parts[0] != "TCP" or parts[3] != "LISTENING":
115
- continue
116
- local = parts[1]
117
- pid_str = parts[4]
118
- if not pid_str.isdigit():
119
- continue
120
- addr, _, port_str = local.rpartition(":")
121
- if not port_str.isdigit():
122
- continue
123
- ports.append({"port": int(port_str), "pid": int(pid_str),
124
- "local_only": addr in ("127.0.0.1", "[::1]", "::1"),
125
- "process": "", "project": ""})
126
- if with_names and ports:
127
- try:
128
- pn = subprocess.run(
129
- ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
130
- "-NoProfile", "-Command",
131
- "Get-Process | Select-Object Id,ProcessName | ConvertTo-Json -Compress"],
132
- capture_output=True, timeout=15)
133
- procs = json.loads(pn.stdout.decode("utf-8", "replace"))
134
- if isinstance(procs, dict):
135
- procs = [procs]
136
- name_map = {p.get("Id"): p.get("ProcessName", "") for p in procs}
137
- for p in ports:
138
- p["process"] = name_map.get(p["pid"], "")
139
- except Exception:
140
- pass
141
- dedup = {}
142
- for p in ports:
143
- dedup.setdefault(p["port"], p)
144
- return sorted(dedup.values(), key=lambda x: x["port"])
145
-
146
-
147
- def listening_ports(with_names=True):
148
- """扫描本机所有 LISTEN 端口,返回 [{port, pid, process, project, local_only}]。
149
-
150
- project 从进程 CWD 推断项目根目录名(POSIX /proc 可得,Windows 留空)。
151
- with_names=False:跳过进程名/归属补全(Windows 下省掉 PowerShell 一次
152
- 起跳,重验场景用),process/project 恒为空串。
153
- 结果按端口号排序,重复端口去重(保留有 PID 信息的条目)。"""
154
- if os.name == "nt":
155
- return _ports_windows(with_names)
156
- return _ports_linux()
157
-
158
-
159
- def close_port(port):
160
- """温和关闭端口上的进程。返回 (ok, message)。关闭前重验 PID 绑定。"""
161
- for p in listening_ports():
162
- if p["port"] != port:
163
- continue
164
- pid = p.get("pid", 0)
165
- if not pid or pid <= 4:
166
- return False, "系统进程,不关闭"
167
- if pid == os.getpid():
168
- return False, "不能关闭自身服务进程"
169
- if p.get("process", "").lower() in _SYSTEM_PROCS:
170
- return False, "系统服务,不关闭"
171
- # 重验 PID 仍在监听该端口(防 PID 复用竞态);轻量扫描省掉 PowerShell
172
- still = any(pp["port"] == port and pp["pid"] == pid
173
- for pp in listening_ports(with_names=False))
174
- if not still:
175
- return False, "PID %d 已不在端口 %d 上监听(竞态)" % (pid, port)
176
- if os.name == "posix":
177
- try:
178
- os.kill(pid, 15) # SIGTERM
179
- return True, "已发送 SIGTERM(PID %d)" % pid
180
- except OSError as e:
181
- return False, str(e)[:160]
182
- try:
183
- subprocess.run(["taskkill", "/PID", str(pid)],
184
- timeout=10, capture_output=True)
185
- return True, "已发送关闭信号(PID %d)" % pid
186
- except Exception as e:
187
- return False, str(e)[:160]
188
- return False, "端口 %d 未找到监听进程" % port
1
+ # -*- coding: utf-8 -*-
2
+ """端口/进程扫描与项目归属推断(借鉴 leftopen 38★)。
3
+
4
+ 三个机制:
5
+ - **端口→进程→项目归属推断**:netstat/ss 拿端口→PID,再从进程 CWD 向上走找
6
+ .git/package.json 等项目根——知道该进程属于哪个项目/用户
7
+ - **本地 vs LAN 区分**:127.0.0.1 与 0.0.0.0/LAN 的安全边界
8
+ - **温和关闭**:SIGTERM only(Windows taskkill /PID 不带 /F),关闭前重验 PID
9
+
10
+ 跨平台(Windows netstat + PowerShell / POSIX ss + /proc)纯标准库。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import subprocess
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ _PROJECT_MARKERS = (".git", "package.json", "pyproject.toml", "Cargo.toml",
23
+ "go.mod", "pom.xml", "build.gradle", ".codebee")
24
+ _SYSTEM_PROCS = {"system", "idle", "kernel", "svchost", "launchd", "init",
25
+ "systemd", "sshd", "explorer", "finder", "windowserver"}
26
+
27
+
28
+ def _project_from_cwd(cwd):
29
+ """从 CWD 向上走到项目根(含标志文件的最深目录名)。"""
30
+ if not cwd:
31
+ return ""
32
+ cur = os.path.abspath(cwd)
33
+ origin = cur
34
+ home = os.path.abspath(os.path.expanduser("~"))
35
+ while cur and cur != os.path.dirname(cur):
36
+ # 家目录及以上散落的标志文件(package.json 等)是环境噪音不是项目;
37
+ # 只有进程就跑在家目录本身时才认它为归属。
38
+ if cur == home and cur != origin:
39
+ return ""
40
+ for marker in _PROJECT_MARKERS:
41
+ if os.path.exists(os.path.join(cur, marker)):
42
+ return os.path.basename(cur)
43
+ cur = os.path.dirname(cur)
44
+ return ""
45
+
46
+
47
+ def _proc_detail(pid):
48
+ """POSIX:读 /proc/<pid>/cwd 和 comm 获取进程详情。"""
49
+ detail = {"name": "", "project": ""}
50
+ try:
51
+ cwd = os.readlink("/proc/%d/cwd" % pid)
52
+ with open("/proc/%d/comm" % pid) as f:
53
+ detail["name"] = f.read().strip()
54
+ proj = _project_from_cwd(cwd)
55
+ if proj:
56
+ detail["project"] = proj
57
+ except (OSError, PermissionError):
58
+ pass
59
+ return detail
60
+
61
+
62
+ def _parse_ss(output):
63
+ """解析 ss/netstat -tlnp 输出为端口条目列表。"""
64
+ ports, pid_map = [], {}
65
+ for ln in output.splitlines():
66
+ m = re.search(r":(\d{4,5})\s", ln)
67
+ if not m:
68
+ continue
69
+ port = int(m.group(1))
70
+ pm = re.search(r"pid=(\d+)", ln)
71
+ pid = int(pm.group(1)) if pm else 0
72
+ local_only = "127.0.0.1" in ln or "[::1]" in ln or "localhost" in ln
73
+ if pid and pid not in pid_map:
74
+ pid_map[pid] = _proc_detail(pid)
75
+ ports.append({"port": port, "pid": pid, "local_only": local_only,
76
+ "process": pid_map.get(pid, {}).get("name", ""),
77
+ "project": pid_map.get(pid, {}).get("project", "")})
78
+ dedup = {}
79
+ for p in ports:
80
+ key = p["port"]
81
+ if key not in dedup or (p["pid"] and not dedup[key]["pid"]):
82
+ dedup[key] = p
83
+ return sorted(dedup.values(), key=lambda x: x["port"])
84
+
85
+
86
+ def _ports_linux():
87
+ """Linux/macOS:ss 优先,netstat 兜底。"""
88
+ try:
89
+ proc = subprocess.run(["ss", "-tlnp"], capture_output=True, timeout=15)
90
+ if proc.returncode == 0 and proc.stdout:
91
+ return _parse_ss(proc.stdout.decode("utf-8", "replace"))
92
+ except (OSError, subprocess.TimeoutExpired):
93
+ pass
94
+ try:
95
+ proc = subprocess.run(["netstat", "-tlnp"], capture_output=True, timeout=15)
96
+ if proc.returncode == 0 and proc.stdout:
97
+ return _parse_ss(proc.stdout.decode("utf-8", "replace"))
98
+ except (OSError, subprocess.TimeoutExpired):
99
+ pass
100
+ return []
101
+
102
+
103
+ def _ports_windows(with_names=True):
104
+ """Windows:netstat -ano -p TCP 拿端口,PowerShell 一次性补进程名。"""
105
+ try:
106
+ proc = subprocess.run(
107
+ ["C:\\Windows\\System32\\netstat.exe", "-ano", "-p", "TCP"],
108
+ capture_output=True, timeout=15)
109
+ except (OSError, subprocess.TimeoutExpired):
110
+ return []
111
+ ports = []
112
+ for ln in proc.stdout.decode("utf-8", "replace").splitlines():
113
+ parts = ln.split()
114
+ if len(parts) < 5 or parts[0] != "TCP" or parts[3] != "LISTENING":
115
+ continue
116
+ local = parts[1]
117
+ pid_str = parts[4]
118
+ if not pid_str.isdigit():
119
+ continue
120
+ addr, _, port_str = local.rpartition(":")
121
+ if not port_str.isdigit():
122
+ continue
123
+ ports.append({"port": int(port_str), "pid": int(pid_str),
124
+ "local_only": addr in ("127.0.0.1", "[::1]", "::1"),
125
+ "process": "", "project": ""})
126
+ if with_names and ports:
127
+ try:
128
+ pn = subprocess.run(
129
+ ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
130
+ "-NoProfile", "-Command",
131
+ "Get-Process | Select-Object Id,ProcessName | ConvertTo-Json -Compress"],
132
+ capture_output=True, timeout=15)
133
+ procs = json.loads(pn.stdout.decode("utf-8", "replace"))
134
+ if isinstance(procs, dict):
135
+ procs = [procs]
136
+ name_map = {p.get("Id"): p.get("ProcessName", "") for p in procs}
137
+ for p in ports:
138
+ p["process"] = name_map.get(p["pid"], "")
139
+ except Exception:
140
+ pass
141
+ dedup = {}
142
+ for p in ports:
143
+ dedup.setdefault(p["port"], p)
144
+ return sorted(dedup.values(), key=lambda x: x["port"])
145
+
146
+
147
+ def listening_ports(with_names=True):
148
+ """扫描本机所有 LISTEN 端口,返回 [{port, pid, process, project, local_only}]。
149
+
150
+ project 从进程 CWD 推断项目根目录名(POSIX /proc 可得,Windows 留空)。
151
+ with_names=False:跳过进程名/归属补全(Windows 下省掉 PowerShell 一次
152
+ 起跳,重验场景用),process/project 恒为空串。
153
+ 结果按端口号排序,重复端口去重(保留有 PID 信息的条目)。"""
154
+ if os.name == "nt":
155
+ return _ports_windows(with_names)
156
+ return _ports_linux()
157
+
158
+
159
+ def close_port(port):
160
+ """温和关闭端口上的进程。返回 (ok, message)。关闭前重验 PID 绑定。"""
161
+ for p in listening_ports():
162
+ if p["port"] != port:
163
+ continue
164
+ pid = p.get("pid", 0)
165
+ if not pid or pid <= 4:
166
+ return False, "系统进程,不关闭"
167
+ if pid == os.getpid():
168
+ return False, "不能关闭自身服务进程"
169
+ if p.get("process", "").lower() in _SYSTEM_PROCS:
170
+ return False, "系统服务,不关闭"
171
+ # 重验 PID 仍在监听该端口(防 PID 复用竞态);轻量扫描省掉 PowerShell
172
+ still = any(pp["port"] == port and pp["pid"] == pid
173
+ for pp in listening_ports(with_names=False))
174
+ if not still:
175
+ return False, "PID %d 已不在端口 %d 上监听(竞态)" % (pid, port)
176
+ if os.name == "posix":
177
+ try:
178
+ os.kill(pid, 15) # SIGTERM
179
+ return True, "已发送 SIGTERM(PID %d)" % pid
180
+ except OSError as e:
181
+ return False, str(e)[:160]
182
+ try:
183
+ subprocess.run(["taskkill", "/PID", str(pid)],
184
+ timeout=10, capture_output=True)
185
+ return True, "已发送关闭信号(PID %d)" % pid
186
+ except Exception as e:
187
+ return False, str(e)[:160]
188
+ return False, "端口 %d 未找到监听进程" % port
@@ -0,0 +1,38 @@
1
+ # -*- coding: utf-8 -*-
2
+ """自由文本脱敏基础函数;不依赖业务模块,供各类本地台账复用。"""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _KEY_PATTERNS = (
9
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
10
+ (re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
11
+ (re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
12
+ r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
13
+ (re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
14
+ )
15
+
16
+ _PATH_PATTERNS = (
17
+ (re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
18
+ lambda m: "[path]" + m.group(0).split("\\")[-1]),
19
+ (re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
20
+ (re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"),
21
+ lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
22
+ )
23
+
24
+
25
+ def scrub_text(text, limit=600):
26
+ """剥离常见密钥、令牌和绝对路径,再按字符上限截断。"""
27
+ if not isinstance(text, str):
28
+ text = "" if text is None else str(text)
29
+ out = text
30
+ for pattern, replacement in _KEY_PATTERNS:
31
+ out = pattern.sub(replacement, out)
32
+ for pattern, replacement in _PATH_PATTERNS:
33
+ try:
34
+ out = pattern.sub(replacement, out)
35
+ except Exception:
36
+ pass
37
+ out = out.strip()
38
+ return out[:limit] + "…" if len(out) > limit else out
@@ -37,7 +37,7 @@ def _binding_bonus(agent_id, dispatch_mode=False):
37
37
  return 0.0
38
38
 
39
39
 
40
- def _history_bonus(stats, agent_id, ttype):
40
+ def _history_bonus(stats, agent_id, ttype):
41
41
  s = (stats.get(agent_id) or {}).get(ttype) or {}
42
42
  if not s.get("runs"):
43
43
  return 0.0
@@ -47,9 +47,34 @@ def _history_bonus(stats, agent_id, ttype):
47
47
  # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
48
48
  # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
49
49
  loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
50
- return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
51
-
52
-
50
+ return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
51
+
52
+
53
+ def _online_bonus(agent, role, ttype):
54
+ """近期真实运行信号:成功率、P95 延迟和均价只作软加减分。"""
55
+ try:
56
+ from . import usage
57
+ metrics = usage.routing_stats(task_type=ttype, role=role,
58
+ agent=agent.get("id") or "")
59
+ samples = int(metrics.get("samples") or 0)
60
+ if not samples:
61
+ return 0.0, ""
62
+ rate = float(metrics.get("success_rate") or 0.0)
63
+ success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
64
+ p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
65
+ latency_score = -min(6.0, max(0.0, (p95 - 45.0) / 10.0))
66
+ cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
67
+ cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
68
+ total = round(success_score + latency_score + cost_score, 1)
69
+ success_samples = int(metrics.get("success_samples") or samples)
70
+ reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
71
+ % (int(metrics.get("successes") or 0), success_samples,
72
+ success_score, p95, latency_score, cost, cost_score))
73
+ return total, reason
74
+ except Exception:
75
+ return 0.0, ""
76
+
77
+
53
78
  def score(agent, role, ttype, stats=None):
54
79
  """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
55
80
  quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
@@ -65,12 +90,13 @@ def score(agent, role, ttype, stats=None):
65
90
  btxt = ",绑定链可用(+%s)" % bb
66
91
  elif bb < 0:
67
92
  btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
68
- hb = _history_bonus(stats, agent.get("id"), ttype)
93
+ hb = _history_bonus(stats, agent.get("id"), ttype)
94
+ online, online_txt = _online_bonus(agent, role, ttype)
69
95
  # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
70
96
  # 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
71
97
  affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
72
98
  if use_dispatch else (0.0, "兼容模式"))
73
- total = base + bb + hb + affinity
99
+ total = base + bb + hb + affinity + online
74
100
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
75
101
  htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
76
102
  quota_txt = ""
@@ -87,8 +113,8 @@ def score(agent, role, ttype, stats=None):
87
113
  if penalty:
88
114
  total += penalty
89
115
  quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
90
- return total, "能力基线 %d,%s%s%s%s,总分 %s" % (
91
- base, affinity_txt, btxt, htxt, quota_txt, round(total, 1))
116
+ return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
117
+ base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
92
118
 
93
119
 
94
120
  def pick(agents, role, ttype, stats=None, exclude=()):
@@ -768,9 +768,10 @@ def _resolve_attempts(agent):
768
768
  """
769
769
  chain = agent.get("call_chain") or []
770
770
  if chain:
771
- return [{"model": (e.get("model") or "").strip() or None,
772
- "env": dict(e.get("env") or {}),
773
- "from_chain": True,
771
+ return [{"model": (e.get("model") or "").strip() or None,
772
+ "env": dict(e.get("env") or {}),
773
+ "provider": e.get("provider") or {},
774
+ "from_chain": True,
774
775
  "own_cp": "codex_provider" in e,
775
776
  "codex_provider": e.get("codex_provider"),
776
777
  "provider_id": e.get("provider_id") or "",
@@ -778,8 +779,12 @@ def _resolve_attempts(agent):
778
779
  base_model = agent.get("model")
779
780
  fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
780
781
  models_to_try = ([base_model] if base_model else []) + fb
781
- return [{"model": m or None, "env": {}, "from_chain": False, "own_cp": False,
782
- "codex_provider": None, "provider_id": "", "key_id": ""}
782
+ provider = agent.get("provider") or {}
783
+ return [{"model": m or None, "env": {}, "provider": provider,
784
+ "from_chain": False, "own_cp": False,
785
+ "codex_provider": None,
786
+ "provider_id": provider.get("id") if isinstance(provider, dict) else "",
787
+ "key_id": ""}
783
788
  for m in (models_to_try or [None])[:3]]
784
789
 
785
790
 
@@ -1056,9 +1061,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1056
1061
  os.remove(tf)
1057
1062
  except OSError:
1058
1063
  pass
1059
- out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1060
- "tokens": 0, "usage": None, "error": "", "error_code": "",
1061
- "sid": "", "raw": res, "kind": kind, "model": att["model"]}
1064
+ out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1065
+ "tokens": 0, "usage": None, "error": "", "error_code": "",
1066
+ "sid": "", "raw": res, "kind": kind, "model": att["model"],
1067
+ "provider_id": att.get("provider_id") or "",
1068
+ "provider": att.get("provider") or {}}
1062
1069
  if not res["ok"]:
1063
1070
  # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
1064
1071
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL