Skip to content

43 行,切一次 API:我给 Claude Code 写了个配置档切换器 ​

我换 Claude Code 的 API,手法一直很土:打开 ~/.claude/settings.json,找到 env,把 ANTHROPIC_BASE_URL、ANTHROPIC_AUTH_TOKEN 那几行改掉,保存,重启。

一次两次还行。但现在我手上有好几套端点,每套要换的不是一个值,而一组互相绑定的值。于是必然会出这种事:URL 换了,token 忘了换;token 换了,模型名还是上一个端点才认识的那个。然后 Claude Code 报一个我看不懂的错,我再回来对着 JSON 一行行排查。

改一个字段是编辑,改一组字段是切换档位——而档位应该按序号选,不该靠手改。

所以我给这件事写了个脚本,43 行。

先说清楚它切的是什么 ​

不是模型。settings.json 的 env 字段是一整块会被注入进程的环境变量,端点、密钥、默认模型名全在这里面。它们不是独立的开关,而是一套必须彼此匹配的组合:

  • 换端点就要换 token,否则认证失败
  • 换端点还要换模型名,否则请求发出去了、对面不认识这个模型
  • 想临时降级到便宜档,往往要动的是这一整套里的两三项

所以这个脚本的定位很明确:把"一整套 env"当成一个配置档,用序号切换。

完整代码 ​

python
# /// script
# requires-python = ">=3.11"
# dependencies = ["PyYAML>=6.0.2,<7"]
# ///

import json
import os
import sys
from pathlib import Path

import yaml

SETTINGS_PATH = Path.home() / ".claude" / "settings.json"


def load_configs() -> dict[str, dict[str, str]]:
    config_path = Path(os.environ["SECRETS49"]) / "claudecode_api.yaml"
    return yaml.safe_load(config_path.read_text(encoding="utf-8"))


def choose_config(configs: dict[str, dict[str, str]]) -> tuple[str, dict[str, str]]:
    names = list(configs)
    print("可用配置:")
    for index, name in enumerate(names, start=1):
        print(f"  {index}. {name}")

    while True:
        choice = input("请选择配置编号:")
        if choice.isdigit() and 1 <= (num := int(choice)) <= len(names):
            return names[num - 1], configs[names[num - 1]]
        print(f"请输入 1 到 {len(names)} 之间的整数。", file=sys.stderr)


def main() -> None:
    name, config = choose_config(load_configs())
    settings = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) if SETTINGS_PATH.exists() else {}
    settings["env"] = settings.get("env", {}) | config
    SETTINGS_PATH.write_text(json.dumps(settings, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(f"已将配置 {name!r} 合并到 {SETTINGS_PATH} 的 env。")


if __name__ == "__main__":
    main()

跑起来是这样:

txt
$ uv run --no-project select_claudecode_api.py
可用配置:
  1. deepseek
  2. xiaomimimo
请选择配置编号:2
已将配置 'xiaomimimo' 合并到 /home/me/.claude/settings.json 的 env。

claudecode_api.yaml 怎么写 ​

脚本只负责搬运,所以这个文件的结构就是它的全部约定:顶层是配置档的名字,二级是环境变量名。

yaml
# $SECRETS49/claudecode_api.yaml
deepseek:
  ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic"
  ANTHROPIC_AUTH_TOKEN: "<token>"
  ANTHROPIC_MODEL: "deepseek-flash[1m]"
  ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek-flash[1m]"
  ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-flash[1m]"
  ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek-flash[1m]"
  CLAUDE_CODE_AUTO_COMPACT_WINDOW: "780000"

xiaomimimo:
  ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic"
  ANTHROPIC_AUTH_TOKEN: "<token>"
  ANTHROPIC_MODEL: "mimo-v2.6-flash[1m]"
  ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2.6-pro[1m]"
  ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2.6-flash[1m]"
  ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2.6-flash[1m]"
  CLAUDE_CODE_AUTO_COMPACT_WINDOW: "780000"

五个要点:

  1. 顶层是名字,二级是变量名。 二级键不做任何转换,原样进 settings.json 的 env,所以那里写的必须是 Claude Code 认识的名字。ANTHROPIC_BASE_URL、ANTHROPIC_AUTH_TOKEN、ANTHROPIC_MODEL、ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL 是主力,除此之外任何它认的配置项都能放进来,比如上面那个 CLAUDE_CODE_AUTO_COMPACT_WINDOW——脚本不知道它是什么,照样搬。
  2. 一个档位是一整套对应关系,不是一条 base url。 看这两档的差别就清楚了:xiaomimimo 那档把 Opus 位留给 pro 模型,其余三位给 flash;deepseek 那档则四位全填同一个。"哪个位放哪个模型"这种映射,恰恰是手改 JSON 时最容易改错的地方——写成档位就只需要写一次。
  3. 按需写,不必抄全。 我这两档都写满了,是因为换了厂商连模型名都整套不一样。但结构上不必如此:靠后面那个 settings.get("env", {}) | config 合并,档位里没提到的键会原样保留——你想让某一档只覆盖 ANTHROPIC_BASE_URL 和 ANTHROPIC_AUTH_TOKEN、其余沿用当前值,完全可以。
  4. 值都是字符串。 [1m] 后缀就直接拼在值里("deepseek-flash[1m]"),脚本不做解析,也不管服务端认不认这个名字。
  5. 文件本身就是秘密。 它放在 SECRETS49 指向的私有目录里,权限收紧到 600;环境变量在 shell 里导出(export SECRETS49="$HOME/secrets")。脚本读的是 os.environ["SECRETS49"],这个变量必须存在——不存在就在第一行 KeyError,这是有意的,配置都找不到就没必要继续。

还有一件事得说明白:脚本不校验二级键名。 把 ANTHROPIC_DEFAULT_SONNET_MODEL 错写成 ANTHROPIC_SONNET_MODEL,它照样把这一行合并进去,然后被 Claude Code 静默忽略——搬运工不负责判断货对不对。

下面是我为什么觉得这 43 行写得好——不是自夸,是我写完之后回头看,发现每一个决定都能对上它要解决的问题。

1. 唯一的依赖,是留给"我"的 ​

python
# /// script
# requires-python = ">=3.11"
# dependencies = ["PyYAML>=6.0.2,<7"]
# ///

PEP-723 的头部声明,uv run 直接执行,不需要 venv、不需要 requirements.txt。这一点我在底栏脚本里也是这么做的:一个文件就是全部。

但这里我破例加了一个依赖:PyYAML。为什么不是更省事的 JSON?因为这份配置是写给我自己看的。

yaml
deepseek:
  ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic"
  ANTHROPIC_MODEL: "deepseek-flash[1m]"
  CLAUDE_CODE_AUTO_COMPACT_WINDOW: "780000"

xiaomimimo:  # 换厂商时模型名整套都不一样
  ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic"
  ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2.6-pro[1m]"

JSON 写不了注释。而"这一档什么时候过期""某个键为什么填这个值""哪个端点在什么时段更快"这类信息,没有一个地方可以落笔——不写下来,三个月后我一定会忘。YAML 能让我顺手写在旁边,所以这一个依赖我认为很值。

顺带两个细节:用的是 yaml.safe_load 而不是 load,配置是数据不是代码,不该有构造任意对象的能力;版本也钉了上界 <7,小工具同样不该被大版本升级随手打断。

2. 密钥不在脚本里,这是全篇最重要的一行 ​

python
config_path = Path(os.environ["SECRETS49"]) / "claudecode_api.yaml"

这个脚本里一个 token 都没有,这也是它现在能光明正大出现在这篇文章里的原因。

配置本体放在 SECRETS49 指向的目录里——那个目录是私有的、不进 git 的。脚本只负责去那里把它读出来。于是同一份脚本可以随便扔进仓库、发给朋友、贴到博客上,因为它不携带任何秘密;而密钥换地方、换文件名,也只改一个环境变量。

写小工具的时候很容易图痛快,把 ~/.secret/api.yaml 这种绝对路径直接写死。路径里没有敏感字面量,和文件里没有敏感内容,是同等重要的两件事。

3. 合并,而不是替换 ​

python
settings["env"] = settings.get("env", {}) | config

这是整个脚本我最满意的一行,做了两件事。

第一件是 |:Python 3.9 起 dict 支持合并运算符。它的语义正是一个配置档切换器该有的语义——配置档里写了的键覆盖掉,没写的键原样留着。我的档位文件只需要声明"这个端点相关的几个键",其他 env 项(比如某个只跟实验有关的开关)不会被顺手抹掉。切档不等于重置。

反过来,如果写成 settings["env"] = config,每切一次档就要在 YAML 里把 env 的所有键都抄全,漏一个就静默丢一个配置——而丢配置的错误,通常要等到几天后某个功能不工作时才浮出来。

第二件是 settings.get("env", {})。它让"文件里还没有 env"这种情况从崩溃变成一个正常的起点。对比一下:我 6 月写的那个模型切换脚本,回头看在这一点上是硬来的——它直接 settings["env"],文件少一个键就 KeyError。同一个作者,隔了三个月,写出的默认值处理会不一样。(顺便,这也解释了为什么我现在更喜欢重写而不是打补丁。)

4. 交互循环:一行完成全部校验 ​

python
while True:
    choice = input("请选择配置编号:")
    if choice.isdigit() and 1 <= (num := int(choice)) <= len(names):
        return names[num - 1], configs[names[num - 1]]
    print(f"请输入 1 到 {len(names)} 之间的整数。", file=sys.stderr)

这段我很喜欢,因为它把"用户输入的是不是合法编号"压进了一个条件表达式。

  • choice.isdigit() 先挡掉空串、字母、空格和负号——"-1".isdigit() 是 False,所以这里连负数都不用单独处理。校验顺序是有讲究的:先证明它是数字,再把它变成数字。
  • (num := int(choice)) 是海象运算符,int() 转换被放在条件内部,num 只在拿到合法值时才会被用到后面。没有"先转、再检查、转错了还要兜"的那套冗余。
  • 1 <= num <= len(names) 是 Python 的链式比较,用户看到的是 1 开始的编号,列表是 0 开始的,这个 ±1 只在这一处出现,names[num - 1] 拿值,不会散落到别处。
  • 错误提示写了 file=sys.stderr:stdout 只留正常信息。这样将来我要把选择结果重定向或接管道,屏幕上不会混进提示语。

一个 while True + 一个 return,没有状态变量,没有重试计数。循环的出口只有一个,而且就在校验通过的那一行。

5. 返回值带上名字 ​

python
def choose_config(configs) -> tuple[str, dict[str, str]]:

它返回 (名字, 内容) 这个元组,而不是只返回内容。多出来的那个字符串,唯一的用途是最后那句回显:

python
print(f"已将配置 {name!r} 合并到 {SETTINGS_PATH} 的 env。")

这看起来是小事,但切换配置最怕的就是"到底切成功没有"。写回之后明确告诉我切到了哪一档、写进了哪个文件,这一步的安心感,抵得上后面少查十次配置文件。 调用方也干净:

python
name, config = choose_config(load_configs())

一行拿到两个值,不用先查表再取值。

6. 写回的三个细节 ​

python
settings = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) if SETTINGS_PATH.exists() else {}
settings["env"] = settings.get("env", {}) | config
SETTINGS_PATH.write_text(json.dumps(settings, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  • if SETTINGS_PATH.exists() else {}:这是脚本里第一次运行的样子——还没有 settings 文件,那就从空 dict 开始,而不是拒绝执行。
  • ensure_ascii=False:文件里有中文时不会被转义成 中文。我这份 settings 里偶尔会有中文备注,转义之后连我自己都不想读。
  • indent=2 加结尾补一个 "\n":每次写回格式完全一致。这条我吃过亏——缩进风格在不同工具间来回横跳时,git diff 会整篇变红,真正的改动淹没在里面。让输出保持位级稳定,是配置工具应有的教养。

7. 类型标注:写给编辑器和三个月后的我 ​

python
def load_configs() -> dict[str, dict[str, str]]:
def choose_config(configs: dict[str, dict[str, str]]) -> tuple[str, dict[str, str]]:

这两行标注把数据结构表达得非常完整:顶层是"配置名 → 环境变量字典"的映射。看到标注就知道 YAML 该长什么样,不用读实现。

用的是内置泛型 dict[str, ...](3.9+),不需要 from typing import Dict。既然头部已经写了 requires-python = ">=3.11",那就用这个版本能给的写法,不为了兼容一个我根本不装的旧版本而多写 import。

边界 ​

夸完了,该说的前提也要说清楚:

  • SECRETS49 必须存在且指向正确目录,否则第一行就 KeyError——这是有意的,配置都找不到就没必要继续。
  • YAML 顶层必须是"名字 → 字典"的结构,脚本只是搬运,不做校验。
  • 合并只发生在 env 的顶层,不做嵌套合并——env 是扁平字典,够用。

结语 ​

43 行里没有一个类是必需的,没有一处抽象是为了将来。

它做的事可以一句话说完:从私有目录读一份配置档,让你用序号选一个,把它合并进 settings.json 的 env,然后告诉你切成了哪一档。

但我觉得它比我 6 月那个模型切换器进了一步——那个脚本是"把选模型这件事变顺手",这个脚本是"把配置当成一个有名字的整体来管理"。区别在于:模型切换只动一个值,切错了立刻看得出来;而 API 配置是一组绑定值,切错了要看半天日志。

对这种"一组值必须一起换"的东西,最好的界面就是一个名字加一个序号。这份代码里我最喜欢的三处,也都是围绕这个想法长出来的:

  • os.environ["SECRETS49"]:秘密和脚本彻底分开,脚本因此可以公开
  • settings.get("env", {}) | config:合并而不是覆盖,切档不动无关配置
  • 1 <= (num := int(choice)) <= len(names):一次输入校验压成一行,±1 只出现一次

写完之后我自己也一直在用。它不是那种"值得做成项目"的代码,但它是我这类小工具里,单位代码量解决真实烦恼最多的一份。

Nine and AI — Licensed under the MIT License