-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprobe_codex_cc_switch.py
More file actions
1380 lines (1211 loc) · 46.8 KB
/
Copy pathprobe_codex_cc_switch.py
File metadata and controls
1380 lines (1211 loc) · 46.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import random
import re
import shutil
import sqlite3
import sys
import tempfile
import textwrap
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10
import tomli as tomllib
from probe_tools.codex_app_server_client import (
AppServerProtocolError,
AppServerTurnResult,
CodexAppServerClient,
)
NETWORK_ENV_KEYS = (
"PATH",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"PATHEXT",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"TEMP",
"TMP",
"LANG",
"LC_ALL",
"TERM",
"NO_COLOR",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
)
TYPE_CHECKERS: dict[str, tuple[type, ...]] = {
"str": (str,),
"int": (int,),
"list": (list,),
}
STATUS_LABELS: dict[str, str] = {
"healthy": "正常",
"timeout": "超时",
"auth_fail": "失败",
"rate_limited": "限流",
"model_unavailable": "模型不可用",
"provider_error": "服务异常",
"route_fail": "路由异常",
"bad_output": "输出异常",
"network_error": "连接异常",
"client_blocked": "客户端受限",
"app_server_error": "客户端异常",
"exec_failed": "失败",
}
@dataclass(frozen=True)
class PromptSpec:
prompt_id: str
title: str
body: str
required_keys: tuple[str, ...]
type_expectations: dict[str, str]
@dataclass(frozen=True)
class ProviderRecord:
provider_id: str
name: str
is_current: bool
endpoint_url: str | None
common_config_enabled: bool
raw_config: str
auth: dict[str, Any]
meta: dict[str, Any]
@property
def is_api_provider(self) -> bool:
return bool(self.endpoint_url) or bool(self.raw_config.strip())
@dataclass(frozen=True)
class ModelRunSpec:
model: str
reasoning_effort: str
PROMPT_POOL: tuple[PromptSpec, ...] = (
PromptSpec(
prompt_id="pagination_bug",
title="Python pagination bug",
required_keys=("bug", "fixed_code", "tests"),
type_expectations={"bug": "str", "fixed_code": "str", "tests": "list"},
body=textwrap.dedent(
"""
你在帮我排查一个分页 bug。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"bug":"", "fixed_code":"", "tests":["",""]}
Python 代码:
def page(items, page_no, page_size):
start = page_no * page_size
end = start + page_size
return items[start:end]
要求:
1. 指出 bug
2. 给出修复后的函数
3. 给出 2 个边界测试点
"""
).strip(),
),
PromptSpec(
prompt_id="log_root_cause",
title="API log root cause",
required_keys=("root_cause", "evidence", "next_step"),
type_expectations={"root_cause": "str", "evidence": "list", "next_step": "list"},
body=textwrap.dedent(
"""
你在做一次日志排查。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"root_cause":"", "evidence":["",""], "next_step":["",""]}
日志:
2026-07-04T10:12:01Z GET /api/orders?page=1&page_size=20 200 35ms
2026-07-04T10:12:03Z GET /api/orders?page=2&page_size=20 200 37ms
2026-07-04T10:12:06Z GET /api/orders?page=3&page_size=20 500 12ms
2026-07-04T10:12:06Z ERROR ValueError: invalid literal for int() with base 10: ''
2026-07-04T10:12:06Z at parse_page_size(request.query.page_size)
要求:
1. 判断最可能根因
2. 给出两条证据
3. 给出两个下一步处理建议
"""
).strip(),
),
PromptSpec(
prompt_id="order_summary",
title="Order data summary",
required_keys=("total_orders", "paid_orders", "total_paid_amount", "top_user_by_paid_amount"),
type_expectations={
"total_orders": "int",
"paid_orders": "int",
"total_paid_amount": "int",
"top_user_by_paid_amount": "str",
},
body=textwrap.dedent(
"""
你在做一个小型数据汇总。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"total_orders":0, "paid_orders":0, "total_paid_amount":0, "top_user_by_paid_amount":""}
数据:
[
{"user":"alice","status":"paid","amount":120},
{"user":"bob","status":"pending","amount":80},
{"user":"alice","status":"paid","amount":30},
{"user":"carol","status":"paid","amount":200},
{"user":"bob","status":"paid","amount":50}
]
要求:
1. 统计总订单数
2. 统计 paid 订单数
3. 统计 paid 总金额
4. 找出 paid 金额最高的用户
"""
).strip(),
),
PromptSpec(
prompt_id="coupon_api_design",
title="Coupon API design",
required_keys=("endpoint", "method", "required_fields", "validation_rules", "error_cases"),
type_expectations={
"endpoint": "str",
"method": "str",
"required_fields": "list",
"validation_rules": "list",
"error_cases": "list",
},
body=textwrap.dedent(
"""
你在帮我补一个接口设计说明。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"endpoint":"", "method":"", "required_fields":["",""], "validation_rules":["",""], "error_cases":["",""]}
需求:
要新增“创建优惠券”接口。字段包括:
- code: 优惠券码,必填,长度 6 到 12
- discount_percent: 折扣百分比,必填,范围 1 到 80
- expires_at: 过期时间,必填,必须晚于当前时间
- user_id: 可选,不传表示全站可用
要求:
1. 设计一个合理的 endpoint 和 method
2. 列出必填字段
3. 列出 3 条校验规则
4. 列出 2 个错误场景
"""
).strip(),
),
PromptSpec(
prompt_id="javascript_review",
title="JavaScript async review",
required_keys=("problem", "fixed_code", "why", "test_case"),
type_expectations={
"problem": "str",
"fixed_code": "str",
"why": "str",
"test_case": "str",
},
body=textwrap.dedent(
"""
你在 review 一段 JavaScript。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"problem":"", "fixed_code":"", "why":"", "test_case":""}
代码:
async function loadUser(id) {
const resp = fetch(`/api/users/${id}`);
const data = await resp.json();
return data.name;
}
要求:
1. 找出问题
2. 给出修复后的代码
3. 简述原因
4. 给出一个测试场景
"""
).strip(),
),
PromptSpec(
prompt_id="sql_validation",
title="SQL validation",
required_keys=("correct_query", "reason", "expected_result"),
type_expectations={"correct_query": "str", "reason": "str", "expected_result": "int"},
body=textwrap.dedent(
"""
你在做 SQL 结果校对。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"correct_query":"", "reason":"", "expected_result":0}
表结构:
orders(id, user_id, status, amount)
目标:
统计 status='paid' 且 amount > 100 的订单数量
错误 SQL:
SELECT SUM(*) FROM orders WHERE status = 'paid' OR amount > 100;
样例数据:
1, 10, paid, 120
2, 11, paid, 50
3, 12, pending, 180
4, 13, paid, 220
要求:
1. 写出正确 SQL
2. 说明原 SQL 错在哪
3. 计算样例数据上的正确结果
"""
).strip(),
),
PromptSpec(
prompt_id="shell_safety",
title="Shell safety refactor",
required_keys=("risk", "fixed_script", "notes"),
type_expectations={"risk": "str", "fixed_script": "str", "notes": "list"},
body=textwrap.dedent(
"""
你在做一段 shell 脚本的稳健性修复。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"risk":"", "fixed_script":"", "notes":["",""]}
脚本:
#!/usr/bin/env bash
set -e
TARGET_DIR=$1
rm -rf $TARGET_DIR/*
cp -r ./dist/* $TARGET_DIR/
要求:
1. 指出最主要风险
2. 给出更稳妥的修复版脚本
3. 给出 2 条注意事项
"""
).strip(),
),
PromptSpec(
prompt_id="regex_extract",
title="Regex extraction",
required_keys=("regex", "matches", "explanation"),
type_expectations={"regex": "str", "matches": "list", "explanation": "str"},
body=textwrap.dedent(
"""
你在帮我整理一个正则提取需求。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"regex":"", "matches":["",""], "explanation":""}
文本:
订单号: ORD-20260704-001
订单号: ORD-20260704-002
错误编号: ERR-900
订单号: ORD-20260705-003
要求:
1. 写一个能提取所有订单号的正则
2. 列出应匹配到的结果
3. 简述为什么这个正则合适
"""
).strip(),
),
PromptSpec(
prompt_id="python_refactor",
title="Python function refactor",
required_keys=("issue", "refactored_code", "benefit", "test_case"),
type_expectations={"issue": "list", "refactored_code": "str", "benefit": "str", "test_case": "str"},
body=textwrap.dedent(
"""
你在做一个 Python 函数重构。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"issue":["",""], "refactored_code":"", "benefit":"", "test_case":""}
代码:
def normalize_name(name):
name = name.strip()
name = name.lower()
if name == "":
return ""
return name[0].upper() + name[1:]
要求:
1. 说出 2 个可以改进的点
2. 给出重构后的代码
3. 说明收益
4. 给出 1 个测试用例
"""
).strip(),
),
PromptSpec(
prompt_id="feature_breakdown",
title="Feature breakdown",
required_keys=("tasks", "risks", "acceptance"),
type_expectations={"tasks": "list", "risks": "list", "acceptance": "list"},
body=textwrap.dedent(
"""
你在做一次小型需求拆解。不要调用工具,直接基于下面内容回答,并且只返回 JSON。
返回格式:
{"tasks":["","",""], "risks":["",""], "acceptance":["","",""]}
需求:
要给后台订单列表增加“按支付状态筛选”和“按下单时间倒序排序”功能。前端已有筛选栏,后端已有基础分页接口,但当前不支持这两个条件。
要求:
1. 拆成 3 个实施任务
2. 列出 2 个主要风险
3. 给出 3 条验收标准
"""
).strip(),
),
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Probe Codex providers stored by CC Switch via isolated Codex app-server calls."
)
parser.add_argument(
"--db-path",
default="~/.cc-switch/cc-switch.db",
help="Path to the CC Switch sqlite database.",
)
parser.add_argument(
"--catalog-path",
default="~/.codex/cc-switch-model-catalog.json",
help="Optional model catalog copied into isolated CODEX_HOME when present.",
)
parser.add_argument(
"--codex-bin",
default="codex",
help="Codex CLI executable to invoke.",
)
parser.add_argument(
"--base-dir",
default="~/.cache/codex-cc-switch-probe",
help="Directory used for temporary probe homes and workspaces.",
)
parser.add_argument(
"--provider",
action="append",
default=[],
help="Filter providers by case-insensitive substring match on name or id. Repeatable.",
)
parser.add_argument(
"--current-only",
action="store_true",
help="Probe only the provider currently marked as active in CC Switch.",
)
parser.add_argument(
"--include-non-api",
action="store_true",
help="Include non-API providers such as official direct-login entries. Default is to probe API providers only.",
)
parser.add_argument(
"--attempts",
type=int,
default=1,
help="Maximum attempts per provider-model pair. Default: 1.",
)
parser.add_argument(
"--model",
default="",
help="Single model override for compatibility. When set, overrides --models.",
)
parser.add_argument(
"--models",
default="gpt-5.4,gpt-5.5",
help="Comma-separated model list to probe per provider. Default: gpt-5.4,gpt-5.5.",
)
parser.add_argument(
"--reasoning-effort",
default="high",
help="Reasoning effort passed to the isolated app-server turn. Default: high.",
)
parser.add_argument(
"--sandbox",
default="read-only",
choices=("read-only", "workspace-write", "danger-full-access"),
help="Sandbox mode for isolated app-server threads. Default: read-only.",
)
parser.add_argument(
"--timeout",
type=int,
default=90,
help="Timeout in seconds for each app-server attempt. Default: 90.",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Seed for deterministic prompt selection.",
)
parser.add_argument(
"--keep-temp",
action="store_true",
help="Keep isolated run directories for inspection.",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the final report as JSON when --output is not set. When --output is set, JSON is written to file and stdout prints a human-readable summary.",
)
parser.add_argument(
"--output",
default="",
help="Optional file path to write the final JSON report.",
)
parser.add_argument(
"--list-providers",
action="store_true",
help="List Codex providers found in CC Switch and exit.",
)
parser.add_argument(
"--list-prompts",
action="store_true",
help="List the built-in realistic prompt pool and exit.",
)
return parser
def expand_path(raw: str) -> Path:
return Path(os.path.expanduser(raw)).resolve()
def slugify(value: str) -> str:
lowered = value.strip().lower()
slug = re.sub(r"[^a-z0-9]+", "-", lowered)
return slug.strip("-") or "provider"
def load_codex_common_config(db_path: Path) -> str:
# 数据库文件不存在时(例如全新环境尚未安装 cc-switch)视为没有公共配置,
# 返回空字符串而不是抛出 sqlite 错误,避免 GUI smoke 检查等流程崩溃。
if not Path(db_path).exists():
return ""
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT value FROM settings WHERE key = 'common_config_codex'"
).fetchone()
return (row["value"] if row else "") or ""
def load_codex_providers(db_path: Path) -> list[ProviderRecord]:
query = """
SELECT
p.id AS provider_id,
p.name AS provider_name,
p.is_current AS is_current,
p.settings_config AS settings_config,
p.meta AS provider_meta,
pe.url AS endpoint_url
FROM providers p
LEFT JOIN provider_endpoints pe
ON pe.provider_id = p.id AND pe.app_type = p.app_type
WHERE p.app_type = 'codex'
ORDER BY p.sort_index IS NULL, p.sort_index, p.created_at, p.name
"""
providers: list[ProviderRecord] = []
# 数据库文件不存在时(例如全新环境尚未安装 cc-switch)视为没有任何供应商,
# 返回空列表而不是抛出 sqlite 错误,避免 GUI smoke 检查等流程崩溃。
if not Path(db_path).exists():
return providers
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(query).fetchall()
for row in rows:
payload = json.loads(row["settings_config"])
auth = payload.get("auth") or {}
if not isinstance(auth, dict):
raise ValueError(f"Provider {row['provider_name']} has non-dict auth payload")
meta = json.loads(row["provider_meta"]) if row["provider_meta"] else {}
providers.append(
ProviderRecord(
provider_id=row["provider_id"],
name=row["provider_name"],
is_current=bool(row["is_current"]),
endpoint_url=row["endpoint_url"],
common_config_enabled=bool(meta.get("commonConfigEnabled")),
raw_config=(payload.get("config") or "").strip(),
auth=auth,
meta=meta,
)
)
return providers
def filter_providers(
providers: list[ProviderRecord],
filters: list[str],
current_only: bool,
include_non_api: bool,
) -> list[ProviderRecord]:
selected = providers
if not include_non_api:
selected = [provider for provider in selected if provider.is_api_provider]
if current_only:
selected = [provider for provider in selected if provider.is_current]
if filters:
lowered_filters = [item.casefold() for item in filters]
selected = [
provider
for provider in selected
if any(
needle in provider.name.casefold() or needle in provider.provider_id.casefold()
for needle in lowered_filters
)
]
return selected
def list_providers(providers: list[ProviderRecord]) -> int:
if not providers:
print("No codex providers found in CC Switch.")
return 1
for provider in providers:
current = "yes" if provider.is_current else "no"
common = "yes" if provider.common_config_enabled else "no"
endpoint = provider.endpoint_url or "-"
api = "yes" if provider.is_api_provider else "no"
print(
f"{provider.name}\n"
f" id: {provider.provider_id}\n"
f" api_provider: {api}\n"
f" current: {current}\n"
f" common_config_enabled: {common}\n"
f" endpoint: {endpoint}\n"
)
return 0
def list_prompts() -> int:
for prompt in PROMPT_POOL:
print(f"{prompt.prompt_id}: {prompt.title}")
return 0
def build_effective_config(provider: ProviderRecord, common_config: str) -> str:
if not provider.common_config_enabled:
return provider.raw_config.strip() + ("\n" if provider.raw_config.strip() else "")
merged = merge_toml_documents(common_config, provider.raw_config)
return serialize_toml_document(merged)
def merge_toml_documents(base_text: str, overlay_text: str) -> dict[str, Any]:
base_data = tomllib.loads(base_text) if base_text.strip() else {}
overlay_data = tomllib.loads(overlay_text) if overlay_text.strip() else {}
return deep_merge_dicts(base_data, overlay_data)
def deep_merge_dicts(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
merged: dict[str, Any] = dict(base)
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = deep_merge_dicts(merged[key], value)
else:
merged[key] = value
return merged
def serialize_toml_document(data: dict[str, Any]) -> str:
lines = emit_toml_table(data, path=())
if not lines:
return ""
return "\n".join(lines).rstrip() + "\n"
def emit_toml_table(table: dict[str, Any], path: tuple[str, ...]) -> list[str]:
lines: list[str] = []
scalar_items: list[tuple[str, Any]] = []
table_items: list[tuple[str, dict[str, Any]]] = []
for key, value in table.items():
if isinstance(value, dict):
table_items.append((key, value))
else:
scalar_items.append((key, value))
if path:
lines.append(f"[{format_table_path(path)}]")
for key, value in scalar_items:
lines.append(f"{format_key(key)} = {format_toml_value(value)}")
for key, child in table_items:
child_lines = emit_toml_table(child, path + (key,))
if child_lines:
if lines:
lines.append("")
lines.extend(child_lines)
return lines
def format_table_path(path: tuple[str, ...]) -> str:
return ".".join(format_key(part) for part in path)
def format_key(key: str) -> str:
if re.fullmatch(r"[A-Za-z0-9_-]+", key):
return key
escaped = key.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def format_toml_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return repr(value)
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, list):
return "[" + ", ".join(format_toml_value(item) for item in value) + "]"
if isinstance(value, dict):
return "{ " + ", ".join(f'{format_key(k)} = {format_toml_value(v)}' for k, v in value.items()) + " }"
raise TypeError(f"Unsupported TOML value type: {type(value)!r}")
def prepare_run_directory(
base_dir: Path,
provider: ProviderRecord,
common_config: str,
catalog_path: Path,
) -> tuple[Path, Path]:
base_dir.mkdir(parents=True, exist_ok=True)
run_dir = Path(
tempfile.mkdtemp(
prefix=f"{slugify(provider.name)}-",
dir=str(base_dir),
)
)
codex_home = run_dir / "codex-home"
workspace = run_dir / "workspace"
codex_home.mkdir(parents=True, exist_ok=True)
workspace.mkdir(parents=True, exist_ok=True)
config_text = build_effective_config(provider, common_config)
(codex_home / "config.toml").write_text(config_text, encoding="utf-8")
(codex_home / "auth.json").write_text(
json.dumps(provider.auth, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if catalog_path.is_file():
shutil.copy2(catalog_path, codex_home / catalog_path.name)
return run_dir, workspace
def build_env(codex_home: Path) -> dict[str, str]:
env: dict[str, str] = {}
for key in NETWORK_ENV_KEYS:
value = os.environ.get(key)
if value:
env[key] = value
env["HOME"] = str(codex_home)
env["CODEX_HOME"] = str(codex_home)
env.setdefault("LANG", "C.UTF-8")
env.setdefault("LC_ALL", "C.UTF-8")
env.setdefault("TERM", "xterm-256color")
env["NO_COLOR"] = "1"
return env
def strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
return "\n".join(lines).strip()
def extract_json_payload(raw_text: str) -> tuple[dict[str, Any] | None, str | None]:
text = strip_code_fence(raw_text)
candidates: list[str] = []
if text:
candidates.append(text)
first_brace = text.find("{")
last_brace = text.rfind("}")
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
candidate = text[first_brace : last_brace + 1]
if candidate != text:
candidates.append(candidate)
seen: set[str] = set()
for candidate in candidates:
if candidate in seen:
continue
seen.add(candidate)
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
return parsed, None
return None, "response JSON is not an object"
return None, "response is not valid JSON"
def validate_payload(prompt: PromptSpec, payload: dict[str, Any]) -> str | None:
missing = [key for key in prompt.required_keys if key not in payload]
if missing:
return "missing keys: " + ", ".join(missing)
for key, kind in prompt.type_expectations.items():
expected = TYPE_CHECKERS[kind]
value = payload[key]
if not isinstance(value, expected):
return f"key {key!r} is not of type {kind}"
if kind == "str" and not value.strip():
return f"key {key!r} is empty"
if kind == "list" and not value:
return f"key {key!r} is empty"
return None
def mask_sensitive_text(text: str) -> str:
masked = re.sub(
r"sk-[A-Za-z0-9_-]{10,}",
lambda match: match.group(0)[:6] + "***" + match.group(0)[-4:],
text,
)
return masked
def compact_text(text: str, limit: int = 600) -> str:
flattened = " ".join(text.split())
masked = mask_sensitive_text(flattened)
if len(masked) <= limit:
return masked
head = max(120, limit // 2 - 20)
tail = max(120, limit - head - 5)
return masked[:head] + " ... " + masked[-tail:]
def emit_progress(message: str) -> None:
timestamp = time.strftime("%H:%M:%S")
print(f"[{timestamp}] {message}", file=sys.stderr, flush=True)
def status_label(status: str) -> str:
return STATUS_LABELS.get(status, status)
def summarize_model_status(model_run: dict[str, Any]) -> str:
return f"{model_run['model']} {status_label(model_run['status'])}"
def append_unique(values: list[str], value: str) -> None:
normalized = " ".join(value.split())
if normalized and normalized not in values:
values.append(normalized)
def extract_reconnect_progress(detail_text: str) -> tuple[int, int] | None:
matches = re.findall(
r"ERROR:\s*Reconnecting\.\.\.\s*(\d+)/(\d+)",
detail_text,
flags=re.IGNORECASE,
)
if not matches:
return None
parsed = [(int(current), int(total)) for current, total in matches]
return max(parsed, key=lambda item: item[0])
def extract_common_error_notes(detail_text: str) -> list[str]:
notes: list[str] = []
if re.search(r"currently experiencing high demand", detail_text, flags=re.IGNORECASE):
append_unique(notes, "上游当前高负载,可能导致临时失败")
for matched in re.finditer(
r"stream disconnected before completion:\s*error sending request for url\s*\((https?://[^)]+)\)",
detail_text,
flags=re.IGNORECASE,
):
append_unique(
notes,
f"连接异常:响应完成前连接已断开({matched.group(1)})",
)
for matched in re.finditer(
r"No available channel for model\s+([^\s]+)\s+under group\s+(.+?)(?:\s+\(distributor\)|\s+\(request id:|,|\n|$)",
detail_text,
flags=re.IGNORECASE,
):
model = matched.group(1).strip()
group = matched.group(2).strip()
append_unique(notes, f"服务商无可用通道:分组 {group} 下没有 {model} 可用通道")
if re.search(r"401 Unauthorized|auth error:\s*401|Invalid token|invalid_api_key|incorrect api key", detail_text, flags=re.IGNORECASE):
append_unique(notes, "鉴权失败:API Key 或 Token 无效")
if re.search(r"429|rate limit", detail_text, flags=re.IGNORECASE):
append_unique(notes, "请求被限流:触发频率限制")
if re.search(r"\bquota\b|insufficient_quota", detail_text, flags=re.IGNORECASE):
append_unique(notes, "额度不足:账号或通道配额不可用")
if re.search(r"INSUFFICIENT_BALANCE", detail_text, flags=re.IGNORECASE):
append_unique(notes, "余额不足:账号或通道余额不可用")
if re.search(r"model_not_found|unsupported model", detail_text, flags=re.IGNORECASE):
append_unique(notes, "模型不可用:服务商不支持当前模型名")
if re.search(r"unexpected status 530|error code:\s*1033", detail_text, flags=re.IGNORECASE):
append_unique(notes, "上游网关异常:Cloudflare 530/1033 连接失败")
if re.search(r"This channel does not allow the current client", detail_text, flags=re.IGNORECASE):
append_unique(notes, "渠道限制:当前客户端不允许使用该通道")
if "无可用账号" in detail_text:
append_unique(notes, "服务商无可用账号")
if "请勿发送探测请求和无意义内容" in detail_text:
append_unique(notes, "服务商拒绝疑似探测或无意义请求")
return notes
def extract_attempt_notes(
detail_text: str,
validation_error: str | None = None,
timed_out: bool = False,
) -> list[str]:
notes: list[str] = []
for note in extract_common_error_notes(detail_text):
append_unique(notes, note)
concrete_patterns = (
r"unexpected status \d+ [^:,\n]*(?:: [^,\n]+)?",
r"\b\d{3} Unauthorized: [^,\n]+",
r"\b\d{3} Forbidden: [^,\n]+",
r"auth error: 401[^,\n]*",
r"invalid_api_key[^,\n]*",
r"incorrect api key[^,\n]*",
r"Invalid token",
r"error code: \d+",
r"This channel does not allow the current client",
r"无可用账号",
r"请勿发送探测请求和无意义内容",
r"rate limit[^,\n]*",
r"quota[^,\n]*",
r"model_not_found[^,\n]*",
r"unsupported model[^,\n]*",
)
if not notes:
for pattern in concrete_patterns:
for matched in re.finditer(pattern, detail_text, flags=re.IGNORECASE):
append_unique(notes, matched.group(0).strip())
reconnect = extract_reconnect_progress(detail_text)
if reconnect:
current, total = reconnect
if timed_out:
append_unique(
notes,
f"Codex 重连到 {current}/{total} 时被脚本超时截断,最终后端错误尚未吐出;可把 --timeout 调到 60 或 90 再看完整错误",
)
else:
append_unique(notes, f"Codex 出现重连,最后进度 {current}/{total}")
if validation_error and validation_error != "response is not valid JSON":
append_unique(notes, validation_error)
return notes[:4]
def extract_attempt_note(attempt: dict[str, Any]) -> str:
notes = attempt.get("error_summary")
if isinstance(notes, list) and notes:
return str(notes[0])
extracted = extract_attempt_notes(
detail_text=attempt.get("detail_excerpt") or "",
validation_error=attempt.get("validation_error"),
timed_out=bool(attempt.get("timed_out")),
)
return extracted[0] if extracted else ""
def collect_provider_notes(result: dict[str, Any]) -> list[str]:
notes: list[str] = []
for model_run in result["model_runs"]:
if model_run["status"] == "healthy":
continue
for attempt in reversed(model_run["attempts"]):
attempt_notes = attempt.get("error_summary")
if not isinstance(attempt_notes, list):
attempt_notes = extract_attempt_notes(
detail_text=attempt.get("detail_excerpt") or "",
validation_error=attempt.get("validation_error"),
timed_out=bool(attempt.get("timed_out")),
)
before_count = len(notes)
for note in attempt_notes:
append_unique(notes, str(note))
if len(notes) > before_count:
break
return notes
def summarize_provider_result(result: dict[str, Any]) -> str:
model_parts = [summarize_model_status(model_run) for model_run in result["model_runs"]]
return f"{result['provider_name']}:{','.join(model_parts)}"
def classify_failure(
returncode: int,