将CPA的Codex凭据转到OpenCode

如题所示,CLI Proxy API,也就是 CPA,它是支持导出已登录的 Codex 凭据的,我们只要稍作调整即可适配到 OpenCode 上。 到 CPA 下载 Codex 凭据: 运行下面的代码,需要 Python 3.10+ 神秘代码: """Convert CPA/CLIProxyAPI ...
将CPA的Codex凭据转到OpenCode
将CPA的Codex凭据转到OpenCode

如题所示,CLI Proxy API,也就是 CPA,它是支持导出已登录的 Codex 凭据的,我们只要稍作调整即可适配到 OpenCode 上。

  1. 到 CPA 下载 Codex 凭据:
    image
  2. 运行下面的代码,需要 Python 3.10+

神秘代码:

"""Convert CPA/CLIProxyAPI Codex OAuth credentials to opencode auth.json."""

from __future__ import annotations

import json
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import unquote, urlparse


DEFAULT_PROVIDER_ID = "openai"
REQUIRED_FIELDS = ("access_token", "refresh_token", "expired", "account_id")


def mask_token(value: object) -> str:
    if not isinstance(value, str) or not value:
        return "<missing>"
    if len(value) <= 12:
        return "******"
    return f"{value[:6]}...{value[-4:]}"


def normalize_path(raw: str) -> Path:
    text = raw.strip()
    if text.startswith("& "):
        text = text[2:].strip()
    if (text.startswith('"') and text.endswith('"')) or (
        text.startswith("'") and text.endswith("'")
    ):
        text = text[1:-1].strip()

    if text.lower().startswith("file:"):
        parsed = urlparse(text)
        text = unquote(parsed.path or "")
        if os.name == "nt" and len(text) >= 3 and text[0] == "/" and text[2] == ":":
            text = text[1:]

    text = os.path.expandvars(os.path.expanduser(text))
    return Path(text).resolve()


def read_json(path: Path) -> dict:
    with path.open("r", encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, dict):
        raise ValueError("JSON 顶层必须是对象。")
    return data


def prompt_credential_path() -> Path:
    while True:
        raw = input("请输入 CPA/Codex 凭据 JSON 路径: ").strip()
        if not raw:
            print("路径不能为空。")
            continue

        path = normalize_path(raw)
        if not path.is_file():
            print(f"未找到文件: {path}")
            continue
        if path.suffix.lower() != ".json":
            print("请输入 .json 文件。")
            continue
        return path


def parse_expired_to_ms(value: object) -> tuple[int, str]:
    if isinstance(value, (int, float)):
        number = int(value)
        expires_ms = number if number > 10_000_000_000 else number * 1000
        dt = datetime.fromtimestamp(expires_ms / 1000, tz=timezone.utc).astimezone()
        return expires_ms, dt.strftime("%Y-%m-%d %H:%M:%S %z")

    if not isinstance(value, str) or not value.strip():
        raise ValueError("expired 字段不能为空。")

    text = value.strip()
    candidates = []
    iso_text = text[:-1] + "+00:00" if text.endswith("Z") else text
    try:
        candidates.append(datetime.fromisoformat(iso_text))
    except ValueError:
        pass

    formats = (
        "%Y-%m-%dT%H:%M:%S%z",
        "%Y-%m-%d %H:%M:%S%z",
        "%Y-%m-%dT%H:%M:%S",
        "%Y-%m-%d %H:%M:%S",
        "%Y/%m/%d %H:%M:%S",
        "%m/%d/%Y %H:%M:%S",
    )
    for fmt in formats:
        try:
            candidates.append(datetime.strptime(text, fmt))
        except ValueError:
            continue

    if not candidates:
        raise ValueError(f"无法解析 expired 时间: {text}")

    dt = candidates[0]
    expires_ms = int(dt.timestamp() * 1000)
    display_dt = dt.astimezone() if dt.tzinfo else datetime.fromtimestamp(dt.timestamp()).astimezone()
    return expires_ms, display_dt.strftime("%Y-%m-%d %H:%M:%S %z")


def ask_yes_no(prompt: str, default: bool = False) -> bool:
    suffix = "[Y/n]" if default else "[y/N]"
    while True:
        answer = input(f"{prompt} {suffix}: ").strip().lower()
        if not answer:
            return default
        if answer in {"y", "yes"}:
            return True
        if answer in {"n", "no"}:
            return False
        print("请输入 y 或 n。")


def default_auth_candidates() -> list[Path]:
    candidates: list[Path] = []
    local_app_data = os.environ.get("LOCALAPPDATA")
    user_profile = os.environ.get("USERPROFILE")

    if local_app_data:
        candidates.append(Path(local_app_data) / "opencode" / "auth.json")
    if user_profile:
        candidates.append(Path(user_profile) / ".local" / "share" / "opencode" / "auth.json")
    return candidates


def prompt_auth_path() -> Path:
    while True:
        raw = input("未找到默认 opencode 目录,请输入 auth.json 路径或目标目录: ").strip()
        if not raw:
            print("路径不能为空。")
            continue

        path = normalize_path(raw)
        if path.exists() and path.is_dir():
            return path / "auth.json"
        if path.suffix.lower() == ".json":
            target = path
            target_dir = path.parent
        else:
            target_dir = path
            target = target_dir / "auth.json"

        if target_dir.exists():
            return target

        if ask_yes_no(f"目标目录不存在,是否创建?{target_dir}"):
            target_dir.mkdir(parents=True, exist_ok=True)
            return target
        print("已取消使用该路径,请重新输入。")


def resolve_auth_path() -> Path:
    candidates = default_auth_candidates()

    for candidate in candidates:
        if candidate.is_file():
            return candidate
    for candidate in candidates:
        if candidate.parent.is_dir():
            return candidate

    return prompt_auth_path()


def load_auth(path: Path) -> dict:
    if not path.exists():
        return {}
    data = read_json(path)
    return data


def backup_auth(path: Path) -> Path | None:
    if not path.exists():
        return None
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_path = path.with_name(f"{path.name}.bak-{timestamp}")
    shutil.copy2(path, backup_path)
    return backup_path


def write_auth(path: Path, auth_data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="\n") as f:
        json.dump(auth_data, f, ensure_ascii=False, indent=2)
        f.write("\n")


def validate_credential(data: dict) -> None:
    missing = [field for field in REQUIRED_FIELDS if not data.get(field)]
    if missing:
        raise ValueError("凭据 JSON 缺少必要字段: " + ", ".join(missing))


def main() -> int:
    print("Codex OAuth -> opencode auth.json 转换工具")
    print("安全提示: 本脚本只读取/写入本机文件,不会联网;token 只打码显示。")

    credential_path = prompt_credential_path()
    try:
        credential = read_json(credential_path)
        validate_credential(credential)
        expires_ms, expires_human = parse_expired_to_ms(credential["expired"])
    except Exception as exc:
        print(f"读取或解析凭据失败: {exc}")
        return 1

    provider_id = input(f"请输入 provider ID,直接回车默认 {DEFAULT_PROVIDER_ID}: ").strip()
    if not provider_id:
        provider_id = DEFAULT_PROVIDER_ID

    auth_path = resolve_auth_path()

    print("\n即将写入:")
    print(f"凭据文件: {credential_path}")
    print(f"auth.json: {auth_path}")
    print(f"provider ID: {provider_id}")
    print(f"access_token: {mask_token(credential.get('access_token'))}")
    print(f"refresh_token: {mask_token(credential.get('refresh_token'))}")
    print(f"expires: {expires_human} ({expires_ms})")
    print(f"account_id: {credential.get('account_id')}")
    if credential.get("id_token"):
        print("id_token: 已忽略")
    if credential.get("email"):
        print(f"email: {credential.get('email')}")

    if not ask_yes_no("确认写入?"):
        print("已取消,未写入任何文件。")
        return 0

    try:
        auth_data = load_auth(auth_path)
        backup_path = backup_auth(auth_path)
        auth_data[provider_id] = {
            "type": "oauth",
            "refresh": credential["refresh_token"],
            "access": credential["access_token"],
            "expires": expires_ms,
            "accountId": credential["account_id"],
        }
        write_auth(auth_path, auth_data)
    except Exception as exc:
        print(f"写入失败: {exc}")
        return 1

    print("\n写入完成:")
    print(f"auth.json 路径: {auth_path}")
    print(f"provider ID: {provider_id}")
    print(f"expires: {expires_human}")
    if backup_path:
        print(f"备份: 已创建 {backup_path}")
    else:
        print("备份: 未创建,原 auth.json 不存在")

    return 0


if __name__ == "__main__":
    sys.exit(main())

按照提示输入凭据文件位置就好。

然后你的 C:\Users\用户名\.local\share\opencode\auth.json 大概长这样:

{
  "openai": {
    "type": "oauth",
    "refresh": "",
    "access": "",
    "expires": 1780837337000,
    "accountId": ""
  }
}

这个时候再 opencode/models,就会发现那个 OpenAI 官方标志的 GPT 5.5 了:

image

image

顺带一提,这是直接请求了 OpenAI Codex 官方的后端地址(Cloudflare CDN) https://chatgpt.com/backend-api/codex/responses

image

2 个帖子 - 2 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文