-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodex_chat_bridge.py
More file actions
1976 lines (1709 loc) · 85.3 KB
/
codex_chat_bridge.py
File metadata and controls
1976 lines (1709 loc) · 85.3 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
from __future__ import annotations
import csv
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
import tomllib
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from urllib.parse import urlparse
import tkinter as tk
from tkinter import messagebox, ttk
APP_NAME = "Codex Chat Bridge"
CHUNK_SIZE = 500
DEFAULT_LANGUAGE = "zh_CN"
LANGUAGE_LABELS = {
"zh_CN": "中文",
"en_US": "English",
}
RUNTIME_ERROR_KEYS = {
"This bridge session is not open anymore.": "error.runtime.bridge_session_not_open",
"Provider name cannot be empty.": "error.runtime.provider_name_empty",
"Base URL cannot be empty.": "error.runtime.base_url_empty",
"Base URL must start with http:// or https:// and include a host.": "error.runtime.base_url_invalid",
}
I18N = {
"zh_CN": {
"app_title": "Codex 聊天迁移器",
"label.refresh": "刷新",
"label.language": "语言",
"label.codex_home": "Codex 目录:",
"label.current_provider": "当前 Provider:",
"label.codex_process": "Codex 进程:",
"tab.chat_bridge": "聊天迁移",
"tab.providers": "Provider 管理",
"action.backup_full": "完整备份 .codex",
"action.open_session_folder": "打开会话目录",
"action.open_backup_folder": "打开备份目录",
"section.log": "日志",
"section.saved_configured_providers": "已保存与已配置的 Providers",
"section.provider_details": "Provider 详情",
"section.providers": "Providers",
"section.start_bridge_session": "开始桥接会话",
"section.direct_move": "直接迁移",
"section.bridge_sessions": "桥接会话",
"section.return_session": "回迁会话",
"field.provider": "Provider",
"field.base_url": "Base URL",
"field.api_key": "API Key",
"field.saved": "已保存",
"field.config": "配置中",
"field.current": "当前",
"field.threads": "线程数",
"field.provider_name": "Provider 名称",
"field.in_config": "在配置中",
"field.source_provider": "源 Provider",
"field.target_provider": "目标 Provider",
"field.session": "会话",
"field.source": "源",
"field.target": "目标",
"field.status": "状态",
"field.moved": "已迁移",
"field.selected_session": "选中的会话",
"checkbox.show_api_key": "显示 API Key",
"checkbox.switch_current_to_target_after_start": "开始后把当前 Provider 切到目标",
"checkbox.switch_current_to_target_after_move": "迁移后把当前 Provider 切到目标",
"checkbox.move_new_target_threads": "把会话开始后在目标下新建的线程一起迁回",
"checkbox.switch_current_back_after_return": "回迁后把当前 Provider 切回源",
"button.save_update": "保存 / 更新",
"button.save_switch": "保存并切换",
"button.switch_form": "使用表单切换",
"button.delete_saved_entry": "删除已保存项",
"button.open_provider_store": "打开 Provider 存储",
"button.load_current_provider": "载入当前 Provider",
"button.start_session": "开始会话",
"button.move_all_source_threads": "迁移源 Provider 全部线程",
"button.return_selected_session": "回迁所选会话",
"status.ready": "就绪",
"status.checking_process": "正在检查 Codex 进程...",
"status.refreshed": "已刷新",
"status.app_started": "程序已启动",
"misc.none": "未设置",
"misc.no_process_detected": "未检测到 Codex 进程",
"misc.yes": "是",
"misc.no": "否",
"misc.runtime_suffix": "运行中",
"session_status.open": "进行中",
"session_status.completed": "已完成",
"note.provider_default": "已保存的 Provider 存在 provider_store.json 中。删除保存项不会删除聊天线程。",
"note.provider_runtime_key": "这个 Key 当前来自活动 Provider 的 auth.json。若想以后快速切换时继续使用,点击“保存 / 更新”即可保存到本地。",
"note.provider_saved_entry": "这个 Provider 已有本地保存项。删除它只会删除保存项,不会删除聊天历史,也不会删掉现有 config 块。",
"note.provider_config_only": "这个 Provider 当前只存在于 Codex 配置中。如果你想记住它的 URL 和 Key 方便快速切换,可以在这里保存。",
"note.bridge_hint": "说明:Codex 的聊天按 model_provider 分线。\n这个工具做的是“迁移/回迁”,不是物理复制两份线程。",
"action_label.running_migration": "迁移",
"action_label.switching_providers": "切换 Provider",
"error.open_path": "无法打开路径:\n{path}\n\n{error}",
"warn.codex_active": "Codex 似乎仍在运行:\n\n{processes}\n\n请先完全关闭 Codex Desktop,再执行“{action_label}”。",
"error.missing_required_files": "缺少必要文件:\n\n{files}",
"error.sqlite_not_ready": "SQLite 当前无法进行写入操作。\n\n{error}",
"error.missing_required_config": "缺少必要配置:\n\n{path}",
"error.enter_provider_and_url": "请同时填写 Provider 名称和 Base URL。",
"error.save_provider_failed": "保存 Provider 失败。\n\n{error}",
"error.choose_or_enter_provider": "请先选择或输入一个 Provider。",
"error.enter_url_before_switch": "切换前请先填写 Base URL。",
"error.switch_provider_failed": "切换 Provider 失败。\n\n{error}",
"error.choose_provider_first": "请先选择一个 Provider。",
"error.provider_has_no_saved_entry": "这个 Provider 没有本地保存项。\n\n当前版本只删除保存项,不会删除 config 块。",
"error.saved_provider_missing": "找不到要删除的已保存项。",
"error.full_backup_failed": "完整备份失败。\n\n{error}",
"error.choose_source_target": "请选择源 Provider 和目标 Provider。",
"error.source_target_different": "源 Provider 和目标 Provider 不能相同。",
"error.start_bridge_session_failed": "开始桥接会话失败。\n\n{error}",
"error.select_bridge_session_first": "请先选择一个桥接会话。",
"error.bridge_session_completed": "这个桥接会话已经结束了。",
"error.return_bridge_session_failed": "回迁桥接会话失败。\n\n{error}",
"error.direct_move_failed": "迁移线程失败。\n\n{error}",
"error.runtime.bridge_session_not_open": "这个桥接会话已经不能再回迁了。",
"error.runtime.provider_name_empty": "Provider 名称不能为空。",
"error.runtime.base_url_empty": "Base URL 不能为空。",
"error.runtime.base_url_invalid": "Base URL 必须以 http:// 或 https:// 开头,并且要包含主机名。",
"confirm.no_api_key_continue": "表单里没有 API Key。\n\n程序会更新 Provider 配置和当前 Provider,但不会改动 auth.json。\n\n要继续吗?",
"confirm.switch_provider": "确认把当前 Provider 切换为“{provider}”?\n\nBase URL: {base_url}\n\n程序会先备份 config/auth/provider store。",
"confirm.delete_saved_provider": "确认删除“{provider}”的已保存项?\n\n这不会删除聊天线程,也不会从 config.toml 移除现有的 Provider 配置块。",
"confirm.full_backup_while_active": "Codex 似乎仍在运行。\n\n完整备份仍然可以执行,但复制过程中相关文件可能发生变化。\n\n仍要继续吗?",
"confirm.start_bridge_session": "要开始桥接会话吗?\n\n当前属于“{source}”的全部线程会被迁移到“{target}”。\n程序会先创建关键备份。",
"confirm.return_bridge_session": "要回迁这个桥接会话吗?\n\n记录在“{target}”下的线程会被迁回“{source}”。\n程序会先创建关键备份。",
"confirm.direct_move": "要把“{source}”下的全部线程迁移到“{target}”吗?\n\n程序会先创建关键备份。",
"success.saved_provider_entry": "已保存 Provider:\n{provider}\n\n当前 Provider 未切换。若要落盘切换,请点“保存并切换”或“使用表单切换”。",
"success.provider_switched": "Provider 已切换并验证成功。\n\nProvider: {provider}\nAuth 已更新: {auth_updated}\n配置文件: {config_path}\n备份目录: {backup_dir}",
"success.full_backup_created": "已创建完整备份:\n{target}",
"success.bridge_session_started": "桥接会话已开始。\n\n会话: {session_id}\n已迁移线程: {moved_thread_count}",
"success.bridge_session_returned": "桥接会话已回迁。\n\n恢复线程: {restored_thread_count}\n回迁新增线程: {new_thread_count}",
"success.direct_move_finished": "直接迁移完成。\n\n已迁移线程: {changed}\n备份目录: {backup_dir}",
"log.saved_provider": "已保存 Provider {provider}({api_key_mode} API Key)",
"log.switched_provider": "已切换到 Provider {provider}({auth_mode})",
"log.deleted_saved_provider": "已删除 Provider {provider} 的本地保存项",
"log.full_backup_created": "已创建完整备份:{target}",
"log.started_bridge_session": "已开始桥接会话 {session_id}:迁移 {moved_thread_count} 个线程 {source} -> {target}",
"log.completed_bridge_session": "已完成桥接会话 {session_id}:恢复 {restored_thread_count} 个线程,并回迁 {new_thread_count} 个新线程",
"log.direct_move_finished": "直接迁移完成:已迁移 {changed} 个线程 {source} -> {target}",
"phrase.with_api_key": "含",
"phrase.without_api_key": "不含",
"phrase.updated_auth": "已更新 auth",
"phrase.config_only": "仅配置",
},
"en_US": {
"app_title": "Codex Chat Bridge",
"label.refresh": "Refresh",
"label.language": "Language",
"label.codex_home": "Codex Home:",
"label.current_provider": "Current Provider:",
"label.codex_process": "Codex Process:",
"tab.chat_bridge": "Chat Bridge",
"tab.providers": "Providers",
"action.backup_full": "Backup Full .codex",
"action.open_session_folder": "Open Session Folder",
"action.open_backup_folder": "Open Backup Folder",
"section.log": "Log",
"section.saved_configured_providers": "Saved and Configured Providers",
"section.provider_details": "Provider Details",
"section.providers": "Providers",
"section.start_bridge_session": "Start Bridge Session",
"section.direct_move": "Direct Move",
"section.bridge_sessions": "Bridge Sessions",
"section.return_session": "Return Session",
"field.provider": "Provider",
"field.base_url": "Base URL",
"field.api_key": "API Key",
"field.saved": "Saved",
"field.config": "Config",
"field.current": "Current",
"field.threads": "Threads",
"field.provider_name": "Provider Name",
"field.in_config": "In Config",
"field.source_provider": "Source Provider",
"field.target_provider": "Target Provider",
"field.session": "Session",
"field.source": "Source",
"field.target": "Target",
"field.status": "Status",
"field.moved": "Moved",
"field.selected_session": "Selected Session",
"checkbox.show_api_key": "Show API key",
"checkbox.switch_current_to_target_after_start": "Switch current provider to target after start",
"checkbox.switch_current_to_target_after_move": "Switch current provider to target after move",
"checkbox.move_new_target_threads": "Move new target threads created after the session started",
"checkbox.switch_current_back_after_return": "Switch current provider back to source after return",
"button.save_update": "Save / Update",
"button.save_switch": "Save and Switch",
"button.switch_form": "Switch Using Form",
"button.delete_saved_entry": "Delete Saved Entry",
"button.open_provider_store": "Open Provider Store",
"button.load_current_provider": "Load Current Provider",
"button.start_session": "Start Session",
"button.move_all_source_threads": "Move All Source Threads",
"button.return_selected_session": "Return Selected Session",
"status.ready": "Ready",
"status.checking_process": "Checking Codex process...",
"status.refreshed": "Refreshed",
"status.app_started": "App started",
"misc.none": "(none)",
"misc.no_process_detected": "No Codex process detected",
"misc.yes": "Yes",
"misc.no": "No",
"misc.runtime_suffix": "runtime",
"session_status.open": "Open",
"session_status.completed": "Completed",
"note.provider_default": "Saved providers live in provider_store.json. Deleting a saved entry will not remove chat threads.",
"note.provider_runtime_key": "This key currently comes from auth.json for the active provider. Click Save / Update if you want to keep it for quick switching later.",
"note.provider_saved_entry": "This provider has a saved local entry. Deleting it will only remove the saved entry, not chat history or the existing config block.",
"note.provider_config_only": "This provider currently exists only in Codex config. Save it here if you want a remembered URL/key for quick switching.",
"note.bridge_hint": "Note: Codex chats are separated by model_provider.\nThis tool does migration / return migration, not physical duplication of threads.",
"action_label.running_migration": "running a migration",
"action_label.switching_providers": "switching providers",
"error.open_path": "Cannot open path:\n{path}\n\n{error}",
"warn.codex_active": "Codex still looks active:\n\n{processes}\n\nPlease close Codex Desktop completely before {action_label}.",
"error.missing_required_files": "Missing required files:\n\n{files}",
"error.sqlite_not_ready": "SQLite is not ready for write operations.\n\n{error}",
"error.missing_required_config": "Missing required config:\n\n{path}",
"error.enter_provider_and_url": "Please enter both provider name and base URL.",
"error.save_provider_failed": "Could not save provider entry.\n\n{error}",
"error.choose_or_enter_provider": "Please choose or enter a provider first.",
"error.enter_url_before_switch": "Please enter a base URL before switching.",
"error.switch_provider_failed": "Could not switch provider.\n\n{error}",
"error.choose_provider_first": "Please choose a provider first.",
"error.provider_has_no_saved_entry": "This provider does not have a saved local entry.\n\nCurrent version only deletes saved entries and does not remove config blocks.",
"error.saved_provider_missing": "The saved provider entry could not be found.",
"error.full_backup_failed": "Full backup failed.\n\n{error}",
"error.choose_source_target": "Please choose both source and target providers.",
"error.source_target_different": "Source and target providers must be different.",
"error.start_bridge_session_failed": "Could not start bridge session.\n\n{error}",
"error.select_bridge_session_first": "Please select a bridge session first.",
"error.bridge_session_completed": "That bridge session is already completed.",
"error.return_bridge_session_failed": "Could not return bridge session.\n\n{error}",
"error.direct_move_failed": "Could not move threads.\n\n{error}",
"error.runtime.bridge_session_not_open": "This bridge session is not open anymore.",
"error.runtime.provider_name_empty": "Provider name cannot be empty.",
"error.runtime.base_url_empty": "Base URL cannot be empty.",
"error.runtime.base_url_invalid": "Base URL must start with http:// or https:// and include a host.",
"confirm.no_api_key_continue": "No API key is set in the form.\n\nThe provider block and current provider will be updated, but auth.json will be left unchanged.\n\nContinue?",
"confirm.switch_provider": "Switch current provider to '{provider}'?\n\nBase URL: {base_url}\n\nA backup of config/auth/provider store will be created first.",
"confirm.delete_saved_provider": "Delete the saved entry for '{provider}'?\n\nThis will not remove chat threads or the existing provider block from config.toml.",
"confirm.full_backup_while_active": "Codex looks active.\n\nA full backup can still work, but files may change while copying.\n\nContinue anyway?",
"confirm.start_bridge_session": "Start a bridge session?\n\nAll threads currently under '{source}' will be moved to '{target}'.\nA critical backup will be created first.",
"confirm.return_bridge_session": "Return bridge session?\n\nThreads recorded in '{target}' will be moved back to '{source}'.\nA critical backup will be created first.",
"confirm.direct_move": "Move all threads from '{source}' to '{target}'?\n\nA critical backup will be created first.",
"success.saved_provider_entry": "Saved provider entry:\n{provider}\n\nThe current provider was not switched. Use Save and Switch or Switch Using Form to write the change to config.toml.",
"success.provider_switched": "Provider switched and verified.\n\nProvider: {provider}\nAuth updated: {auth_updated}\nConfig path: {config_path}\nBackup: {backup_dir}",
"success.full_backup_created": "Full backup created:\n{target}",
"success.bridge_session_started": "Bridge session started.\n\nSession: {session_id}\nMoved threads: {moved_thread_count}",
"success.bridge_session_returned": "Bridge session returned.\n\nRestored threads: {restored_thread_count}\nNew threads moved back: {new_thread_count}",
"success.direct_move_finished": "Direct move finished.\n\nMoved threads: {changed}\nBackup: {backup_dir}",
"log.saved_provider": "Saved provider {provider} ({api_key_mode} API key)",
"log.switched_provider": "Switched provider to {provider} ({auth_mode})",
"log.deleted_saved_provider": "Deleted saved provider entry for {provider}",
"log.full_backup_created": "Created full backup at {target}",
"log.started_bridge_session": "Started bridge session {session_id}: {moved_thread_count} threads moved {source} -> {target}",
"log.completed_bridge_session": "Completed bridge session {session_id}: restored {restored_thread_count} and moved {new_thread_count} new threads",
"log.direct_move_finished": "Direct move finished: {changed} threads moved {source} -> {target}",
"phrase.with_api_key": "with",
"phrase.without_api_key": "without",
"phrase.updated_auth": "updated auth",
"phrase.config_only": "config only",
},
}
def normalize_language(value: str | None) -> str:
candidate = str(value or "").strip()
return candidate if candidate in I18N else DEFAULT_LANGUAGE
def language_label(code: str) -> str:
return LANGUAGE_LABELS.get(normalize_language(code), LANGUAGE_LABELS[DEFAULT_LANGUAGE])
def language_code_from_label(label: str) -> str:
raw = str(label or "").strip()
for code, value in LANGUAGE_LABELS.items():
if raw == value:
return code
return DEFAULT_LANGUAGE
def translate(language: str, key: str, **kwargs) -> str:
lang = normalize_language(language)
template = I18N.get(lang, {}).get(key) or I18N["en_US"].get(key) or key
try:
return template.format(**kwargs)
except Exception:
return template
@dataclass
class BridgePaths:
codex_home: Path
workspace_root: Path
bridge_root: Path
backup_root: Path
session_root: Path
log_root: Path
settings_path: Path
config_path: Path
auth_path: Path
provider_store_path: Path
db_path: Path
wal_path: Path
shm_path: Path
def resolve_workspace_root() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def app_paths(codex_home: Path | None = None) -> BridgePaths:
workspace_root = resolve_workspace_root()
bridge_root = workspace_root / "artifacts" / "codex-chat-bridge"
backup_root = bridge_root / "backups"
session_root = bridge_root / "sessions"
log_root = bridge_root / "logs"
for folder in (bridge_root, backup_root, session_root, log_root):
folder.mkdir(parents=True, exist_ok=True)
actual_codex_home = codex_home or (Path.home() / ".codex")
return BridgePaths(
codex_home=actual_codex_home,
workspace_root=workspace_root,
bridge_root=bridge_root,
backup_root=backup_root,
session_root=session_root,
log_root=log_root,
settings_path=bridge_root / "app_settings.json",
config_path=actual_codex_home / "config.toml",
auth_path=actual_codex_home / "auth.json",
provider_store_path=bridge_root / "provider_store.json",
db_path=actual_codex_home / "state_5.sqlite",
wal_path=actual_codex_home / "state_5.sqlite-wal",
shm_path=actual_codex_home / "state_5.sqlite-shm",
)
def iso_now() -> str:
return datetime.now().isoformat(timespec="seconds")
def ts_now() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S")
def load_text(path: Path) -> str:
return path.read_text(encoding="utf-8") if path.exists() else ""
def atomic_write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = path.with_name(f"{path.name}.tmp-{uuid.uuid4().hex}")
try:
temp_path.write_text(content, encoding="utf-8")
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()
def save_text(path: Path, content: str) -> None:
atomic_write_text(path, content)
def load_json_file(path: Path, default):
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def save_json_file(path: Path, payload: object) -> None:
save_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
def safe_filename_component(value: str) -> str:
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(value or "").strip())
cleaned = cleaned.rstrip(" .")
return cleaned or "item"
def load_app_settings(path: Path) -> dict[str, object]:
payload = load_json_file(path, {})
return payload if isinstance(payload, dict) else {}
def save_app_settings(path: Path, payload: dict[str, object]) -> None:
save_json_file(path, payload)
def append_log(paths: BridgePaths, message: str) -> None:
log_path = paths.log_root / f"{datetime.now():%Y-%m}.log"
with log_path.open("a", encoding="utf-8") as fh:
fh.write(f"[{iso_now()}] {message}\n")
def is_blocking_codex_process(image_name: str) -> bool:
normalized = str(image_name or "").strip().lower()
if not normalized:
return False
ignored_names = {
"codexchatbridge.exe",
"codex-chat-bridge.exe",
Path(sys.executable).name.lower(),
Path(sys.argv[0]).name.lower(),
}
if normalized in ignored_names:
return False
if "bridge" in normalized:
return False
return "codex" in normalized
def detect_codex_processes() -> list[str]:
try:
output = subprocess.check_output(
["tasklist", "/FO", "CSV", "/NH"],
text=True,
encoding="utf-8",
errors="ignore",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
return []
found: list[str] = []
for row in csv.reader(output.splitlines()):
if not row:
continue
image_name = row[0].strip()
if is_blocking_codex_process(image_name):
found.append(image_name)
return sorted(set(found))
def ensure_sqlite_ready(db_path: Path) -> None:
conn = sqlite3.connect(str(db_path), timeout=1.0)
try:
cur = conn.cursor()
cur.execute("PRAGMA busy_timeout = 1000")
cur.execute("BEGIN IMMEDIATE")
conn.rollback()
finally:
conn.close()
def sqlite_connect(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path), timeout=5.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
return conn
def get_thread_counts(db_path: Path) -> dict[str, int]:
conn = sqlite_connect(db_path)
try:
cur = conn.cursor()
cur.execute(
"select model_provider, count(*) as cnt from threads group by model_provider order by model_provider"
)
return {str(row["model_provider"]): int(row["cnt"]) for row in cur.fetchall()}
finally:
conn.close()
def list_thread_ids(db_path: Path, provider: str) -> list[str]:
conn = sqlite_connect(db_path)
try:
cur = conn.cursor()
cur.execute(
"select id from threads where model_provider = ? order by updated_at desc, id asc",
(provider,),
)
return [str(row["id"]) for row in cur.fetchall()]
finally:
conn.close()
def list_new_thread_ids_since(db_path: Path, provider: str, started_at: int, exclude_ids: set[str]) -> list[str]:
conn = sqlite_connect(db_path)
try:
cur = conn.cursor()
cur.execute(
"""
select id
from threads
where model_provider = ?
and created_at >= ?
order by created_at asc, id asc
""",
(provider, started_at),
)
result = []
for row in cur.fetchall():
thread_id = str(row["id"])
if thread_id not in exclude_ids:
result.append(thread_id)
return result
finally:
conn.close()
def update_thread_provider(db_path: Path, thread_ids: Iterable[str], target_provider: str) -> int:
ids = [thread_id for thread_id in thread_ids if thread_id]
if not ids:
return 0
conn = sqlite_connect(db_path)
changed = 0
try:
cur = conn.cursor()
cur.execute("BEGIN IMMEDIATE")
for start in range(0, len(ids), CHUNK_SIZE):
chunk = ids[start:start + CHUNK_SIZE]
placeholders = ",".join("?" for _ in chunk)
sql = f"update threads set model_provider = ? where id in ({placeholders})"
cur.execute(sql, [target_provider, *chunk])
changed += cur.rowcount
conn.commit()
return changed
except Exception:
conn.rollback()
raise
finally:
conn.close()
def extract_provider_names_from_config(config_text: str) -> list[str]:
try:
data = tomllib.loads(config_text)
providers = data.get("model_providers", {})
if isinstance(providers, dict):
return sorted(str(name).strip() for name in providers.keys() if str(name).strip())
except Exception:
pass
names: list[str] = []
for match in re.finditer(r'(?m)^\[model_providers\.(?:"([^"]+)"|([^\]\r\n]+))\]$', config_text):
provider = match.group(1) or match.group(2) or ""
provider = provider.strip()
if provider:
names.append(provider)
return sorted(set(names))
def get_current_provider(config_path: Path) -> str:
raw = load_text(config_path)
try:
data = tomllib.loads(raw)
current = data.get("model_provider", "")
return str(current).strip() if current else ""
except Exception:
match = re.search(r'(?m)^model_provider = "([^"]+)"$', raw)
return match.group(1) if match else ""
def find_provider_block(config_text: str, provider: str) -> tuple[str | None, tuple[int, int] | None]:
patterns = [
rf'(?ms)^\[model_providers\.{re.escape(provider)}\]\r?\n.*?(?=^\[|\Z)',
rf'(?ms)^\[model_providers\."{re.escape(provider)}"\]\r?\n.*?(?=^\[|\Z)',
]
for pattern in patterns:
match = re.search(pattern, config_text)
if match:
return match.group(0), match.span()
return None, None
def provider_settings_from_block(block: str, provider: str) -> dict[str, object]:
settings: dict[str, object] = {
"provider": provider,
"name": provider,
"base_url": "",
"wire_api": "responses",
"requires_openai_auth": True,
}
for line in block.splitlines()[1:]:
match = re.match(r'^\s*([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$', line)
if not match:
continue
key, value = match.group(1), match.group(2)
if value.startswith('"') and value.endswith('"'):
parsed_value: object = value[1:-1]
elif value in ("true", "false"):
parsed_value = value == "true"
else:
parsed_value = value
settings[key] = parsed_value
return settings
def load_config_provider_map(config_path: Path) -> dict[str, dict[str, object]]:
raw = load_text(config_path)
result: dict[str, dict[str, object]] = {}
try:
data = tomllib.loads(raw)
providers = data.get("model_providers", {})
if isinstance(providers, dict):
for key, value in providers.items():
provider_key = str(key).strip()
if not provider_key:
continue
if isinstance(value, dict):
result[provider_key] = {
"provider": provider_key,
"name": str(value.get("name") or provider_key),
"base_url": str(value.get("base_url") or ""),
"wire_api": str(value.get("wire_api") or "responses"),
"requires_openai_auth": bool(value.get("requires_openai_auth", True)),
}
else:
result[provider_key] = {
"provider": provider_key,
"name": provider_key,
"base_url": "",
"wire_api": "responses",
"requires_openai_auth": True,
}
return result
except Exception:
pass
for provider in extract_provider_names_from_config(raw):
block, _ = find_provider_block(raw, provider)
if block:
result[provider] = provider_settings_from_block(block, provider)
return result
def render_provider_block(
provider: str,
base_url: str,
existing_block: str | None = None,
wire_api: str = "responses",
requires_openai_auth: bool = True,
) -> str:
line_ending = "\r\n" if existing_block and "\r\n" in existing_block else "\n"
preserved_lines: list[str] = []
if existing_block:
for line in existing_block.splitlines()[1:]:
if re.match(r'^\s*(name|base_url|wire_api|requires_openai_auth)\s*=', line):
continue
preserved_lines.append(line)
lines = [
f'[model_providers."{provider}"]',
f'name = "{provider}"',
f'base_url = "{base_url}"',
f'wire_api = "{wire_api}"',
f'requires_openai_auth = {"true" if requires_openai_auth else "false"}',
]
lines.extend(preserved_lines)
return line_ending.join(lines).rstrip() + line_ending
def minimal_provider_block(provider: str, base_url: str = "https://api.openai.com/v1") -> str:
return (
f'\n[model_providers."{provider}"]\n'
f'name = "{provider}"\n'
f'base_url = "{base_url}"\n'
'wire_api = "responses"\n'
'requires_openai_auth = true\n'
)
def upsert_provider_in_config(
config_path: Path,
provider: str,
base_url: str,
wire_api: str = "responses",
requires_openai_auth: bool = True,
) -> None:
raw = load_text(config_path)
block, span = find_provider_block(raw, provider)
rendered = render_provider_block(provider, base_url, block, wire_api=wire_api, requires_openai_auth=requires_openai_auth)
if span:
updated = raw[:span[0]] + rendered + raw[span[1]:]
else:
updated = raw.rstrip() + "\n\n" + rendered.strip("\n") + "\n"
save_text(config_path, updated)
def ensure_provider_exists(config_path: Path, source_provider: str, target_provider: str) -> None:
raw = load_text(config_path)
target_block, _ = find_provider_block(raw, target_provider)
if target_block:
return
source_block, _ = find_provider_block(raw, source_provider)
if source_block:
source_settings = provider_settings_from_block(source_block, source_provider)
base_url = str(source_settings.get("base_url") or "https://api.openai.com/v1")
wire_api = str(source_settings.get("wire_api") or "responses")
requires_openai_auth = bool(source_settings.get("requires_openai_auth", True))
else:
base_url = "https://api.openai.com/v1"
wire_api = "responses"
requires_openai_auth = True
upsert_provider_in_config(
config_path,
target_provider,
base_url,
wire_api=wire_api,
requires_openai_auth=requires_openai_auth,
)
def set_current_provider(config_path: Path, provider: str) -> None:
raw = load_text(config_path)
if re.search(r'(?m)^model_provider = "([^"]+)"$', raw):
updated = re.sub(r'(?m)^model_provider = "([^"]+)"$', f'model_provider = "{provider}"', raw, count=1)
else:
updated = f'model_provider = "{provider}"\n' + raw
save_text(config_path, updated)
def load_provider_store(path: Path) -> dict[str, dict[str, object]]:
raw = load_json_file(path, {})
providers = raw.get("providers") if isinstance(raw, dict) else {}
if not isinstance(providers, dict):
providers = raw if isinstance(raw, dict) else {}
result: dict[str, dict[str, object]] = {}
for provider, entry in providers.items():
provider_key = str(provider).strip()
if not provider_key or not isinstance(entry, dict):
continue
result[provider_key] = {
"base_url": str(entry.get("base_url") or ""),
"api_key": str(entry.get("api_key") or ""),
"updated_at_iso": str(entry.get("updated_at_iso") or ""),
}
return result
def save_provider_store(path: Path, providers: dict[str, dict[str, object]]) -> None:
payload = {
"version": 1,
"providers": dict(sorted(providers.items(), key=lambda item: item[0].lower())),
}
save_json_file(path, payload)
def save_provider_entry(path: Path, provider: str, base_url: str, api_key: str) -> None:
providers = load_provider_store(path)
providers[provider] = {
"base_url": base_url,
"api_key": api_key,
"updated_at_iso": iso_now(),
}
save_provider_store(path, providers)
def delete_provider_entry(path: Path, provider: str) -> bool:
providers = load_provider_store(path)
if provider not in providers:
return False
del providers[provider]
save_provider_store(path, providers)
return True
def load_auth_payload(path: Path) -> dict[str, object]:
payload = load_json_file(path, {})
return payload if isinstance(payload, dict) else {}
def get_runtime_api_key(path: Path) -> str:
payload = load_auth_payload(path)
value = payload.get("OPENAI_API_KEY", "")
return str(value).strip() if value else ""
def write_runtime_api_key(path: Path, api_key: str) -> None:
payload = load_auth_payload(path)
payload["OPENAI_API_KEY"] = api_key
save_json_file(path, payload)
def mask_secret(secret: str) -> str:
if not secret:
return ""
if len(secret) <= 8:
return "*" * len(secret)
return f"{secret[:4]}...{secret[-4:]}"
def validate_provider_url(value: str) -> bool:
parsed = urlparse(value.strip())
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def normalize_url_for_compare(value: str) -> str:
return str(value or "").strip().rstrip("/")
def build_provider_catalog(paths: BridgePaths, include_threads: bool = True) -> dict[str, dict[str, object]]:
config_entries = load_config_provider_map(paths.config_path)
saved_entries = load_provider_store(paths.provider_store_path)
thread_counts = get_thread_counts(paths.db_path) if include_threads and paths.db_path.exists() else {}
current_provider = get_current_provider(paths.config_path)
runtime_key = get_runtime_api_key(paths.auth_path)
catalog: dict[str, dict[str, object]] = {}
all_names = sorted(set(config_entries.keys()) | set(saved_entries.keys()) | set(thread_counts.keys()))
for provider in all_names:
config_entry = config_entries.get(provider, {})
saved_entry = saved_entries.get(provider, {})
saved_key = str(saved_entry.get("api_key") or "")
runtime_key_for_provider = runtime_key if provider == current_provider and not saved_key else ""
effective_base_url = str(saved_entry.get("base_url") or config_entry.get("base_url") or "")
catalog[provider] = {
"provider": provider,
"base_url": effective_base_url,
"config_base_url": str(config_entry.get("base_url") or ""),
"saved_base_url": str(saved_entry.get("base_url") or ""),
"api_key": saved_key or runtime_key_for_provider,
"saved_api_key": saved_key,
"key_source": "saved" if saved_key else ("runtime" if runtime_key_for_provider else ""),
"in_config": provider in config_entries,
"in_store": provider in saved_entries,
"current": provider == current_provider,
"threads": int(thread_counts.get(provider, 0)),
"wire_api": str(config_entry.get("wire_api") or "responses"),
"requires_openai_auth": bool(config_entry.get("requires_openai_auth", True)),
}
return catalog
def backup_selected_files(paths: BridgePaths, label: str, sources: Iterable[Path]) -> Path:
target = paths.backup_root / f"{ts_now()}-{safe_filename_component(label)}"
target.mkdir(parents=True, exist_ok=True)
for src in sources:
if src.exists():
shutil.copy2(src, target / src.name)
append_log(paths, f"Created backup at {target}")
return target
def backup_critical_files(paths: BridgePaths, label: str) -> Path:
return backup_selected_files(paths, label, (paths.config_path, paths.db_path, paths.wal_path, paths.shm_path))
def backup_provider_files(paths: BridgePaths, label: str) -> Path:
return backup_selected_files(paths, label, (paths.config_path, paths.auth_path, paths.provider_store_path))
def backup_full_codex(paths: BridgePaths) -> Path:
target = paths.backup_root / f"{ts_now()}-full-codex"
shutil.copytree(paths.codex_home, target)
append_log(paths, f"Created full Codex backup at {target}")
return target
def session_file(paths: BridgePaths, session_id: str) -> Path:
return paths.session_root / f"{session_id}.json"
def load_session_records(paths: BridgePaths) -> list[dict]:
records: list[dict] = []
for path in sorted(paths.session_root.glob("*.json"), reverse=True):
try:
data = json.loads(path.read_text(encoding="utf-8"))
data["_file"] = str(path)
records.append(data)
except Exception:
continue
return records
def save_session_record(paths: BridgePaths, record: dict) -> Path:
path = session_file(paths, record["session_id"])
path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def create_bridge_session(
paths: BridgePaths,
source_provider: str,
target_provider: str,
switch_current: bool,
) -> dict:
ensure_sqlite_ready(paths.db_path)
backup_dir = backup_critical_files(paths, f"bridge-start-{source_provider}-to-{target_provider}")
started_at = int(time.time())
moved_thread_ids = list_thread_ids(paths.db_path, source_provider)
changed = update_thread_provider(paths.db_path, moved_thread_ids, target_provider)
ensure_provider_exists(paths.config_path, source_provider, target_provider)
if switch_current:
set_current_provider(paths.config_path, target_provider)
record = {
"session_id": f"{datetime.now():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:8]}",
"created_at_iso": iso_now(),
"started_at": started_at,
"source_provider": source_provider,
"target_provider": target_provider,
"switch_current_on_start": switch_current,
"status": "open",
"moved_thread_ids": moved_thread_ids,
"moved_thread_count": changed,
"backup_dir": str(backup_dir),
}
save_session_record(paths, record)
append_log(
paths,
f"Started bridge session {record['session_id']}: moved {changed} threads from {source_provider} to {target_provider}",
)
return record
def complete_bridge_session(
paths: BridgePaths,
record: dict,
switch_current_back: bool,
include_new_target_threads: bool,
) -> dict:
if record.get("status") != "open":
raise RuntimeError("This bridge session is not open anymore.")
source_provider = str(record["source_provider"])
target_provider = str(record["target_provider"])
started_at = int(record["started_at"])
moved_thread_ids = [str(item) for item in record.get("moved_thread_ids", [])]
ensure_sqlite_ready(paths.db_path)
backup_dir = backup_critical_files(paths, f"bridge-return-{target_provider}-to-{source_provider}")
restored_count = update_thread_provider(paths.db_path, moved_thread_ids, source_provider)
new_thread_ids: list[str] = []
new_thread_count = 0
if include_new_target_threads:
exclude_ids = set(moved_thread_ids)
new_thread_ids = list_new_thread_ids_since(paths.db_path, target_provider, started_at, exclude_ids)
new_thread_count = update_thread_provider(paths.db_path, new_thread_ids, source_provider)
ensure_provider_exists(paths.config_path, target_provider, source_provider)
if switch_current_back:
set_current_provider(paths.config_path, source_provider)
record["status"] = "completed"
record["completed_at_iso"] = iso_now()
record["switch_current_on_return"] = switch_current_back
record["include_new_target_threads"] = include_new_target_threads
record["restored_thread_count"] = restored_count
record["new_thread_ids"] = new_thread_ids
record["new_thread_count"] = new_thread_count
record["return_backup_dir"] = str(backup_dir)
save_session_record(paths, record)
append_log(
paths,
f"Completed bridge session {record['session_id']}: restored {restored_count} threads and moved {new_thread_count} new threads from {target_provider} back to {source_provider}",
)
return record
def direct_move(
paths: BridgePaths,
source_provider: str,
target_provider: str,
switch_current: bool,
) -> dict:
ensure_sqlite_ready(paths.db_path)
backup_dir = backup_critical_files(paths, f"direct-move-{source_provider}-to-{target_provider}")
thread_ids = list_thread_ids(paths.db_path, source_provider)
changed = update_thread_provider(paths.db_path, thread_ids, target_provider)
ensure_provider_exists(paths.config_path, source_provider, target_provider)
if switch_current:
set_current_provider(paths.config_path, target_provider)
append_log(
paths,
f"Direct move: moved {changed} threads from {source_provider} to {target_provider}",
)
return {
"backup_dir": str(backup_dir),
"changed": changed,
"source_provider": source_provider,
"target_provider": target_provider,
}