Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Test

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
name: go test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
# 契约测试矩阵所在包:gateway(Responses/Chat 入站规范化)、
# provider(能力表与引擎适配)、ir、engine/upstream(OpenAI 兼容出站)、
# upstream/antigravity(Gemini 报文)。其余包无测试文件,./... 一并跑。
run: go test ./...
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ go build -o web2api.exe .
- **Gemini Web**:多号轮询 + 配额/认证异常自动切换下一个号
- **AI Studio**:轮询(`round-robin`)或会话粘性(`account-sticky`),单号并发槽位,
Playground+Build 双额度通道自动降级,模型资格与冷却由池管理
- **模型路由**:`routing.engine_a/b_models` 显式指定 > 订阅/custom 目录按模型 id 精确命中 >
- **模型路由**:`routing.engine_geminiweb/aistudio_models` 显式指定(旧键 engine_a/b_models 仍可读) > 订阅/custom 目录按模型 id 精确命中 >
Gemini Web / AI Studio 实时目录精确命中(裸名模型归属拥有它的引擎,如 `gemini-argon` → Gemini Web)>
多模态关键词(image/veo/lyria…)走 AI Studio > `default_engine` > `auto`(AI Studio 优先,
不可用自动降级 Gemini Web)。除 `default_engine` 与 `auto` 外,命中即「专属」:引擎不可用时
Expand Down
123 changes: 123 additions & 0 deletions docs/web2api-responses-adapter-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# web2api Responses 分层适配方案

> 用「一条 Responses 入口 + 内部协议 + 按引擎适配」替代共享路径上的反复打补丁,避免 Codex / Claude / Gemini / MiniMax 再被同一处转换拖垮。

页面版(可在 Grok Bot 里继续改):见对话中的 Page「web2api Responses 分层适配方案」。

---

## 1. 问题与目标

### 反复翻车的根因

Codex(CC Switch)走 `/v1/responses` 时,先被收成一份共享 `ChatRequest`,再丢给 Antigravity / Custom / OpenAI 兼容上游。平台差异(非 function 工具、content=null、tool 回填 name、空 tool-only turn)有的堵在共享转换,有的堵在引擎里,补丁互相打架。

### 目标

目标不是「每个平台各做一套 `/v1/responses`」,而是:

- 一条公开入口
- 一份内部协议(IR)
- 多个上游适配器

新平台只加适配器与能力行,不再改 Responses 主路径。

### 这样做

- 入口统一:Codex / 面板 / 第三方都打 `/v1/responses` 或 `/v1/chat/completions`
- 差异下沉:工具协议、空 content、400 降级留在引擎适配器
- 可测:能力表 + 单测/mock,回归可复现

### 明确不做

- 不为每个上游复制一套 HTTP Responses handler
- 不在共享 `toChatTools` 里按模型 if/else 堆特例
- 不静默吞掉工具失败(退化要可观测、可记用量引擎)

---

## 2. 目标架构

```
客户端 (Codex / CC Switch / SDK)
↓
Wire 入站 (/v1/responses · /v1/chat)
↓ 只保留 function
内部 IR (规范化消息 + function tools)
↓
路由 (选引擎 · 查能力表 · 记 Engine)
├─ Antigravity 适配 (Gemini tools · Claude 清洗)
├─ OpenAI 兼容适配 (content 规范化 · thought)
└─ Custom / MiniMax (400 无工具重试)
↓
Wire 出站 (编回 Responses / Chat · 用量)
```

Wire 层只做格式互转与「不明工具类型」策略(丢弃或折成文本并打日志)。能力表决定这次请求能不能带 tools、要不要空 content、失败是否无工具重试、是否回填 `call_id→name`。适配器独占上游报文与降级,路由层统一写 `Engine`。

---

## 3. 能力表(首版)

按引擎+模型族扩展;未知项默认保守(不透传危险能力)。

| 能力 | Antigravity Gemini | Antigravity Claude | Custom / MiniMax | OpenAI 兼容 |
| --- | --- | --- | --- | --- |
| 透传 function tools | 是 | 否(先剥) | 是,400 可降级 | 是 |
| 非 function 工具入站 | 入口丢弃 | 入口丢弃 | 入口丢弃 | 入口丢弃 |
| assistant content=null | 按上游 | 清洗 | 改成 `""` | 改成 `""` |
| 空 tool-only assistant | 保留 | 去掉 | 按上游 | 按上游 |
| function_call_output 补 name | 需要 | 需要 | 需要 | 按上游 |
| 带 tools 的 400 重试 | 无工具再试 | 无工具再试 | 无工具再试 | 可选 |
| 失败用量写 Engine | 是 | 是 | 是 | 是 |

---

## 4. 落地分阶段

### 阶段 0:冻结线上,收口现有补丁

不发版。把本机未提交改动收进独立分支:Responses 规范化、Custom 400 降级、失败记 Engine。用 mock 固定 Codex Responses + tools 的回归用例。

### 阶段 1:抽出 IR 与能力表

新增内部类型(规范化 messages/tools)与 Capability 查询(引擎+模型)。`toChatTools` / `parseResponsesInput` 只产出 IR;不明工具类型在此统一处理并打日志。

### 阶段 2:按引擎拆适配器

Antigravity / OpenAI-compat / Custom 各自 IR→上游、结果→Chat。把现在散落的 `stripChatTools`、`normalizeAssistantContent`、Claude 清洗、400 重试挪进对应适配器,共享路由不再 if 模型名。

### 阶段 3:契约测试矩阵

每个适配器至少覆盖:纯文本、function tools、Codex `local_shell` 入站被剥、上游 400 降级、失败用量含 Engine。CI 跑 gateway/provider/upstream 包。

### 阶段 4:再发版

你确认后再合并、打 tag(建议 v0.3.9)、通知 @bllii 用真号本地测 Claude/Gemini/MiniMax + CC Switch Responses。

### 进度(as of 2026-10-28)

- [x] 阶段 0 补丁收进 `refactor/responses-adapter` 分支(95a9a6a,未合并)
- [x] IR + 能力表代码落地(`internal/ir.NormalizeChat` + `provider.CapsFor`,617c8bd)
- [x] 三引擎适配器边界清晰、共享路由无模型特例(能力表驱动;local_shell/apply_patch 入站从「降级成 function」改为「丢弃并记日志」,见 §5.2)
- [x] 契约测试矩阵进 CI(`.github/workflows/test.yml`,go test ./...)
- [x] 引擎目录统一(同分支 refactor/engine-packages):实现收进 `internal/engine/<name>/`,
引擎标识 a/b 改名 geminiweb/aistudio(config 旧键与存量 DB 行读时兼容)
- [ ] 发版与 bllii 真号验收

---

## 5. 验收与边界

1. CC Switch `wire_api=responses` 带 tools:Claude / Gemini / MiniMax 不再因共享转换或错误透传整请求 4xx/5xx
2. 入站 `local_shell` / `apply_patch` 等非 function 工具不进入上游报文;行为可测、可日志
3. 失败用量明细始终能显示引擎来源(不再是「—」)
4. 新增上游时只需:能力表一行 + 适配器实现 + 契约测试,无需改 `handleResponses` 主流程

### 和「每平台一份 Responses」的关系

公开路由仍然只有一份。平台兼容写在适配器里,最后都汇入同一条 `/v1/responses`(及 Chat)出站编码。这样既按平台隔离,又不会复制 N 套 HTTP 与鉴权/用量逻辑。

### 建议下一动作

按阶段 0→1 开重构分支 PR(不合并不发版),把现有未提交补丁作为阶段 0 收进去。
29 changes: 15 additions & 14 deletions internal/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import (
"web2api/internal/provider"
"web2api/internal/store"
"web2api/internal/upgrade"
"web2api/internal/upstream/antigravity"
"web2api/internal/upstream/codex"
"web2api/internal/upstream/workbuddy"
"web2api/internal/engine/antigravity"
"web2api/internal/engine/codex"
"web2api/internal/engine/custom"
"web2api/internal/engine/workbuddy"
"web2api/internal/version"
)

Expand Down Expand Up @@ -265,7 +266,7 @@ func openAPISpec() map[string]any {
"GeminiAccountInput": map[string]any{"type": "object", "required": []string{"label", "psid", "psidts"}, "properties": map[string]any{"label": map[string]any{"type": "string"}, "psid": map[string]any{"type": "string", "description": "__Secure-1PSID Cookie"}, "psidts": map[string]any{"type": "string", "description": "__Secure-1PSIDTS Cookie"}}},
"GeminiCredentialsInput": map[string]any{"type": "object", "required": []string{"psid", "psidts"}, "properties": map[string]any{"psid": map[string]any{"type": "string"}, "psidts": map[string]any{"type": "string"}}},
"AIStudioAccountInput": map[string]any{"type": "object", "required": []string{"email", "storage_state"}, "properties": map[string]any{"email": map[string]any{"type": "string", "format": "email"}, "storage_state": map[string]any{"type": "string", "description": "storage-state.json 原文"}, "locale": map[string]any{"type": "string"}, "timezone": map[string]any{"type": "string"}, "proxy": map[string]any{"type": "string"}}},
"AccountInput": map[string]any{"type": "object", "required": []string{"engine"}, "properties": map[string]any{"engine": map[string]any{"type": "string", "enum": []string{"a", "b", "codex", "antigravity", "kiro", "workbuddy", "custom"}, "description": "a=Gemini Web,b=AI Studio;codex=GPT、antigravity 等为订阅引擎;custom 为第三方 OpenAI 兼容 Key"}, "label": map[string]any{"type": "string"}, "email": map[string]any{"type": "string", "format": "email"}, "storage_state": map[string]any{"type": "string", "description": "Gemini Web / AI Studio 的 storage-state.json 原文"}, "credentials": map[string]any{"type": "object", "description": "订阅引擎:codex/antigravity/kiro 传 {refresh_token};workbuddy 传 {token};custom 传 {base_url, api_key, models?, proxy?}"}, "locale": map[string]any{"type": "string"}, "timezone": map[string]any{"type": "string"}, "proxy": map[string]any{"type": "string"}}},
"AccountInput": map[string]any{"type": "object", "required": []string{"engine"}, "properties": map[string]any{"engine": map[string]any{"type": "string", "enum": []string{"geminiweb", "aistudio", "codex", "antigravity", "kiro", "workbuddy", "custom"}, "description": "geminiweb=Gemini Web,aistudio=AI Studio;codex=GPT、antigravity 等为订阅引擎;custom 为第三方 OpenAI 兼容 Key"}, "label": map[string]any{"type": "string"}, "email": map[string]any{"type": "string", "format": "email"}, "storage_state": map[string]any{"type": "string", "description": "Gemini Web / AI Studio 的 storage-state.json 原文"}, "credentials": map[string]any{"type": "object", "description": "订阅引擎:codex/antigravity/kiro 传 {refresh_token};workbuddy 传 {token};custom 传 {base_url, api_key, models?, proxy?}"}, "locale": map[string]any{"type": "string"}, "timezone": map[string]any{"type": "string"}, "proxy": map[string]any{"type": "string"}}},
"CustomProbeInput": map[string]any{"type": "object", "required": []string{"base_url", "api_key"}, "properties": map[string]any{"base_url": map[string]any{"type": "string"}, "api_key": map[string]any{"type": "string"}, "proxy": map[string]any{"type": "string"}}},
"OAuthExchangeInput": map[string]any{"type": "object", "required": []string{"session_id", "code"}, "properties": map[string]any{"session_id": map[string]any{"type": "string"}, "code": map[string]any{"type": "string", "description": "授权码或完整回调 URL"}, "label": map[string]any{"type": "string"}, "email": map[string]any{"type": "string"}}},
},
Expand Down Expand Up @@ -327,11 +328,11 @@ func (a *API) handleOverview(w http.ResponseWriter, r *http.Request) {
readyByEngine := map[string]int64{}
for _, acc := range accounts {
switch acc.Engine {
case "a":
case "a", "geminiweb":
if acc.Live.Ready {
readyA++
}
case "b":
case "b", "aistudio":
if acc.Live.Ready {
readyB++
}
Expand Down Expand Up @@ -436,7 +437,7 @@ func (a *API) handleAddGemini(w http.ResponseWriter, r *http.Request) {
{"name": "__Secure-1PSIDTS", "value": req.PSIDTS, "domain": ".google.com", "path": "/", "secure": true},
}})
acc, err := a.mgr.AddAccountFromStorageState(provider.AccountInput{
Engine: "a", Label: req.Label, StorageState: string(state),
Engine: "geminiweb", Label: req.Label, StorageState: string(state),
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
Expand All @@ -463,7 +464,7 @@ func (a *API) handleAddAIStudio(w http.ResponseWriter, r *http.Request) {
return
}
acc, err := a.mgr.AddAccountFromStorageState(provider.AccountInput{
Engine: "b", Email: req.Email, StorageState: req.StorageState,
Engine: "aistudio", Email: req.Email, StorageState: req.StorageState,
Locale: req.Locale, Timezone: req.Timezone, Proxy: req.Proxy,
})
if err != nil {
Expand Down Expand Up @@ -513,7 +514,7 @@ func (a *API) handleUpdateCredentials(w http.ResponseWriter, r *http.Request) {
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<20))
switch acc.Engine {
case "a":
case "a", "geminiweb":
var req struct {
PSID string `json:"psid"`
PSIDTS string `json:"psidts"`
Expand Down Expand Up @@ -555,14 +556,14 @@ func (a *API) handleUpdateCredentials(w http.ResponseWriter, r *http.Request) {

// handleProbeCustomModels 探测第三方供应商 /v1/models(添加前预览)。
func (a *API) handleProbeCustomModels(w http.ResponseWriter, r *http.Request) {
var req provider.CustomCredentials
var req custom.Credentials
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "请求体无效"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
models, err := provider.ProbeCustomModels(ctx, a.mgr.EngineProxy("custom"), req)
models, err := custom.ProbeCustomModels(ctx, a.mgr.EngineProxy("custom"), req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
Expand Down Expand Up @@ -659,7 +660,7 @@ func (a *API) handleOAuthExchange(w http.ResponseWriter, r *http.Request) {
}
acc, err := a.mgr.AddAccountFromStorageState(provider.AccountInput{
Engine: "codex", Label: req.Label, Email: email,
Credentials: json.RawMessage(mustJSON(provider.CodexCredentials{
Credentials: json.RawMessage(mustJSON(codex.Credentials{
RefreshToken: tok.RefreshToken, Email: email, ChatGPTAccountID: tok.ChatGPTAccount,
})),
})
Expand All @@ -680,7 +681,7 @@ func (a *API) handleOAuthExchange(w http.ResponseWriter, r *http.Request) {
}
acc, err := a.mgr.AddAccountFromStorageState(provider.AccountInput{
Engine: "antigravity", Label: req.Label, Email: email,
Credentials: json.RawMessage(mustJSON(provider.AntigravityCredentials{
Credentials: json.RawMessage(mustJSON(antigravity.Credentials{
RefreshToken: tok.RefreshToken, Email: email,
})),
})
Expand Down Expand Up @@ -734,7 +735,7 @@ func (a *API) handleOAuthPoll(w http.ResponseWriter, r *http.Request) {
}
acc, err := a.mgr.AddAccountFromStorageState(provider.AccountInput{
Engine: "workbuddy", Label: req.Label, Email: email,
Credentials: json.RawMessage(mustJSON(provider.WorkBuddyCredentials{
Credentials: json.RawMessage(mustJSON(workbuddy.Credentials{
Token: tok.AccessToken, RefreshToken: tok.RefreshToken, Email: email, ExpiresAt: expiresAt,
})),
})
Expand Down
78 changes: 78 additions & 0 deletions internal/config/compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package config

import (
"testing"

"gopkg.in/yaml.v3"
)

// v0.3.9 引擎改名(a→geminiweb、b→aistudio):旧 yaml 键与旧 default_engine
// 值必须继续可读,新键优先。
func TestConfigLegacyKeysStillLoad(t *testing.T) {
raw := `
routing:
default_engine: a
engine_a_models: ["my-a-model"]
engine_b_models: ["my-b-model"]
engine_a:
enabled: true
refresh_seconds: 600
engine_b:
enabled: true
mode: native
`
var cfg Config
if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Routing.DefaultEngine != "geminiweb" {
t.Fatalf("default_engine a should normalize to geminiweb, got %q", cfg.Routing.DefaultEngine)
}
if len(cfg.Routing.GeminiWebModels) != 1 || cfg.Routing.GeminiWebModels[0] != "my-a-model" {
t.Fatalf("engine_a_models should map to GeminiWebModels: %#v", cfg.Routing.GeminiWebModels)
}
if len(cfg.Routing.AIStudioModels) != 1 || cfg.Routing.AIStudioModels[0] != "my-b-model" {
t.Fatalf("engine_b_models should map to AIStudioModels: %#v", cfg.Routing.AIStudioModels)
}
if !cfg.GeminiWeb.Enabled || cfg.GeminiWeb.RefreshSeconds != 600 {
t.Fatalf("engine_a should map to GeminiWeb: %#v", cfg.GeminiWeb)
}
if !cfg.AIStudio.Enabled || cfg.AIStudio.Mode != "native" {
t.Fatalf("engine_b should map to AIStudio: %#v", cfg.AIStudio)
}
}

func TestConfigNewKeysWinOverLegacy(t *testing.T) {
raw := `
engine_a:
enabled: false
engine_geminiweb:
enabled: true
default_engine: b
`
// 新键与旧键同时出现时新键优先;default_engine 的 b 归一为 aistudio。
raw = "routing:\n default_engine: b\nengine_a:\n enabled: false\nengine_geminiweb:\n enabled: true\n"
var cfg Config
if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Routing.DefaultEngine != "aistudio" {
t.Fatalf("default_engine b should normalize to aistudio, got %q", cfg.Routing.DefaultEngine)
}
if !cfg.GeminiWeb.Enabled {
t.Fatal("engine_geminiweb should win over legacy engine_a")
}
}

func TestNormalizeEngineName(t *testing.T) {
cases := map[string]string{
"a": "geminiweb", "gemini": "geminiweb", "GEMINIWEB": "geminiweb",
"b": "aistudio", "AISTUDIO": "aistudio",
"codex": "codex", "auto": "auto", "": "",
}
for in, want := range cases {
if got := NormalizeEngineName(in); got != want {
t.Fatalf("NormalizeEngineName(%q)=%q want %q", in, got, want)
}
}
}
Loading
Loading