跳到主要内容

模型连通性测试

配好一条凭据之后,最想知道的是"它到底能不能出话"。AI Switch 的模型连通性测试给出的答案是确定的:它发的是一次真实的生成请求,不是探活。

请求内容写死在代码里:

rust
pub const MODEL_TEST_PROMPT: &str = "Reply with exactly: ai-switch-ok";

模型被要求原样回一句 ai-switch-ok,输出上限 16 token,温度 0。这样既能确认整条链路(鉴权、协议桥接、模型名映射、响应解析)真的通了,又几乎不消耗配额。

请求长什么样

请求的形状由平台决定,而不是由上游方言决定——因为测试要模拟的是本地 CLI 的入口请求,剩下的改写交给协议桥接完成。

平台入口路径请求体
codex/responses{"model": …, "input": "<prompt>", "temperature": 0, "max_output_tokens": 16}
claude/v1/messages{"model": …, "messages": [{"role": "user", "content": "<prompt>"}], "max_tokens": 16}
gemini/v1beta/models/{model}:generateContent{"contents": [...], "generationConfig": {"temperature": 0, "maxOutputTokens": 16}}

其余平台(Grok、OpenCode、OpenClaw、Hermes)没有固定入口协议,按凭据的 interface_format 选形状:

interface_format路径请求体形状
openai/chat/completionsmessages + temperature: 0 + max_tokens: 16
openai-responses/responsesinput + temperature: 0 + max_output_tokens: 16
anthropic/v1/messagesmessages + max_tokens: 16
gemini/v1beta/models/{model}:generateContentcontents + generationConfig

模型名的选择顺序是:请求里显式指定的模型 → 凭据的模型映射 → 平台/方言的内置默认值(anthropicclaude-sonnet-4-20250514geminigemini-2.5-flashgrok 平台 → grok-4.5、其余 → gpt-5.5)。占位映射(空值或字面量 upstream-model)会被剔除,不参与选择。

测试和真实流量走同一条路

关键的一点:构造好入口请求之后,测试调用的是代理转发用的同一个上游请求构造函数。也就是说模型映射、自定义工具兼容、托管工具剥离、协议桥接、方言鉴权装配全都会照常执行。

所以结果里记录的 request_body_json 是桥接之后的上游请求体,不是你在界面上看到的那份入口请求。这正是排查桥接问题时最需要的东西——你能直接看到 Responses 请求被改写成 Chat Completions 之后到底长什么样。

响应回来时同样先过一遍桥接的反向转换,再去提取文本。

方言覆盖

测试时可以临时指定一个方言,用来试探"这个网关到底说哪种协议",但可选范围是受限的:

平台允许覆盖成
codexclaudeopenaiopenai-responsesanthropicgemini(四种全开)
gemini只允许 gemini
grokopencodeopenclawhermes只允许 openai

超出范围返回 validation.route_model_test_interface_format

两种测试路径

直连上游

默认路径。选号之后直接从应用进程发出 HTTP 请求到上游,30 秒超时。

选号逻辑跟真实转发一致:指定了账号 ID 就测那一个;没指定就走池内候选,按优先级分组轮转,逐个尝试取并发租约。所有账号都占满时返回 route_pool.concurrency_exhausted;池里没有可用账号时返回 validation.route_pool_empty

经本地代理

另一条路径把请求打到本地代理的入口地址上,用平台的本地代理 key 鉴权,额外带一个 x-ai-switch-test-trace-id 请求头。

这条路径验证的是"CLI 打过来会不会通",而不只是"凭据能不能用"——代理监听、平台识别、选号、桥接全都在链路里。它只在不指定具体账号时生效;指定了账号会退回直连模式。

因为请求是代理内部选号的,测试端事先并不知道会命中哪个账号。所以请求结束后会用 trace ID 反查:扫最近 50 条 source_label = 'route_proxy' 的请求事件,找到 metadata_json.trace_id 匹配的那一条,从里面读出实际命中的账号 ID、账号名和目标 URL。

重试与失败判定

测试的重试次数、间隔和语义失败阈值来自账号的失败策略(config_json.failure_policy),默认重试 2 次、间隔 200 ms。

重试规则里有两条硬性例外:

  • 401 / 403 永不重试。 鉴权失败重试没有意义,只会更快触发上游风控。
  • 确定性的配额耗尽不重试。 语义失败被识别为配额耗尽时直接短路,不再尝试。

除此之外,非 2xx 状态码和语义失败(响应体结构上是失败,但 HTTP 状态是 200 那种)都会触发重试。流式请求如果在完成事件之前断流,也算一次语义失败。

成功与失败分别做什么

成功:

  • 清空瞬时失败计数与退避窗口
  • 如果账号当前是 errorwarning,拉回 ok
  • 如果是针对单个账号的显式测试,额外执行"显式测试恢复",把账号完整恢复进池

失败按类型分流:

判定结果
配额耗尽状态直接写 error
非 2xx HTTP记一次 model_test_status 瞬时失败
语义失败记一次 semantic_response_transient 瞬时失败
永久性失败(如凭据已吊销)状态写 revoked
其他可重试失败记一次 model_test 瞬时失败

瞬时失败会带上退避窗口,具体阈值和时长见 稳定性与自动恢复

paused 的账号可以被测试。 代码里对此有明确注释:显式测一次正是用户判断暂停中的账号是否已恢复的方式,成功即恢复。

结果里有什么

一次测试返回的结果字段:

字段内容
platform平台
selected_account_id / selected_account_name实际命中的账号
via_route_proxy是否走的本地代理路径
route_proxy_entry_url / route_proxy_entry_path / route_proxy_trace_id代理路径专属信息
interface_format实际使用的上游方言
request_path入口路径
base_url / target_url凭据的 base URL 与最终请求的完整 URL
request_body_json桥接之后的上游请求体,格式化输出
response_statusHTTP 状态码(传输层失败时为空)
response_body原始响应体,上限 16 KiB
response_text从响应里提取出的模型回复文本
error_message错误信息
success / duration_ms是否成功、耗时
stats该平台的完整用量统计快照

response_text 的提取路径按方言不同:

方言JSON 指针(按顺序尝试)
openai / openai-responses/choices/0/message/content/output_text → 遍历 /output[]/content[]/text
anthropic/content/0/text
gemini/candidates/0/content/parts/0/text

敏感值脱敏

响应体、错误信息在写入数据库之前都会做替换式脱敏:从凭据的 secret 载荷里取出所有敏感键的值(api_keyaccess_tokenrefresh_tokenid_tokenauthorizationx-api-key),在文本里逐一替换成 [redacted]。所以哪怕上游把 key 原样回显在错误信息里,也不会落库。

每次测试都会记一条用量事件

测试结果会写进 usage_events 表,source_labelroute_pool_model_testmetadata_json 里带:

json
{
  "source": "ui_model_connectivity_test",
  "request_kind": "model_connectivity",
  "platform": "codex",
  "route_credential_id": "…",
  "route_credential_name": "…",
  "interface_format": "openai",
  "path": "/responses",
  "base_url": "…",
  "target_url": "…",
  "status": 200,
  "success": true,
  "duration_ms": 812,
  "request_body_json": "…",
  "response_body": "…",
  "response_text": "ai-switch-ok",
  "error_message": null
}

上游返回的 usage 信息也会被解析成 token 与费用拆分一并入库。因为测试事件和真实转发事件写在同一张表里,统计页面上看到的请求数会包含你手工点的每一次测试——这一点在看数据时要留意。详见 用量与请求统计

拉取上游模型列表

模型测试需要一个模型名,而第三方网关提供哪些模型往往只有它自己知道。所以还有一个独立的模型列表拉取功能,直接问上游要清单。

要求 base URL 和 API Key 都不为空,15 秒超时。候选 URL 按方言依次尝试:

方言候选 URL(按顺序)鉴权
openai / openai-responses(默认){base}/modelsAuthorization: Bearer + Codex CLI 客户端标识
anthropic{base}/v1/models{base}/modelsx-api-keyAuthorization: Bearer,加 anthropic-version: 2023-06-01anthropic-beta 与 Claude Code 客户端标识
geminibase 已以 /v1beta/v1 结尾时用 {base}/models;否则 {base}/v1beta/models{base}/v1/modelskey 放查询参数,主动移除 Authorizationx-api-key

失败处理很克制:只有 404 和 405 会继续尝试下一个候选,其他非 2xx 立刻返回 validation.route_models_http,不做无意义的重试。所有候选都失败返回 validation.route_models_all_failed

响应解析

上游返回的结构五花八门,解析逻辑做了递归归一:

  • 容器键依次识别 datamodelsitems,递归展开
  • 模型 ID 依次尝试 idnamemodelslug
  • 归属信息依次尝试 owned_byownedByproviderdisplay_namedisplayName
  • 长上下文标记识别 supports_1m / supports1m
  • Gemini 风格的 models/gemini-2.5-flash 前缀会被剥掉
  • 纯字符串数组也能解析
  • 结果按 ID 排序并去重

拉取到的列表会写进凭据的 config_json.fetched_models,编辑账号时可以直接从下拉里挑模型,不必每次重新拉。

实时请求日志

模型测试给你的是一次请求的结果快照。如果要看持续的流量,代理侧还有一个实时请求日志,按四个阶段捕获每个转发请求:客户端原始请求、改写后的上游请求、上游原始响应、返回给客户端的最终响应。

它是内存环形缓冲,容量 100 条,单阶段正文上限 64 KiB,完全不落盘,且只在有订阅者时才推送事件。细节见 协议路由与桥接

下一步

基于 MIT 许可发布