diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0511d3f --- /dev/null +++ b/.github/workflows/test.yml @@ -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 ./... diff --git a/README.md b/README.md index 7ca4fbf..65fce2b 100644 --- a/README.md +++ b/README.md @@ -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` 外,命中即「专属」:引擎不可用时 diff --git a/docs/web2api-responses-adapter-plan.md b/docs/web2api-responses-adapter-plan.md new file mode 100644 index 0000000..873e24c --- /dev/null +++ b/docs/web2api-responses-adapter-plan.md @@ -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//`, + 引擎标识 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 收进去。 diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 9e5598e..f8de259 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -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" ) @@ -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"}}}, }, @@ -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++ } @@ -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()}) @@ -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 { @@ -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"` @@ -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 @@ -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, })), }) @@ -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, })), }) @@ -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, })), }) diff --git a/internal/config/compat_test.go b/internal/config/compat_test.go new file mode 100644 index 0000000..2d6f66b --- /dev/null +++ b/internal/config/compat_test.go @@ -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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 221c77d..a14c835 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "fmt" "os" + "reflect" "strings" "gopkg.in/yaml.v3" @@ -13,12 +14,48 @@ import ( type Config struct { Server ServerConfig `yaml:"server"` Routing RoutingConfig `yaml:"routing"` - EngineA EngineAConfig `yaml:"engine_a"` - EngineB EngineBConfig `yaml:"engine_b"` + GeminiWeb EngineAConfig `yaml:"engine_geminiweb"` + AIStudio EngineBConfig `yaml:"engine_aistudio"` Engines EnginesConfig `yaml:"engines"` GeminiAPI GeminiAPIConfig `yaml:"gemini_api"` } +// UnmarshalYAML 兼容 v0.3.8 及之前的配置键:engine_a/engine_b 分别映射到 +// engine_geminiweb/engine_aistudio(新键优先)。 +func (c *Config) UnmarshalYAML(value *yaml.Node) error { + var raw struct { + Server ServerConfig `yaml:"server"` + Routing RoutingConfig `yaml:"routing"` + GeminiWeb EngineAConfig `yaml:"engine_geminiweb"` + AIStudio EngineBConfig `yaml:"engine_aistudio"` + Engines EnginesConfig `yaml:"engines"` + GeminiAPI GeminiAPIConfig `yaml:"gemini_api"` + EngineAOld EngineAConfig `yaml:"engine_a"` + EngineBOld EngineBConfig `yaml:"engine_b"` + } + if err := value.Decode(&raw); err != nil { + return err + } + *c = Config{ + Server: raw.Server, + Routing: raw.Routing, + GeminiWeb: raw.GeminiWeb, + AIStudio: raw.AIStudio, + Engines: raw.Engines, + GeminiAPI: raw.GeminiAPI, + } + if isZero(c.GeminiWeb) { + c.GeminiWeb = raw.EngineAOld + } + if isZero(c.AIStudio) { + c.AIStudio = raw.EngineBOld + } + return nil +} + +// isZero 判定配置段是否完全未设置(用于旧键回退)。 +func isZero(v any) bool { return v == nil || reflect.DeepEqual(v, reflect.Zero(reflect.TypeOf(v)).Interface()) } + // EnginesConfig 订阅引擎 + 第三方 Key 引擎配置。 // 每个引擎独立账号池,账号在管理台运行时增删,也可用静态种子导入。 type EnginesConfig struct { @@ -123,14 +160,56 @@ type ServerConfig struct { // RoutingConfig 模型路由配置。 type RoutingConfig struct { - DefaultEngine string `yaml:"default_engine"` // auto | a | b | codex | antigravity | kiro | workbuddy | custom - EngineAModels []string `yaml:"engine_a_models"` // 强制走引擎A 的模型名(前缀匹配) - EngineBModels []string `yaml:"engine_b_models"` // 强制走引擎B 的模型名(前缀匹配) + DefaultEngine string `yaml:"default_engine"` // auto | geminiweb | aistudio | codex | antigravity | kiro | workbuddy | custom + GeminiWebModels []string `yaml:"engine_geminiweb_models"` // 强制走 Gemini Web 的模型名(前缀匹配) + AIStudioModels []string `yaml:"engine_aistudio_models"` // 强制走 AI Studio 的模型名(前缀匹配) // EngineModels 按引擎名指定强制路由的模型(如 codex: [gpt-*]), - // 优先级高于 engine_a/b_models。 + // 优先级高于 engine_geminiweb/aistudio_models。 EngineModels map[string][]string `yaml:"engine_models"` } +// UnmarshalYAML 兼容旧键 engine_a_models/engine_b_models,并把 default_engine +// 的历史值 a/b 归一成 geminiweb/aistudio。 +func (r *RoutingConfig) UnmarshalYAML(value *yaml.Node) error { + var raw struct { + DefaultEngine string `yaml:"default_engine"` + GeminiWebModels []string `yaml:"engine_geminiweb_models"` + AIStudioModels []string `yaml:"engine_aistudio_models"` + EngineAOld []string `yaml:"engine_a_models"` + EngineBOld []string `yaml:"engine_b_models"` + EngineModels map[string][]string `yaml:"engine_models"` + } + if err := value.Decode(&raw); err != nil { + return err + } + *r = RoutingConfig{ + DefaultEngine: NormalizeEngineName(raw.DefaultEngine), + GeminiWebModels: raw.GeminiWebModels, + AIStudioModels: raw.AIStudioModels, + EngineModels: raw.EngineModels, + } + if len(r.GeminiWebModels) == 0 { + r.GeminiWebModels = raw.EngineAOld + } + if len(r.AIStudioModels) == 0 { + r.AIStudioModels = raw.EngineBOld + } + return nil +} + +// NormalizeEngineName 把历史引擎名归一为当前标识:a→geminiweb、b→aistudio。 +// gemini 是 a 的既有别名。未知值原样返回,交由上层校验。 +func NormalizeEngineName(name string) string { + switch strings.ToLower(strings.TrimSpace(name)) { + case "a", "gemini": + return "geminiweb" + case "b": + return "aistudio" + default: + return strings.ToLower(strings.TrimSpace(name)) + } +} + // EngineAConfig 引擎A:gemini.google.com 网页版(原生 Go 逆向)。 type EngineAConfig struct { Enabled bool `yaml:"enabled"` @@ -191,12 +270,12 @@ func Default() *Config { WorkBuddy: WorkBuddyConfig{Enabled: true, BaseURL: "https://www.codebuddy.ai"}, Custom: CustomConfig{Enabled: true}, }, - EngineA: EngineAConfig{ + GeminiWeb: EngineAConfig{ Enabled: true, RefreshSeconds: 600, TimeoutSeconds: 300, }, - EngineB: EngineBConfig{ + AIStudio: EngineBConfig{ Enabled: true, Mode: "native", AuthStates: "auth", @@ -260,16 +339,16 @@ func applyEnv(cfg *Config) { cfg.Server.DBPath = v } if v := os.Getenv("WEB2API_DEFAULT_ENGINE"); v != "" { - cfg.Routing.DefaultEngine = v + cfg.Routing.DefaultEngine = NormalizeEngineName(v) } if v := os.Getenv("WEB2API_ENGINE_B_URL"); v != "" { - cfg.EngineB.BaseURL = v + cfg.AIStudio.BaseURL = v } if v := os.Getenv("WEB2API_ENGINE_B_KEY"); v != "" { - cfg.EngineB.APIKey = v + cfg.AIStudio.APIKey = v } if v := os.Getenv("WEB2API_PROXY"); v != "" { - cfg.EngineA.Proxy = v + cfg.GeminiWeb.Proxy = v } // 默认值 @@ -282,14 +361,14 @@ func applyEnv(cfg *Config) { if cfg.Routing.DefaultEngine == "" { cfg.Routing.DefaultEngine = "auto" } - if cfg.EngineA.RefreshSeconds == 0 { - cfg.EngineA.RefreshSeconds = 600 + if cfg.GeminiWeb.RefreshSeconds == 0 { + cfg.GeminiWeb.RefreshSeconds = 600 } - if cfg.EngineA.TimeoutSeconds == 0 { - cfg.EngineA.TimeoutSeconds = 300 + if cfg.GeminiWeb.TimeoutSeconds == 0 { + cfg.GeminiWeb.TimeoutSeconds = 300 } - if cfg.EngineB.BaseURL == "" { - cfg.EngineB.BaseURL = "http://127.0.0.1:2048" + if cfg.AIStudio.BaseURL == "" { + cfg.AIStudio.BaseURL = "http://127.0.0.1:2048" } } diff --git a/internal/engine/accounts.go b/internal/engine/accounts.go new file mode 100644 index 0000000..135ee06 --- /dev/null +++ b/internal/engine/accounts.go @@ -0,0 +1,24 @@ +// accounts.go 各引擎共享的账号实时状态结构(供管理台展示)。 +package engine + +// AccountCooldown 账号仍在生效的额度冷却。Until 为 Unix 秒。 +type AccountCooldown struct { + Scope string `json:"scope"` + Until int64 `json:"until"` + Reason string `json:"reason,omitempty"` +} + +// AccountState 账号实时状态(供管理台)。 +type AccountState struct { + ID string `json:"id"` + Label string `json:"label"` + Enabled bool `json:"enabled"` + Ready bool `json:"ready"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + Models int `json:"models"` + Tier string `json:"tier,omitempty"` + Quota string `json:"quota,omitempty"` // 用量/余额描述(订阅引擎) + Cooldowns []AccountCooldown `json:"cooldowns,omitempty"` +} + diff --git a/internal/aistudio/accounts.go b/internal/engine/aistudio/accounts.go similarity index 100% rename from internal/aistudio/accounts.go rename to internal/engine/aistudio/accounts.go diff --git a/internal/aistudio/auth.go b/internal/engine/aistudio/auth.go similarity index 100% rename from internal/aistudio/auth.go rename to internal/engine/aistudio/auth.go diff --git a/internal/aistudio/benefit.go b/internal/engine/aistudio/benefit.go similarity index 100% rename from internal/aistudio/benefit.go rename to internal/engine/aistudio/benefit.go diff --git a/internal/aistudio/bidi.go b/internal/engine/aistudio/bidi.go similarity index 100% rename from internal/aistudio/bidi.go rename to internal/engine/aistudio/bidi.go diff --git a/internal/aistudio/build.go b/internal/engine/aistudio/build.go similarity index 100% rename from internal/aistudio/build.go rename to internal/engine/aistudio/build.go diff --git a/internal/aistudio/build_schema.go b/internal/engine/aistudio/build_schema.go similarity index 100% rename from internal/aistudio/build_schema.go rename to internal/engine/aistudio/build_schema.go diff --git a/internal/aistudio/channel.go b/internal/engine/aistudio/channel.go similarity index 100% rename from internal/aistudio/channel.go rename to internal/engine/aistudio/channel.go diff --git a/internal/aistudio/client.go b/internal/engine/aistudio/client.go similarity index 100% rename from internal/aistudio/client.go rename to internal/engine/aistudio/client.go diff --git a/internal/engine/aistudio/credentials.go b/internal/engine/aistudio/credentials.go new file mode 100644 index 0000000..7ac265a --- /dev/null +++ b/internal/engine/aistudio/credentials.go @@ -0,0 +1,11 @@ +package aistudio + +import "encoding/json" + +// Credentials 引擎B(AI Studio)账号凭据。 +type Credentials struct { + StorageState json.RawMessage `json:"storage_state"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` + Proxy string `json:"proxy,omitempty"` +} diff --git a/internal/aistudio/decoder.go b/internal/engine/aistudio/decoder.go similarity index 100% rename from internal/aistudio/decoder.go rename to internal/engine/aistudio/decoder.go diff --git a/internal/engine/aistudio/engine.go b/internal/engine/aistudio/engine.go new file mode 100644 index 0000000..977b614 --- /dev/null +++ b/internal/engine/aistudio/engine.go @@ -0,0 +1,106 @@ +package aistudio + +import ( + "context" + "errors" + "io" + "time" + + "web2api/internal/config" + "web2api/internal/engine" + "web2api/internal/engine/upstream" + "web2api/internal/model" +) + +// Engine 引擎B:AI Studio(OpenAI 兼容上游,支持多模态透传)。 +type Engine struct { + name string + up *upstream.OpenAI +} + +// NewAIStudio 从配置构建引擎B。 +func New(cfg config.EngineBConfig, timeout time.Duration) (*Engine, error) { + if !cfg.Enabled { + return nil, nil + } + if cfg.BaseURL == "" { + return nil, errors.New("引擎B 未配置 base_url") + } + up := upstream.New("aistudio", cfg.BaseURL, cfg.APIKey, cfg.Passthrough, timeout) + return &Engine{name: "aistudio", up: up}, nil +} + +// Init 探测引擎并拉取模型列表。 +func (b *Engine) Init(ctx context.Context) error { + b.up.RefreshModels(ctx) + return nil +} + +// Name 引擎标识。 +func (b *Engine) Name() string { return b.name } + +// Describe 引擎说明。 +func (b *Engine) Describe() string { + return "google.com(AI Studio,OpenAI 兼容上游,支持多模态)" +} + +// Ready 引擎可用。 +func (b *Engine) Ready() bool { return b.up.Ready() } + +// ProbeImage 用 OpenAI 兼容上游探测图片模型(/v1/images/generations)。 +func (b *Engine) ProbeImage(ctx context.Context, modelID string) (string, error) { + return b.up.ProbeImage(ctx, modelID) +} + +// ProbeEmbedding 用 OpenAI 兼容上游探测向量模型(/v1/embeddings)。 +func (b *Engine) ProbeEmbedding(ctx context.Context, modelID string) (string, error) { + return b.up.ProbeEmbedding(ctx, modelID) +} + +// ListModels 上游模型列表。 +func (b *Engine) ListModels() []model.ModelInfo { + models := b.up.ListModels() + out := make([]model.ModelInfo, 0, len(models)) + for _, m := range models { + m.Engine = "aistudio" + if m.Kind == "" { + m.Kind = model.ClassifyKind(m.ID, m.DisplayName, nil) + } + out = append(out, m) + } + return out +} + +// Chat 非流式。 +func (b *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { + return b.up.Chat(ctx, req) +} + +// ChatStream 流式。 +func (b *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { + return b.up.ChatStream(ctx, req, onDelta) +} + +// ChatStreamSink 流式(支持工具调用增量)。 +func (b *Engine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { + return b.up.ChatStreamSink(ctx, req, sink) +} + +// Passthrough 是否透传多模态。 +func (b *Engine) Passthrough() bool { return b.up.Passthrough() } + +// ServePassthrough 透传。 +func (b *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { + return b.up.ServePassthrough(ctx, method, path, body, contentType, w) +} + +// Status 上游状态。 +func (b *Engine) Status() map[string]any { + return map[string]any{ + "engine": "aistudio", + "ready": b.Ready(), + "base_url": b.up.BaseURL(), + "passthrough": b.Passthrough(), + "error": b.up.ReadyErr(), + } +} diff --git a/internal/provider/engine_b_native.go b/internal/engine/aistudio/engine_native.go similarity index 78% rename from internal/provider/engine_b_native.go rename to internal/engine/aistudio/engine_native.go index 6015285..f60d80d 100644 --- a/internal/provider/engine_b_native.go +++ b/internal/engine/aistudio/engine_native.go @@ -1,4 +1,4 @@ -package provider +package aistudio import ( "context" @@ -16,36 +16,35 @@ import ( "sync" "time" - aistudio "web2api/internal/aistudio" "web2api/internal/config" "web2api/internal/engine" "web2api/internal/model" ) -// NativeAIStudioEngine 引擎B:内置纯 Go 逆向(AI Studio 私有协议 + WAA go 后端)。 +// NativeEngine 引擎B:内置纯 Go 逆向(AI Studio 私有协议 + WAA go 后端)。 // 组装链:AccountStore → AccountPool → 固定出口头 → MakerSuiteHTTPTransport // → 每账号懒启动 WAA Go Worker → WorkerProtectedTransport → Client → PooledService -type NativeAIStudioEngine struct { +type NativeEngine struct { name string cfg config.EngineBConfig - store *aistudio.AccountStore + store *AccountStore rootDir string // 新账号创建根目录(auth_states 首路径) - accounts []*aistudio.Account - pool *aistudio.AccountPool + accounts []*Account + pool *AccountPool headers *nativeHeaderProvider - transport *aistudio.MakerSuiteHTTPTransport + transport *MakerSuiteHTTPTransport workers *nativeWorkerManager - protected *aistudio.WorkerProtectedTransport - client *aistudio.Client - service *aistudio.PooledService + protected *WorkerProtectedTransport + client *Client + service *PooledService idMu sync.RWMutex idMap map[string]string // 管理层 ID → aistudio 账号 ID(邮箱) mu sync.RWMutex - models []aistudio.Model + models []Model ready bool lastErr error stopCh chan struct{} @@ -58,7 +57,7 @@ type NativeAIStudioEngine struct { } // NewNativeAIStudio 构建原生引擎B(不发起网络请求,Init 时才装配)。 -func NewNativeAIStudio(cfg config.EngineBConfig) (*NativeAIStudioEngine, error) { +func NewNative(cfg config.EngineBConfig) (*NativeEngine, error) { if !cfg.Enabled { return nil, nil } @@ -83,15 +82,15 @@ func NewNativeAIStudio(cfg config.EngineBConfig) (*NativeAIStudioEngine, error) if cfg.RoutingStrategy == "" { cfg.RoutingStrategy = "round-robin" } - return &NativeAIStudioEngine{name: "b", cfg: cfg, stopCh: make(chan struct{}), + return &NativeEngine{name: "aistudio", cfg: cfg, stopCh: make(chan struct{}), idMap: map[string]string{}}, nil } // Init 装配运行时并拉取模型目录。 // 零账号时同样完成装配(空池),保证管理台加号可即时入池。 -func (e *NativeAIStudioEngine) Init(ctx context.Context) error { +func (e *NativeEngine) Init(ctx context.Context) error { paths := splitAndTrim(e.cfg.AuthStates, ",") - e.store = aistudio.NewAccountStore(paths...) + e.store = NewAccountStore(paths...) if len(paths) > 0 { e.rootDir = paths[0] } @@ -104,11 +103,11 @@ func (e *NativeAIStudioEngine) Init(ctx context.Context) error { if emptyAccounts { // 空目录不算致命:记录提示,继续构建空池 e.setErr(errors.New("AI Studio 账号目录为空(auth_states 未找到 storage-state.json,可在管理台添加)")) - accounts = []*aistudio.Account{} + accounts = []*Account{} } e.accounts = accounts - e.pool = aistudio.NewAccountPool(accounts, e.cfg.PerAccountConcurrency) + e.pool = NewAccountPool(accounts, e.cfg.PerAccountConcurrency) e.pool.SetRoutingStrategy(e.cfg.RoutingStrategy) e.pool.SetUpstreamChannels(parseChannels(e.cfg.UpstreamChannels)) @@ -117,8 +116,8 @@ func (e *NativeAIStudioEngine) Init(ctx context.Context) error { e.setErr(err) return err } - e.transport, err = aistudio.NewMakerSuiteHTTPTransport(aistudio.HTTPTransportOptions{ - Pool: e.pool, Signer: aistudio.NewSigner(), Headers: e.headers, GlobalProxy: e.cfg.Proxy, + e.transport, err = NewMakerSuiteHTTPTransport(HTTPTransportOptions{ + Pool: e.pool, Signer: NewSigner(), Headers: e.headers, GlobalProxy: e.cfg.Proxy, }) if err != nil { e.headers.Close() @@ -126,7 +125,7 @@ func (e *NativeAIStudioEngine) Init(ctx context.Context) error { return err } e.workers = newNativeWorkerManager(e.pool, e.cfg) - e.protected, err = aistudio.NewWorkerProtectedTransport(aistudio.WorkerProtectedTransportOptions{ + e.protected, err = NewWorkerProtectedTransport(WorkerProtectedTransportOptions{ Transport: e.transport, Workers: e.workers, SetupTimeout: time.Duration(e.cfg.InitTimeoutSeconds) * time.Second, }) if err != nil { @@ -135,14 +134,14 @@ func (e *NativeAIStudioEngine) Init(ctx context.Context) error { e.setErr(err) return err } - requestContext, err := aistudio.NewPoolRequestContextProvider(e.pool) + requestContext, err := NewPoolRequestContextProvider(e.pool) if err != nil { e.headers.Close() e.transport.CloseIdleConnections() e.setErr(err) return err } - e.client, err = aistudio.NewClient(aistudio.ClientOptions{ + e.client, err = NewClient(ClientOptions{ Transport: e.transport, Protected: e.protected, ContextProvider: requestContext, @@ -153,7 +152,7 @@ func (e *NativeAIStudioEngine) Init(ctx context.Context) error { e.setErr(err) return err } - e.service, err = aistudio.NewPooledService(e.pool, e.client) + e.service, err = NewPooledService(e.pool, e.client) if err != nil { e.headers.Close() e.transport.CloseIdleConnections() @@ -177,7 +176,7 @@ func (e *NativeAIStudioEngine) Init(ctx context.Context) error { } // refreshModels 拉取并缓存模型目录。 -func (e *NativeAIStudioEngine) refreshModels(ctx context.Context) { +func (e *NativeEngine) refreshModels(ctx context.Context) { cctx, cancel := context.WithTimeout(ctx, time.Duration(e.cfg.InitTimeoutSeconds)*time.Second) defer cancel() models, err := e.service.Models(cctx) @@ -192,7 +191,7 @@ func (e *NativeAIStudioEngine) refreshModels(ctx context.Context) { } // modelRefreshLoop 后台定期刷新模型目录。 -func (e *NativeAIStudioEngine) modelRefreshLoop() { +func (e *NativeEngine) modelRefreshLoop() { interval := time.Duration(e.cfg.RefreshSeconds) * time.Second ticker := time.NewTicker(interval) defer ticker.Stop() @@ -207,7 +206,7 @@ func (e *NativeAIStudioEngine) modelRefreshLoop() { } // Close 释放运行时资源。 -func (e *NativeAIStudioEngine) Close() { +func (e *NativeEngine) Close() { e.stopOnce.Do(func() { close(e.stopCh) }) if e.workers != nil { e.workers.Close() @@ -220,18 +219,18 @@ func (e *NativeAIStudioEngine) Close() { } } -func (e *NativeAIStudioEngine) setErr(err error) { +func (e *NativeEngine) setErr(err error) { e.mu.Lock() e.lastErr = err e.mu.Unlock() } // Name 引擎标识。 -func (e *NativeAIStudioEngine) Name() string { return e.name } +func (e *NativeEngine) Name() string { return e.name } // Describe 引擎说明。 -func (e *NativeAIStudioEngine) Describe() string { - return "aistudio.google.com(内置纯 Go 逆向:WAA go 后端 + 私有协议,零浏览器依赖)" +func (e *NativeEngine) Describe() string { + return "google.com(内置纯 Go 逆向:WAA go 后端 + 私有协议,零浏览器依赖)" } // Ready 引擎是否真的能接单。 @@ -239,7 +238,7 @@ func (e *NativeAIStudioEngine) Describe() string { // 都会卡在 Worker 启动上,对外只表现为莫名其妙的失败。 // Worker 是懒启动的,所以「还没试过」允许接单(首个请求触发启动), // 只有「试过且全部失败」才判为不可用。 -func (e *NativeAIStudioEngine) Ready() bool { +func (e *NativeEngine) Ready() bool { e.mu.RLock() inited, accounts := e.ready, len(e.accounts) e.mu.RUnlock() @@ -258,12 +257,12 @@ func (e *NativeAIStudioEngine) Ready() bool { // ChatSupportsMedia 原生协议可以在 chat 响应里直接带回音频/图片(转 data URI), // 所以音乐(Lyria)、生图这类模型可以走 /v1/chat/completions。 -func (e *NativeAIStudioEngine) ChatSupportsMedia() bool { return true } +func (e *NativeEngine) ChatSupportsMedia() bool { return true } // ListModels 模型列表。 // available 的口径:该模型至少出现在一个可用账号的目录里,且引擎当前可接单。 // 目录是各账号目录的并集,光「在并集里」不代表真有人能出这个模型。 -func (e *NativeAIStudioEngine) ListModels() []model.ModelInfo { +func (e *NativeEngine) ListModels() []model.ModelInfo { e.mu.RLock() models := e.models e.mu.RUnlock() @@ -277,7 +276,7 @@ func (e *NativeAIStudioEngine) ListModels() []model.ModelInfo { Object: "model", DisplayName: m.Name, Available: canServe && declared[id], - Engine: "b", + Engine: "aistudio", Kind: model.ClassifyKind(m.ID, m.Name, m.Capabilities), }) } @@ -287,7 +286,7 @@ func (e *NativeAIStudioEngine) ListModels() []model.ModelInfo { // declaredModels 汇总「可用账号」(启用且未停用)目录里的模型 ID。 // 带 3 秒缓存:ListModels 在每次请求的路由判定里都会被调用, // 而账号目录本身变化很慢,没必要每次重算。 -func (e *NativeAIStudioEngine) declaredModels() map[string]bool { +func (e *NativeEngine) declaredModels() map[string]bool { if e.pool == nil { return nil } @@ -298,7 +297,7 @@ func (e *NativeAIStudioEngine) declaredModels() map[string]bool { } out := map[string]bool{} for _, st := range e.pool.Status() { - if !st.Enabled || st.State == aistudio.AccountDisabled { + if !st.Enabled || st.State == AccountDisabled { continue } for _, id := range st.Models { @@ -311,25 +310,25 @@ func (e *NativeAIStudioEngine) declaredModels() map[string]bool { } // buildContents 将 OpenAI messages 转为 AI Studio contents + system。 -func buildContents(messages []model.ChatMessage) (string, []aistudio.Content) { +func buildContents(messages []model.ChatMessage) (string, []Content) { var system []string - contents := make([]aistudio.Content, 0, len(messages)) + contents := make([]Content, 0, len(messages)) for _, m := range messages { text := m.Text() switch m.Role { case "system": system = append(system, text) case "assistant": - parts := make([]aistudio.Part, 0, len(m.ToolCalls)+1) + parts := make([]Part, 0, len(m.ToolCalls)+1) if text != "" { - parts = append(parts, aistudio.Part{Text: text}) + parts = append(parts, Part{Text: text}) } for _, call := range m.ToolCalls { arguments := json.RawMessage(call.Function.Arguments) if !json.Valid(arguments) || len(arguments) == 0 { arguments = json.RawMessage(`{}`) } - parts = append(parts, aistudio.Part{FunctionCall: &aistudio.FunctionCall{ + parts = append(parts, Part{FunctionCall: &FunctionCall{ ID: call.ID, Name: call.Function.Name, Arguments: arguments, @@ -338,7 +337,7 @@ func buildContents(messages []model.ChatMessage) (string, []aistudio.Content) { if len(parts) == 0 { continue } - contents = append(contents, aistudio.Content{Role: aistudio.RoleAssistant, Parts: parts}) + contents = append(contents, Content{Role: RoleAssistant, Parts: parts}) case "tool": // 工具结果必须以 function_response 形式回给上游,否则模型 // 看不到执行结果,会反复发起同一个调用。 @@ -346,16 +345,16 @@ func buildContents(messages []model.ChatMessage) (string, []aistudio.Content) { if err != nil { response = json.RawMessage(`{"result":""}`) } - contents = append(contents, aistudio.Content{ - Role: aistudio.RoleUser, - Parts: []aistudio.Part{{FunctionResult: &aistudio.FunctionResult{ + contents = append(contents, Content{ + Role: RoleUser, + Parts: []Part{{FunctionResult: &FunctionResult{ ID: m.ToolCallID, Name: m.Name, Content: response, }}}, }) default: - contents = append(contents, aistudio.Content{Role: aistudio.RoleUser, Parts: []aistudio.Part{{Text: text}}}) + contents = append(contents, Content{Role: RoleUser, Parts: []Part{{Text: text}}}) } } return strings.Join(system, "\n"), contents @@ -366,13 +365,13 @@ func buildContents(messages []model.ChatMessage) (string, []aistudio.Content) { // AI Studio 的 JSON Schema 子集比 OpenAI 窄(例如不认识 $defs / const / // title)。单个工具的参数编码失败不该让整个请求 400,降级成「无参数函数」, // 至少保留函数名与说明,模型仍能发起调用。 -func buildNativeTools(tools []model.Tool) aistudio.Tools { - out := aistudio.Tools{} +func buildNativeTools(tools []model.Tool) Tools { + out := Tools{} for _, tool := range tools { if tool.Function == nil || strings.TrimSpace(tool.Function.Name) == "" { continue } - decl := aistudio.FunctionDeclaration{ + decl := FunctionDeclaration{ Name: tool.Function.Name, Description: tool.Function.Description, Parameters: tool.Function.Parameters, @@ -386,9 +385,9 @@ func buildNativeTools(tools []model.Tool) aistudio.Tools { } // buildGenerateRequest 组装协议请求。 -func (e *NativeAIStudioEngine) buildGenerateRequest(req model.ChatRequest) (aistudio.GenerateRequest, error) { +func (e *NativeEngine) buildGenerateRequest(req model.ChatRequest) (GenerateRequest, error) { system, contents := buildContents(req.Messages) - gen := aistudio.GenerateRequest{ + gen := GenerateRequest{ ID: "web2api-" + model.NewID(8), Model: req.Model, System: system, @@ -408,9 +407,9 @@ func (e *NativeAIStudioEngine) buildGenerateRequest(req model.ChatRequest) (aist if size := strings.TrimSpace(req.ImageSize); size != "" { switch size { case "512", "1K", "2K", "4K": - gen.Config.ImageConfig = &aistudio.ImageConfig{ImageSize: size} + gen.Config.ImageConfig = &ImageConfig{ImageSize: size} default: - return aistudio.GenerateRequest{}, fmt.Errorf("image_size 必须是 512、1K、2K 或 4K") + return GenerateRequest{}, fmt.Errorf("image_size 必须是 512、1K、2K 或 4K") } } if resolution := strings.ToLower(strings.TrimSpace(req.Resolution)); resolution != "" { @@ -418,14 +417,14 @@ func (e *NativeAIStudioEngine) buildGenerateRequest(req model.ChatRequest) (aist case "360p", "720p", "1080p", "4k": gen.Config.VideoResolution = resolution default: - return aistudio.GenerateRequest{}, fmt.Errorf("resolution 必须是 360p、720p、1080p 或 4k") + return GenerateRequest{}, fmt.Errorf("resolution 必须是 360p、720p、1080p 或 4k") } } return gen, nil } // generate 执行一次生成并消费事件流。 -func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *NativeEngine) generate(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { if e.service == nil { return nil, errors.New("引擎B 未初始化") } @@ -461,13 +460,13 @@ func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatReque return nil, event.Err } switch event.Kind { - case aistudio.EventText: + case EventText: full.WriteString(event.Text) if err := sink.Text(event.Text); err != nil { e.setErr(err) return nil, err } - case aistudio.EventToolCall: + case EventToolCall: // AI Studio 的 function call 是一次性给全的(不像 OpenAI 那样 // 分块),所以这里直接发一个完整增量。 if event.ToolCall == nil || strings.TrimSpace(event.ToolCall.Name) == "" { @@ -499,7 +498,7 @@ func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatReque e.setErr(err) return nil, err } - case aistudio.EventMedia: + case EventMedia: // 图片/音媒体:以 Markdown 形式附加。AI Studio 原生协议通常 // 返回 inline data,没有 URL;必须转成 data URI 才能通过 // OpenAI 的纯文本 content 传回调用方。 @@ -522,12 +521,12 @@ func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatReque return nil, err } } - case aistudio.EventUsage: + case EventUsage: if event.Usage != nil { converted := nativeTokenUsage(*event.Usage) usage = &converted } - case aistudio.EventFinish: + case EventFinish: finished = true switch strings.ToUpper(event.FinishReason) { case "MAX_TOKENS": @@ -536,7 +535,7 @@ func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatReque default: finishReason = "stop" } - case aistudio.EventError: + case EventError: if event.Err != nil { e.setErr(event.Err) return nil, event.Err @@ -554,7 +553,7 @@ func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatReque } return &model.ChatResult{ Text: full.String(), - Engine: "b", + Engine: "aistudio", Model: req.Model, Usage: usage, ToolCalls: toolCalls, @@ -564,7 +563,7 @@ func (e *NativeAIStudioEngine) generate(ctx context.Context, req model.ChatReque // nativeTokenUsage 把 AI Studio usage metadata 映射为 OpenAI 口径: // prompt = 输入 + 工具输入,completion = 可见输出 + 思考,total 以上游为准。 -func nativeTokenUsage(u aistudio.Usage) model.TokenUsage { +func nativeTokenUsage(u Usage) model.TokenUsage { out := model.TokenUsage{ PromptTokens: u.InputTokens + u.ToolTokens, CompletionTokens: u.OutputTokens + u.ReasoningTokens, @@ -576,55 +575,55 @@ func nativeTokenUsage(u aistudio.Usage) model.TokenUsage { } // Chat 非流式对话。 -func (e *NativeAIStudioEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *NativeEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { return e.generate(ctx, req, nil) } // ChatStream 流式对话。 -func (e *NativeAIStudioEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *NativeEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { return e.generate(ctx, req, model.TextSink(onDelta)) } // ChatStreamSink 流式对话(支持工具调用增量)。 -func (e *NativeAIStudioEngine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *NativeEngine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { return e.generate(ctx, req, sink) } // GenerateVideo 创建 Veo 长任务。视频协议与聊天 GenerateContent 是两套 // 独立的 AI Studio RPC,必须通过 PooledService 取得账号租约后调用。 -func (e *NativeAIStudioEngine) GenerateVideo(ctx context.Context, request aistudio.VideoRequest) (aistudio.VideoOperation, error) { +func (e *NativeEngine) GenerateVideo(ctx context.Context, request VideoRequest) (VideoOperation, error) { if e.service == nil { - return aistudio.VideoOperation{}, errors.New("引擎B 未初始化") + return VideoOperation{}, errors.New("引擎B 未初始化") } return e.service.GenerateVideo(ctx, request) } // GetGenerateVideoOperation 查询 Veo 长任务状态。 -func (e *NativeAIStudioEngine) GetGenerateVideoOperation(ctx context.Context, operationID string) (aistudio.VideoOperation, error) { +func (e *NativeEngine) GetGenerateVideoOperation(ctx context.Context, operationID string) (VideoOperation, error) { if e.service == nil { - return aistudio.VideoOperation{}, errors.New("引擎B 未初始化") + return VideoOperation{}, errors.New("引擎B 未初始化") } return e.service.GetGenerateVideoOperation(ctx, operationID) } // DownloadVideoFile 下载已完成 Veo 任务绑定的媒体文件。 -func (e *NativeAIStudioEngine) DownloadVideoFile(ctx context.Context, fileID string) (aistudio.MediaStream, error) { +func (e *NativeEngine) DownloadVideoFile(ctx context.Context, fileID string) (MediaStream, error) { if e.service == nil { - return aistudio.MediaStream{}, errors.New("引擎B 未初始化") + return MediaStream{}, errors.New("引擎B 未初始化") } return e.service.DownloadFile(ctx, fileID) } // Passthrough 原生模式不透传(多模态能力已内置于对话协议)。 -func (e *NativeAIStudioEngine) Passthrough() bool { return false } +func (e *NativeEngine) Passthrough() bool { return false } // ServePassthrough 原生模式不支持透传。 -func (e *NativeAIStudioEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *NativeEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("引擎B 原生模式不使用透传:图片模型走 /v1/chat/completions(模型见 /v1/models)") } // Status 引擎与账号状态。 -func (e *NativeAIStudioEngine) Status() map[string]any { +func (e *NativeEngine) Status() map[string]any { e.mu.RLock() models := len(e.models) errMsg := "" @@ -654,7 +653,7 @@ func (e *NativeAIStudioEngine) Status() map[string]any { } } return map[string]any{ - "engine": "b", + "engine": "aistudio", "mode": "native", "ready": e.Ready(), "auth_states": e.cfg.AuthStates, @@ -682,14 +681,14 @@ func splitAndTrim(s, sep string) []string { // ==================== 动态号池管理 ==================== // RegisterAccountID 登记管理 ID ↔ aistudio 账号 ID(邮箱)的映射(启动装配已有账号时用)。 -func (e *NativeAIStudioEngine) RegisterAccountID(managedID, aistudioID string) { +func (e *NativeEngine) RegisterAccountID(managedID, aistudioID string) { e.idMu.Lock() e.idMap[managedID] = aistudioID e.idMu.Unlock() } // PoolAccountIDs 池内账号 ID(邮箱)列表。 -func (e *NativeAIStudioEngine) PoolAccountIDs() []string { +func (e *NativeEngine) PoolAccountIDs() []string { statuses := e.pool.Status() ids := make([]string, 0, len(statuses)) for _, s := range statuses { @@ -699,7 +698,7 @@ func (e *NativeAIStudioEngine) PoolAccountIDs() []string { } // AttachOrAdd 池中已有该邮箱则仅登记映射,否则用给定凭据创建并入池。 -func (e *NativeAIStudioEngine) AttachOrAdd(id, email, storageJSON, locale, timezone, proxy string) error { +func (e *NativeEngine) AttachOrAdd(id, email, storageJSON, locale, timezone, proxy string) error { for _, poolID := range e.PoolAccountIDs() { if strings.EqualFold(poolID, email) { e.RegisterAccountID(id, poolID) @@ -710,7 +709,7 @@ func (e *NativeAIStudioEngine) AttachOrAdd(id, email, storageJSON, locale, timez } // RefreshAccountModels 刷新指定账号的模型目录(健康检查用)。 -func (e *NativeAIStudioEngine) RefreshAccountModels(ctx context.Context, accountID string) ([]aistudio.Model, error) { +func (e *NativeEngine) RefreshAccountModels(ctx context.Context, accountID string) ([]Model, error) { if e.service == nil { return nil, errors.New("引擎B 未初始化") } @@ -725,18 +724,18 @@ func (e *NativeAIStudioEngine) RefreshAccountModels(ctx context.Context, account } // AddAccount 添加 AI Studio 账号(凭据为 storage-state JSON 文本)。 -func (e *NativeAIStudioEngine) AddAccount(id, email, storageJSON, locale, timezone, proxy string) error { +func (e *NativeEngine) AddAccount(id, email, storageJSON, locale, timezone, proxy string) error { if e.rootDir == "" { return errors.New("engine_b.auth_states 未配置") } - var state aistudio.StorageState + var state StorageState if err := json.Unmarshal([]byte(storageJSON), &state); err != nil { return fmt.Errorf("storage-state 不是合法 JSON: %w", err) } if err := state.Validate(); err != nil { return fmt.Errorf("storage-state 无效: %w", err) } - cfg := aistudio.DefaultAccountConfig(email) + cfg := DefaultAccountConfig(email) if locale != "" { cfg.Locale = locale } @@ -746,7 +745,7 @@ func (e *NativeAIStudioEngine) AddAccount(id, email, storageJSON, locale, timezo if proxy != "" { cfg.Proxy = proxy } - createStore := aistudio.NewAccountStore(e.rootDir) + createStore := NewAccountStore(e.rootDir) acc, lease, err := createStore.Create(cfg, state) if err != nil { return fmt.Errorf("创建账号目录失败: %w", err) @@ -760,7 +759,7 @@ func (e *NativeAIStudioEngine) AddAccount(id, email, storageJSON, locale, timezo return fmt.Errorf("账号入池失败: %w", err) } if err := e.headers.Add(acc); err != nil { - _, _ = e.pool.Remove(acc.ID, func(*aistudio.Account) error { return nil }) + _, _ = e.pool.Remove(acc.ID, func(*Account) error { return nil }) _ = createStore.Delete(acc) return err } @@ -779,14 +778,14 @@ func (e *NativeAIStudioEngine) AddAccount(id, email, storageJSON, locale, timezo } // removeWithRetry 从池中移除账号;遇 ErrAccountLeased(活跃请求/后台 auth 刷新占用)时带重试。 -func (e *NativeAIStudioEngine) removeWithRetry(accountID string, wait time.Duration) (*aistudio.Account, error) { +func (e *NativeEngine) removeWithRetry(accountID string, wait time.Duration) (*Account, error) { deadline := time.Now().Add(wait) for { - acc, err := e.pool.Remove(accountID, func(*aistudio.Account) error { return nil }) + acc, err := e.pool.Remove(accountID, func(*Account) error { return nil }) if err == nil { return acc, nil } - if !errors.Is(err, aistudio.ErrAccountLeased) { + if !errors.Is(err, ErrAccountLeased) { return nil, err } if time.Now().After(deadline) { @@ -798,7 +797,7 @@ func (e *NativeAIStudioEngine) removeWithRetry(accountID string, wait time.Durat // RemoveAccount 删除账号(移出池 + 删除目录与凭据文件)。 // 池原生语义下禁用账号仍在池中,可直接 Remove;占用中则最多等 30 秒重试。 -func (e *NativeAIStudioEngine) RemoveAccount(id string) error { +func (e *NativeEngine) RemoveAccount(id string) error { e.idMu.Lock() accountID, ok := e.idMap[id] if ok { @@ -810,7 +809,7 @@ func (e *NativeAIStudioEngine) RemoveAccount(id string) error { } acc, removeErr := e.removeWithRetry(accountID, 30*time.Second) - if removeErr != nil && !errors.Is(removeErr, aistudio.ErrAccountNotFound) { + if removeErr != nil && !errors.Is(removeErr, ErrAccountNotFound) { // 出池失败(被占用):回滚映射,交还上层提示重试 e.idMu.Lock() e.idMap[id] = accountID @@ -850,7 +849,7 @@ func (e *NativeAIStudioEngine) RemoveAccount(id string) error { // SetEnabled 启停账号:池原生管理通道(AcquireAccount 按 ID 取租约、不受 ready 状态限制, // 租约内 SaveConfig 持久化 account.json 并迁移状态;账号留在池中,调度自动跳过)。 // 占用中最多等 3 秒,超时报"稍后重试"。 -func (e *NativeAIStudioEngine) SetEnabled(id string, enabled bool) error { +func (e *NativeEngine) SetEnabled(id string, enabled bool) error { e.idMu.RLock() accountID, ok := e.idMap[id] e.idMu.RUnlock() @@ -864,7 +863,7 @@ func (e *NativeAIStudioEngine) SetEnabled(id string, enabled bool) error { if errors.Is(err, context.DeadlineExceeded) { return errors.New("账号正被活跃请求或后台任务占用,请稍后重试") } - if errors.Is(err, aistudio.ErrAccountNotFound) { + if errors.Is(err, ErrAccountNotFound) { return fmt.Errorf("账号不在池中(可能装配失败,删除后重新添加): %w", err) } return err @@ -882,7 +881,7 @@ func (e *NativeAIStudioEngine) SetEnabled(id string, enabled bool) error { } // AccountStates 池内账号实时状态(禁用账号保留在池中,状态由池回报)。 -func (e *NativeAIStudioEngine) AccountStates() []AccountState { +func (e *NativeEngine) AccountStates() []engine.AccountState { e.idMu.RLock() idByAistudio := make(map[string]string, len(e.idMap)) for managed, aistudioID := range e.idMap { @@ -890,9 +889,9 @@ func (e *NativeAIStudioEngine) AccountStates() []AccountState { } e.idMu.RUnlock() - var out []AccountState + var out []engine.AccountState for _, status := range e.pool.Status() { - state := AccountState{ + state := engine.AccountState{ ID: status.ID, Label: status.Label, Enabled: status.Enabled, @@ -901,18 +900,18 @@ func (e *NativeAIStudioEngine) AccountStates() []AccountState { Cooldowns: activeCooldowns(status.Cooldowns), } switch { - case !status.Enabled || status.State == aistudio.AccountDisabled: + case !status.Enabled || status.State == AccountDisabled: state.Status = "disabled" - case status.State == aistudio.AccountReady: + case status.State == AccountReady: state.Status = "ok" state.Ready = true - case status.State == aistudio.AccountAuthRequired: + case status.State == AccountAuthRequired: state.Status = "error" state.Detail = "登录态失效" - case status.State == aistudio.AccountBusy: + case status.State == AccountBusy: state.Status = "busy" state.Detail = "有进行中的请求或后台任务" - case status.State == aistudio.AccountCooldown: + case status.State == AccountCooldown: state.Status = "cooldown" state.Detail = status.Message default: @@ -928,17 +927,17 @@ func (e *NativeAIStudioEngine) AccountStates() []AccountState { } // activeCooldowns 只保留尚未到期的冷却,并按恢复时间排序。 -func activeCooldowns(cooldowns map[string]aistudio.CooldownState) []AccountCooldown { +func activeCooldowns(cooldowns map[string]CooldownState) []engine.AccountCooldown { if len(cooldowns) == 0 { return nil } now := time.Now() - out := make([]AccountCooldown, 0, len(cooldowns)) + out := make([]engine.AccountCooldown, 0, len(cooldowns)) for scope, cooldown := range cooldowns { if !cooldown.Active(now) { continue } - out = append(out, AccountCooldown{ + out = append(out, engine.AccountCooldown{ Scope: scope, Until: cooldown.Until.Unix(), Reason: cooldown.Reason, @@ -953,18 +952,18 @@ func activeCooldowns(cooldowns map[string]aistudio.CooldownState) []AccountCoold return out } -func parseChannels(s string) []aistudio.Channel { - var out []aistudio.Channel +func parseChannels(s string) []Channel { + var out []Channel for _, name := range splitAndTrim(s, ",") { switch strings.ToLower(name) { case "playground": - out = append(out, aistudio.ChannelPlayground) + out = append(out, ChannelPlayground) case "build": - out = append(out, aistudio.ChannelBuild) + out = append(out, ChannelBuild) } } if len(out) == 0 { - out = []aistudio.Channel{aistudio.ChannelPlayground, aistudio.ChannelBuild} + out = []Channel{ChannelPlayground, ChannelBuild} } return out } @@ -984,7 +983,7 @@ type nativeHeaderProvider struct { states map[string]*nativeHeaderState } -func newNativeHeaderProvider(accounts []*aistudio.Account, globalProxy string) (*nativeHeaderProvider, error) { +func newNativeHeaderProvider(accounts []*Account, globalProxy string) (*nativeHeaderProvider, error) { provider := &nativeHeaderProvider{ globalProxy: strings.TrimSpace(globalProxy), states: make(map[string]*nativeHeaderState, len(accounts)), @@ -1002,8 +1001,8 @@ func newNativeHeaderProvider(accounts []*aistudio.Account, globalProxy string) ( } // Add 注册账号固定出口。 -func (p *nativeHeaderProvider) Add(account *aistudio.Account) error { - client, err := aistudio.NewProxyHTTPClient(account.EffectiveProxy(p.globalProxy)) +func (p *nativeHeaderProvider) Add(account *Account) error { + client, err := NewProxyHTTPClient(account.EffectiveProxy(p.globalProxy)) if err != nil { return fmt.Errorf("创建账号 %s 的固定出口: %w", account.ID, err) } @@ -1028,7 +1027,7 @@ func (p *nativeHeaderProvider) ProtocolHeaders(ctx context.Context, accountID st state.mu.Lock() defer state.mu.Unlock() if len(state.headers) == 0 { - headers, err := aistudio.DiscoverPublicHeaders(ctx, state.client) + headers, err := DiscoverPublicHeaders(ctx, state.client) if err != nil { return nil, err } @@ -1067,20 +1066,20 @@ func (p *nativeHeaderProvider) Close() { // nativeWorkerManager 每账号懒启动纯 Go WAA Worker。 type nativeWorkerManager struct { - pool *aistudio.AccountPool + pool *AccountPool cfg config.EngineBConfig mu sync.Mutex - workers map[string]*aistudio.NativeWorker + workers map[string]*NativeWorker booting map[string]struct{} failed map[string]string // accountID → 最近一次启动失败原因(启动成功后清除) } -func newNativeWorkerManager(pool *aistudio.AccountPool, cfg config.EngineBConfig) *nativeWorkerManager { +func newNativeWorkerManager(pool *AccountPool, cfg config.EngineBConfig) *nativeWorkerManager { return &nativeWorkerManager{ pool: pool, cfg: cfg, - workers: map[string]*aistudio.NativeWorker{}, + workers: map[string]*NativeWorker{}, booting: map[string]struct{}{}, failed: map[string]string{}, } @@ -1096,12 +1095,12 @@ func (m *nativeWorkerManager) noteFailure(accountID string, err error) { m.mu.Unlock() } -// Worker 实现 aistudio.ProtectedPreparerProvider:按账号懒启动(复用已就绪实例)。 -func (m *nativeWorkerManager) Worker(ctx context.Context, accountID string, modelID string) (aistudio.ProtectedPreparer, error) { +// Worker 实现 ProtectedPreparerProvider:按账号懒启动(复用已就绪实例)。 +func (m *nativeWorkerManager) Worker(ctx context.Context, accountID string, modelID string) (ProtectedPreparer, error) { m.mu.Lock() if worker, ok := m.workers[accountID]; ok { phase := worker.State().Phase - if phase == aistudio.WorkerReady || phase == aistudio.WorkerBusy { + if phase == WorkerReady || phase == WorkerBusy { m.mu.Unlock() return worker, nil } @@ -1130,7 +1129,7 @@ func (m *nativeWorkerManager) Worker(ctx context.Context, accountID string, mode if proxy == "" { proxy = strings.TrimSpace(m.cfg.Proxy) } - options := aistudio.Options{ + options := Options{ StorageStatePath: account.StoragePath, Model: modelID, Locale: account.Config.Locale, @@ -1138,7 +1137,7 @@ func (m *nativeWorkerManager) Worker(ctx context.Context, accountID string, mode Proxy: proxy, TemporaryChat: m.cfg.TemporaryChat, } - worker, err := aistudio.NewGoWorker(ctx, accountID, options) + worker, err := NewGoWorker(ctx, accountID, options) if err != nil { bootErr := fmt.Errorf("启动账号 %s 的 WAA Go Worker 失败: %w", accountID, err) m.noteFailure(accountID, bootErr) @@ -1159,7 +1158,7 @@ func (m *nativeWorkerManager) Stats() (ready int, failed map[string]string) { ready = 0 for _, worker := range m.workers { phase := worker.State().Phase - if phase == aistudio.WorkerReady || phase == aistudio.WorkerBusy { + if phase == WorkerReady || phase == WorkerBusy { ready++ } } @@ -1188,7 +1187,7 @@ func (m *nativeWorkerManager) CloseAccount(accountID string) { func (m *nativeWorkerManager) Close() { m.mu.Lock() workers := m.workers - m.workers = map[string]*aistudio.NativeWorker{} + m.workers = map[string]*NativeWorker{} m.mu.Unlock() for _, worker := range workers { _ = worker.Close() diff --git a/internal/aistudio/event.go b/internal/engine/aistudio/event.go similarity index 100% rename from internal/aistudio/event.go rename to internal/engine/aistudio/event.go diff --git a/internal/aistudio/fingerprint/account_fingerprint.go b/internal/engine/aistudio/fingerprint/account_fingerprint.go similarity index 100% rename from internal/aistudio/fingerprint/account_fingerprint.go rename to internal/engine/aistudio/fingerprint/account_fingerprint.go diff --git a/internal/aistudio/fingerprint/fingerprint.go b/internal/engine/aistudio/fingerprint/fingerprint.go similarity index 100% rename from internal/aistudio/fingerprint/fingerprint.go rename to internal/engine/aistudio/fingerprint/fingerprint.go diff --git a/internal/aistudio/fingerprint/number.go b/internal/engine/aistudio/fingerprint/number.go similarity index 100% rename from internal/aistudio/fingerprint/number.go rename to internal/engine/aistudio/fingerprint/number.go diff --git a/internal/aistudio/fingerprint/types.go b/internal/engine/aistudio/fingerprint/types.go similarity index 100% rename from internal/aistudio/fingerprint/types.go rename to internal/engine/aistudio/fingerprint/types.go diff --git a/internal/aistudio/fingerprint/windows_profile.json b/internal/engine/aistudio/fingerprint/windows_profile.json similarity index 100% rename from internal/aistudio/fingerprint/windows_profile.json rename to internal/engine/aistudio/fingerprint/windows_profile.json diff --git a/internal/aistudio/gemma3.model b/internal/engine/aistudio/gemma3.model similarity index 100% rename from internal/aistudio/gemma3.model rename to internal/engine/aistudio/gemma3.model diff --git a/internal/aistudio/generate.go b/internal/engine/aistudio/generate.go similarity index 100% rename from internal/aistudio/generate.go rename to internal/engine/aistudio/generate.go diff --git a/internal/aistudio/interaction.go b/internal/engine/aistudio/interaction.go similarity index 100% rename from internal/aistudio/interaction.go rename to internal/engine/aistudio/interaction.go diff --git a/internal/aistudio/local_defaults.go b/internal/engine/aistudio/local_defaults.go similarity index 100% rename from internal/aistudio/local_defaults.go rename to internal/engine/aistudio/local_defaults.go diff --git a/internal/aistudio/local_defaults_other.go b/internal/engine/aistudio/local_defaults_other.go similarity index 100% rename from internal/aistudio/local_defaults_other.go rename to internal/engine/aistudio/local_defaults_other.go diff --git a/internal/aistudio/local_defaults_windows.go b/internal/engine/aistudio/local_defaults_windows.go similarity index 100% rename from internal/aistudio/local_defaults_windows.go rename to internal/engine/aistudio/local_defaults_windows.go diff --git a/internal/aistudio/media.go b/internal/engine/aistudio/media.go similarity index 100% rename from internal/aistudio/media.go rename to internal/engine/aistudio/media.go diff --git a/internal/aistudio/models.go b/internal/engine/aistudio/models.go similarity index 100% rename from internal/aistudio/models.go rename to internal/engine/aistudio/models.go diff --git a/internal/aistudio/proxy.go b/internal/engine/aistudio/proxy.go similarity index 100% rename from internal/aistudio/proxy.go rename to internal/engine/aistudio/proxy.go diff --git a/internal/aistudio/quota.go b/internal/engine/aistudio/quota.go similarity index 100% rename from internal/aistudio/quota.go rename to internal/engine/aistudio/quota.go diff --git a/internal/aistudio/request.go b/internal/engine/aistudio/request.go similarity index 100% rename from internal/aistudio/request.go rename to internal/engine/aistudio/request.go diff --git a/internal/aistudio/request_phase.go b/internal/engine/aistudio/request_phase.go similarity index 100% rename from internal/aistudio/request_phase.go rename to internal/engine/aistudio/request_phase.go diff --git a/internal/aistudio/runtime_go.go b/internal/engine/aistudio/runtime_go.go similarity index 99% rename from internal/aistudio/runtime_go.go rename to internal/engine/aistudio/runtime_go.go index e21c601..4ad467d 100644 --- a/internal/aistudio/runtime_go.go +++ b/internal/engine/aistudio/runtime_go.go @@ -19,8 +19,8 @@ import ( "time" xproxy "golang.org/x/net/proxy" - "web2api/internal/aistudio/fingerprint" - "web2api/internal/aistudio/waa" + "web2api/internal/engine/aistudio/fingerprint" + "web2api/internal/engine/aistudio/waa" ) const ( diff --git a/internal/aistudio/runtime_native.go b/internal/engine/aistudio/runtime_native.go similarity index 100% rename from internal/aistudio/runtime_native.go rename to internal/engine/aistudio/runtime_native.go diff --git a/internal/aistudio/schema.go b/internal/engine/aistudio/schema.go similarity index 100% rename from internal/aistudio/schema.go rename to internal/engine/aistudio/schema.go diff --git a/internal/aistudio/service.go b/internal/engine/aistudio/service.go similarity index 100% rename from internal/aistudio/service.go rename to internal/engine/aistudio/service.go diff --git a/internal/aistudio/signer.go b/internal/engine/aistudio/signer.go similarity index 100% rename from internal/aistudio/signer.go rename to internal/engine/aistudio/signer.go diff --git a/internal/aistudio/stop.go b/internal/engine/aistudio/stop.go similarity index 100% rename from internal/aistudio/stop.go rename to internal/engine/aistudio/stop.go diff --git a/internal/aistudio/stream_activity.go b/internal/engine/aistudio/stream_activity.go similarity index 100% rename from internal/aistudio/stream_activity.go rename to internal/engine/aistudio/stream_activity.go diff --git a/internal/aistudio/tokenizer.go b/internal/engine/aistudio/tokenizer.go similarity index 100% rename from internal/aistudio/tokenizer.go rename to internal/engine/aistudio/tokenizer.go diff --git a/internal/aistudio/tool_events.go b/internal/engine/aistudio/tool_events.go similarity index 100% rename from internal/aistudio/tool_events.go rename to internal/engine/aistudio/tool_events.go diff --git a/internal/aistudio/tool_validation.go b/internal/engine/aistudio/tool_validation.go similarity index 100% rename from internal/aistudio/tool_validation.go rename to internal/engine/aistudio/tool_validation.go diff --git a/internal/aistudio/tools.go b/internal/engine/aistudio/tools.go similarity index 100% rename from internal/aistudio/tools.go rename to internal/engine/aistudio/tools.go diff --git a/internal/aistudio/transcribe.go b/internal/engine/aistudio/transcribe.go similarity index 100% rename from internal/aistudio/transcribe.go rename to internal/engine/aistudio/transcribe.go diff --git a/internal/aistudio/transport_browser.go b/internal/engine/aistudio/transport_browser.go similarity index 100% rename from internal/aistudio/transport_browser.go rename to internal/engine/aistudio/transport_browser.go diff --git a/internal/aistudio/transport_http.go b/internal/engine/aistudio/transport_http.go similarity index 100% rename from internal/aistudio/transport_http.go rename to internal/engine/aistudio/transport_http.go diff --git a/internal/aistudio/types.go b/internal/engine/aistudio/types.go similarity index 100% rename from internal/aistudio/types.go rename to internal/engine/aistudio/types.go diff --git a/internal/aistudio/upload.go b/internal/engine/aistudio/upload.go similarity index 100% rename from internal/aistudio/upload.go rename to internal/engine/aistudio/upload.go diff --git a/internal/aistudio/usage.go b/internal/engine/aistudio/usage.go similarity index 100% rename from internal/aistudio/usage.go rename to internal/engine/aistudio/usage.go diff --git a/internal/aistudio/video.go b/internal/engine/aistudio/video.go similarity index 100% rename from internal/aistudio/video.go rename to internal/engine/aistudio/video.go diff --git a/internal/aistudio/waa.go b/internal/engine/aistudio/waa.go similarity index 100% rename from internal/aistudio/waa.go rename to internal/engine/aistudio/waa.go diff --git a/internal/aistudio/waa/challenge.go b/internal/engine/aistudio/waa/challenge.go similarity index 100% rename from internal/aistudio/waa/challenge.go rename to internal/engine/aistudio/waa/challenge.go diff --git a/internal/aistudio/waa/dom.js b/internal/engine/aistudio/waa/dom.js similarity index 100% rename from internal/aistudio/waa/dom.js rename to internal/engine/aistudio/waa/dom.js diff --git a/internal/aistudio/waa/firefox152.json b/internal/engine/aistudio/waa/firefox152.json similarity index 100% rename from internal/aistudio/waa/firefox152.json rename to internal/engine/aistudio/waa/firefox152.json diff --git a/internal/aistudio/waa/global_order.go b/internal/engine/aistudio/waa/global_order.go similarity index 100% rename from internal/aistudio/waa/global_order.go rename to internal/engine/aistudio/waa/global_order.go diff --git a/internal/aistudio/waa/goja/LICENSE b/internal/engine/aistudio/waa/goja/LICENSE similarity index 100% rename from internal/aistudio/waa/goja/LICENSE rename to internal/engine/aistudio/waa/goja/LICENSE diff --git a/internal/aistudio/waa/goja/array.go b/internal/engine/aistudio/waa/goja/array.go similarity index 99% rename from internal/aistudio/waa/goja/array.go rename to internal/engine/aistudio/waa/goja/array.go index 59d5602..caa0e05 100644 --- a/internal/aistudio/waa/goja/array.go +++ b/internal/engine/aistudio/waa/goja/array.go @@ -7,7 +7,7 @@ import ( "reflect" "strconv" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type arrayIterObject struct { diff --git a/internal/aistudio/waa/goja/array_sparse.go b/internal/engine/aistudio/waa/goja/array_sparse.go similarity index 99% rename from internal/aistudio/waa/goja/array_sparse.go rename to internal/engine/aistudio/waa/goja/array_sparse.go index 9e8c9e0..6893a5d 100644 --- a/internal/aistudio/waa/goja/array_sparse.go +++ b/internal/engine/aistudio/waa/goja/array_sparse.go @@ -8,7 +8,7 @@ import ( "sort" "strconv" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type sparseArrayItem struct { diff --git a/internal/aistudio/waa/goja/ast/node.go b/internal/engine/aistudio/waa/goja/ast/node.go similarity index 99% rename from internal/aistudio/waa/goja/ast/node.go rename to internal/engine/aistudio/waa/goja/ast/node.go index a14f037..cdd72ea 100644 --- a/internal/aistudio/waa/goja/ast/node.go +++ b/internal/engine/aistudio/waa/goja/ast/node.go @@ -9,9 +9,9 @@ node types are concerned) and may change in the future. package ast import ( - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type PropertyKind string diff --git a/internal/aistudio/waa/goja/builtin_array.go b/internal/engine/aistudio/waa/goja/builtin_array.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_array.go rename to internal/engine/aistudio/waa/goja/builtin_array.go diff --git a/internal/aistudio/waa/goja/builtin_bigint.go b/internal/engine/aistudio/waa/goja/builtin_bigint.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_bigint.go rename to internal/engine/aistudio/waa/goja/builtin_bigint.go index b5eea59..b7a09ad 100644 --- a/internal/aistudio/waa/goja/builtin_bigint.go +++ b/internal/engine/aistudio/waa/goja/builtin_bigint.go @@ -9,7 +9,7 @@ import ( "strconv" "sync" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type valueBigInt big.Int diff --git a/internal/aistudio/waa/goja/builtin_boolean.go b/internal/engine/aistudio/waa/goja/builtin_boolean.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_boolean.go rename to internal/engine/aistudio/waa/goja/builtin_boolean.go diff --git a/internal/aistudio/waa/goja/builtin_date.go b/internal/engine/aistudio/waa/goja/builtin_date.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_date.go rename to internal/engine/aistudio/waa/goja/builtin_date.go diff --git a/internal/aistudio/waa/goja/builtin_error.go b/internal/engine/aistudio/waa/goja/builtin_error.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_error.go rename to internal/engine/aistudio/waa/goja/builtin_error.go diff --git a/internal/aistudio/waa/goja/builtin_function.go b/internal/engine/aistudio/waa/goja/builtin_function.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_function.go rename to internal/engine/aistudio/waa/goja/builtin_function.go diff --git a/internal/aistudio/waa/goja/builtin_global.go b/internal/engine/aistudio/waa/goja/builtin_global.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_global.go rename to internal/engine/aistudio/waa/goja/builtin_global.go index 348890e..d67c036 100644 --- a/internal/aistudio/waa/goja/builtin_global.go +++ b/internal/engine/aistudio/waa/goja/builtin_global.go @@ -10,7 +10,7 @@ import ( "sync" "unicode/utf8" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const hexUpper = "0123456789ABCDEF" diff --git a/internal/aistudio/waa/goja/builtin_json.go b/internal/engine/aistudio/waa/goja/builtin_json.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_json.go rename to internal/engine/aistudio/waa/goja/builtin_json.go index b9a6344..962456e 100644 --- a/internal/aistudio/waa/goja/builtin_json.go +++ b/internal/engine/aistudio/waa/goja/builtin_json.go @@ -12,7 +12,7 @@ import ( "unicode/utf16" "unicode/utf8" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const hex = "0123456789abcdef" diff --git a/internal/aistudio/waa/goja/builtin_map.go b/internal/engine/aistudio/waa/goja/builtin_map.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_map.go rename to internal/engine/aistudio/waa/goja/builtin_map.go diff --git a/internal/aistudio/waa/goja/builtin_math.go b/internal/engine/aistudio/waa/goja/builtin_math.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_math.go rename to internal/engine/aistudio/waa/goja/builtin_math.go diff --git a/internal/aistudio/waa/goja/builtin_number.go b/internal/engine/aistudio/waa/goja/builtin_number.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_number.go rename to internal/engine/aistudio/waa/goja/builtin_number.go index 7248dd9..ca3ac60 100644 --- a/internal/aistudio/waa/goja/builtin_number.go +++ b/internal/engine/aistudio/waa/goja/builtin_number.go @@ -4,7 +4,7 @@ import ( "math" "sync" - "web2api/internal/aistudio/waa/goja/ftoa" + "web2api/internal/engine/aistudio/waa/goja/ftoa" ) func (r *Runtime) toNumber(v Value) Value { diff --git a/internal/aistudio/waa/goja/builtin_object.go b/internal/engine/aistudio/waa/goja/builtin_object.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_object.go rename to internal/engine/aistudio/waa/goja/builtin_object.go diff --git a/internal/aistudio/waa/goja/builtin_promise.go b/internal/engine/aistudio/waa/goja/builtin_promise.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_promise.go rename to internal/engine/aistudio/waa/goja/builtin_promise.go index 950a6a0..a40e1d6 100644 --- a/internal/aistudio/waa/goja/builtin_promise.go +++ b/internal/engine/aistudio/waa/goja/builtin_promise.go @@ -3,7 +3,7 @@ package goja import ( "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type PromiseState int diff --git a/internal/aistudio/waa/goja/builtin_proxy.go b/internal/engine/aistudio/waa/goja/builtin_proxy.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_proxy.go rename to internal/engine/aistudio/waa/goja/builtin_proxy.go index e3c52a6..12656e4 100644 --- a/internal/aistudio/waa/goja/builtin_proxy.go +++ b/internal/engine/aistudio/waa/goja/builtin_proxy.go @@ -1,7 +1,7 @@ package goja import ( - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type nativeProxyHandler struct { diff --git a/internal/aistudio/waa/goja/builtin_reflect.go b/internal/engine/aistudio/waa/goja/builtin_reflect.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_reflect.go rename to internal/engine/aistudio/waa/goja/builtin_reflect.go diff --git a/internal/aistudio/waa/goja/builtin_regexp.go b/internal/engine/aistudio/waa/goja/builtin_regexp.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_regexp.go rename to internal/engine/aistudio/waa/goja/builtin_regexp.go index 06c48d7..4dc6d74 100644 --- a/internal/aistudio/waa/goja/builtin_regexp.go +++ b/internal/engine/aistudio/waa/goja/builtin_regexp.go @@ -9,7 +9,7 @@ import ( "unicode/utf16" "unicode/utf8" - "web2api/internal/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/parser" ) func (r *Runtime) newRegexpObject(proto *Object) *regexpObject { diff --git a/internal/aistudio/waa/goja/builtin_set.go b/internal/engine/aistudio/waa/goja/builtin_set.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_set.go rename to internal/engine/aistudio/waa/goja/builtin_set.go diff --git a/internal/aistudio/waa/goja/builtin_string.go b/internal/engine/aistudio/waa/goja/builtin_string.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_string.go rename to internal/engine/aistudio/waa/goja/builtin_string.go index e428518..a3faf4e 100644 --- a/internal/aistudio/waa/goja/builtin_string.go +++ b/internal/engine/aistudio/waa/goja/builtin_string.go @@ -7,12 +7,12 @@ import ( "unicode/utf16" "unicode/utf8" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" "golang.org/x/text/collate" "golang.org/x/text/language" "golang.org/x/text/unicode/norm" - "web2api/internal/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/parser" ) func (r *Runtime) collator() *collate.Collator { diff --git a/internal/aistudio/waa/goja/builtin_symbol.go b/internal/engine/aistudio/waa/goja/builtin_symbol.go similarity index 98% rename from internal/aistudio/waa/goja/builtin_symbol.go rename to internal/engine/aistudio/waa/goja/builtin_symbol.go index c285a04..fd9d181 100644 --- a/internal/aistudio/waa/goja/builtin_symbol.go +++ b/internal/engine/aistudio/waa/goja/builtin_symbol.go @@ -1,6 +1,6 @@ package goja -import "web2api/internal/aistudio/waa/goja/unistring" +import "web2api/internal/engine/aistudio/waa/goja/unistring" var ( SymHasInstance = newSymbol(asciiString("Symbol.hasInstance")) diff --git a/internal/aistudio/waa/goja/builtin_typedarrays.go b/internal/engine/aistudio/waa/goja/builtin_typedarrays.go similarity index 99% rename from internal/aistudio/waa/goja/builtin_typedarrays.go rename to internal/engine/aistudio/waa/goja/builtin_typedarrays.go index 319d19d..38a0d82 100644 --- a/internal/aistudio/waa/goja/builtin_typedarrays.go +++ b/internal/engine/aistudio/waa/goja/builtin_typedarrays.go @@ -10,7 +10,7 @@ import ( "sync" "unsafe" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type typedArraySortCtx struct { diff --git a/internal/aistudio/waa/goja/builtin_weakmap.go b/internal/engine/aistudio/waa/goja/builtin_weakmap.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_weakmap.go rename to internal/engine/aistudio/waa/goja/builtin_weakmap.go diff --git a/internal/aistudio/waa/goja/builtin_weakset.go b/internal/engine/aistudio/waa/goja/builtin_weakset.go similarity index 100% rename from internal/aistudio/waa/goja/builtin_weakset.go rename to internal/engine/aistudio/waa/goja/builtin_weakset.go diff --git a/internal/aistudio/waa/goja/compiler.go b/internal/engine/aistudio/waa/goja/compiler.go similarity index 99% rename from internal/aistudio/waa/goja/compiler.go rename to internal/engine/aistudio/waa/goja/compiler.go index 6644a92..1b79bac 100644 --- a/internal/aistudio/waa/goja/compiler.go +++ b/internal/engine/aistudio/waa/goja/compiler.go @@ -4,11 +4,11 @@ import ( "fmt" "sort" - "web2api/internal/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type blockType int diff --git a/internal/aistudio/waa/goja/compiler_expr.go b/internal/engine/aistudio/waa/goja/compiler_expr.go similarity index 99% rename from internal/aistudio/waa/goja/compiler_expr.go rename to internal/engine/aistudio/waa/goja/compiler_expr.go index f0d5739..c8a4729 100644 --- a/internal/aistudio/waa/goja/compiler_expr.go +++ b/internal/engine/aistudio/waa/goja/compiler_expr.go @@ -3,10 +3,10 @@ package goja import ( "math/big" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type compiledExpr interface { diff --git a/internal/aistudio/waa/goja/compiler_stmt.go b/internal/engine/aistudio/waa/goja/compiler_stmt.go similarity index 99% rename from internal/aistudio/waa/goja/compiler_stmt.go rename to internal/engine/aistudio/waa/goja/compiler_stmt.go index eea093f..0af7db5 100644 --- a/internal/aistudio/waa/goja/compiler_stmt.go +++ b/internal/engine/aistudio/waa/goja/compiler_stmt.go @@ -1,10 +1,10 @@ package goja import ( - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) func (c *compiler) compileStatement(v ast.Statement, needResult bool) { diff --git a/internal/aistudio/waa/goja/date.go b/internal/engine/aistudio/waa/goja/date.go similarity index 100% rename from internal/aistudio/waa/goja/date.go rename to internal/engine/aistudio/waa/goja/date.go diff --git a/internal/aistudio/waa/goja/date_parser.go b/internal/engine/aistudio/waa/goja/date_parser.go similarity index 100% rename from internal/aistudio/waa/goja/date_parser.go rename to internal/engine/aistudio/waa/goja/date_parser.go diff --git a/internal/aistudio/waa/goja/decompile.go b/internal/engine/aistudio/waa/goja/decompile.go similarity index 96% rename from internal/aistudio/waa/goja/decompile.go rename to internal/engine/aistudio/waa/goja/decompile.go index da1c5ca..605858d 100644 --- a/internal/aistudio/waa/goja/decompile.go +++ b/internal/engine/aistudio/waa/goja/decompile.go @@ -4,8 +4,8 @@ import ( "strconv" "strings" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/token" ) // decompileExpression 按 SpiderMonkey 表达式反编译规则生成错误消息中的表达式文本 diff --git a/internal/aistudio/waa/goja/destruct.go b/internal/engine/aistudio/waa/goja/destruct.go similarity index 99% rename from internal/aistudio/waa/goja/destruct.go rename to internal/engine/aistudio/waa/goja/destruct.go index b31d14c..bbfbe03 100644 --- a/internal/aistudio/waa/goja/destruct.go +++ b/internal/engine/aistudio/waa/goja/destruct.go @@ -3,7 +3,7 @@ package goja import ( "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type destructKeyedSource struct { diff --git a/internal/aistudio/waa/goja/doc.go b/internal/engine/aistudio/waa/goja/doc.go similarity index 100% rename from internal/aistudio/waa/goja/doc.go rename to internal/engine/aistudio/waa/goja/doc.go diff --git a/internal/aistudio/waa/goja/file/file.go b/internal/engine/aistudio/waa/goja/file/file.go similarity index 100% rename from internal/aistudio/waa/goja/file/file.go rename to internal/engine/aistudio/waa/goja/file/file.go diff --git a/internal/aistudio/waa/goja/ftoa/LICENSE_LUCENE b/internal/engine/aistudio/waa/goja/ftoa/LICENSE_LUCENE similarity index 100% rename from internal/aistudio/waa/goja/ftoa/LICENSE_LUCENE rename to internal/engine/aistudio/waa/goja/ftoa/LICENSE_LUCENE diff --git a/internal/aistudio/waa/goja/ftoa/common.go b/internal/engine/aistudio/waa/goja/ftoa/common.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/common.go rename to internal/engine/aistudio/waa/goja/ftoa/common.go diff --git a/internal/aistudio/waa/goja/ftoa/ftoa.go b/internal/engine/aistudio/waa/goja/ftoa/ftoa.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/ftoa.go rename to internal/engine/aistudio/waa/goja/ftoa/ftoa.go diff --git a/internal/aistudio/waa/goja/ftoa/ftobasestr.go b/internal/engine/aistudio/waa/goja/ftoa/ftobasestr.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/ftobasestr.go rename to internal/engine/aistudio/waa/goja/ftoa/ftobasestr.go diff --git a/internal/aistudio/waa/goja/ftoa/ftostr.go b/internal/engine/aistudio/waa/goja/ftoa/ftostr.go similarity index 98% rename from internal/aistudio/waa/goja/ftoa/ftostr.go rename to internal/engine/aistudio/waa/goja/ftoa/ftostr.go index be7aaaa..d1af30d 100644 --- a/internal/aistudio/waa/goja/ftoa/ftostr.go +++ b/internal/engine/aistudio/waa/goja/ftoa/ftostr.go @@ -4,7 +4,7 @@ import ( "math" "strconv" - "web2api/internal/aistudio/waa/goja/ftoa/internal/fast" + "web2api/internal/engine/aistudio/waa/goja/ftoa/internal/fast" ) type FToStrMode int diff --git a/internal/aistudio/waa/goja/ftoa/internal/fast/LICENSE_V8 b/internal/engine/aistudio/waa/goja/ftoa/internal/fast/LICENSE_V8 similarity index 100% rename from internal/aistudio/waa/goja/ftoa/internal/fast/LICENSE_V8 rename to internal/engine/aistudio/waa/goja/ftoa/internal/fast/LICENSE_V8 diff --git a/internal/aistudio/waa/goja/ftoa/internal/fast/cachedpower.go b/internal/engine/aistudio/waa/goja/ftoa/internal/fast/cachedpower.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/internal/fast/cachedpower.go rename to internal/engine/aistudio/waa/goja/ftoa/internal/fast/cachedpower.go diff --git a/internal/aistudio/waa/goja/ftoa/internal/fast/common.go b/internal/engine/aistudio/waa/goja/ftoa/internal/fast/common.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/internal/fast/common.go rename to internal/engine/aistudio/waa/goja/ftoa/internal/fast/common.go diff --git a/internal/aistudio/waa/goja/ftoa/internal/fast/diyfp.go b/internal/engine/aistudio/waa/goja/ftoa/internal/fast/diyfp.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/internal/fast/diyfp.go rename to internal/engine/aistudio/waa/goja/ftoa/internal/fast/diyfp.go diff --git a/internal/aistudio/waa/goja/ftoa/internal/fast/dtoa.go b/internal/engine/aistudio/waa/goja/ftoa/internal/fast/dtoa.go similarity index 100% rename from internal/aistudio/waa/goja/ftoa/internal/fast/dtoa.go rename to internal/engine/aistudio/waa/goja/ftoa/internal/fast/dtoa.go diff --git a/internal/aistudio/waa/goja/func.go b/internal/engine/aistudio/waa/goja/func.go similarity index 99% rename from internal/aistudio/waa/goja/func.go rename to internal/engine/aistudio/waa/goja/func.go index 568b485..7f8857c 100644 --- a/internal/aistudio/waa/goja/func.go +++ b/internal/engine/aistudio/waa/goja/func.go @@ -4,7 +4,7 @@ import ( "fmt" "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type resultType uint8 diff --git a/internal/aistudio/waa/goja/global_order.go b/internal/engine/aistudio/waa/goja/global_order.go similarity index 98% rename from internal/aistudio/waa/goja/global_order.go rename to internal/engine/aistudio/waa/goja/global_order.go index 024ee1d..8562dfb 100644 --- a/internal/aistudio/waa/goja/global_order.go +++ b/internal/engine/aistudio/waa/goja/global_order.go @@ -3,7 +3,7 @@ package goja import ( "strings" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) // GlobalObserver 接收全局对象的惰性名称解析、属性定义与删除,并给出自有字符串键的枚举顺序 diff --git a/internal/aistudio/waa/goja/ipow.go b/internal/engine/aistudio/waa/goja/ipow.go similarity index 100% rename from internal/aistudio/waa/goja/ipow.go rename to internal/engine/aistudio/waa/goja/ipow.go diff --git a/internal/aistudio/waa/goja/map.go b/internal/engine/aistudio/waa/goja/map.go similarity index 100% rename from internal/aistudio/waa/goja/map.go rename to internal/engine/aistudio/waa/goja/map.go diff --git a/internal/aistudio/waa/goja/names.go b/internal/engine/aistudio/waa/goja/names.go similarity index 97% rename from internal/aistudio/waa/goja/names.go rename to internal/engine/aistudio/waa/goja/names.go index dfaade2..26c735e 100644 --- a/internal/aistudio/waa/goja/names.go +++ b/internal/engine/aistudio/waa/goja/names.go @@ -5,10 +5,10 @@ import ( "strconv" "strings" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) // functionNameResolver 按 SpiderMonkey NameFunctions 规则推断匿名函数的栈显示名 diff --git a/internal/aistudio/waa/goja/object.go b/internal/engine/aistudio/waa/goja/object.go similarity index 99% rename from internal/aistudio/waa/goja/object.go rename to internal/engine/aistudio/waa/goja/object.go index ad8be40..81e444a 100644 --- a/internal/aistudio/waa/goja/object.go +++ b/internal/engine/aistudio/waa/goja/object.go @@ -6,7 +6,7 @@ import ( "reflect" "sort" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const ( diff --git a/internal/aistudio/waa/goja/object_args.go b/internal/engine/aistudio/waa/goja/object_args.go similarity index 98% rename from internal/aistudio/waa/goja/object_args.go rename to internal/engine/aistudio/waa/goja/object_args.go index 34fe464..814868f 100644 --- a/internal/aistudio/waa/goja/object_args.go +++ b/internal/engine/aistudio/waa/goja/object_args.go @@ -1,6 +1,6 @@ package goja -import "web2api/internal/aistudio/waa/goja/unistring" +import "web2api/internal/engine/aistudio/waa/goja/unistring" type argumentsObject struct { baseObject diff --git a/internal/aistudio/waa/goja/object_dynamic.go b/internal/engine/aistudio/waa/goja/object_dynamic.go similarity index 99% rename from internal/aistudio/waa/goja/object_dynamic.go rename to internal/engine/aistudio/waa/goja/object_dynamic.go index fd017a8..c5e97f1 100644 --- a/internal/aistudio/waa/goja/object_dynamic.go +++ b/internal/engine/aistudio/waa/goja/object_dynamic.go @@ -5,7 +5,7 @@ import ( "reflect" "strconv" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) /* diff --git a/internal/aistudio/waa/goja/object_goarray_reflect.go b/internal/engine/aistudio/waa/goja/object_goarray_reflect.go similarity index 99% rename from internal/aistudio/waa/goja/object_goarray_reflect.go rename to internal/engine/aistudio/waa/goja/object_goarray_reflect.go index ce29fc5..5ab2b41 100644 --- a/internal/aistudio/waa/goja/object_goarray_reflect.go +++ b/internal/engine/aistudio/waa/goja/object_goarray_reflect.go @@ -4,7 +4,7 @@ import ( "reflect" "strconv" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type objectGoArrayReflect struct { diff --git a/internal/aistudio/waa/goja/object_gomap.go b/internal/engine/aistudio/waa/goja/object_gomap.go similarity index 98% rename from internal/aistudio/waa/goja/object_gomap.go rename to internal/engine/aistudio/waa/goja/object_gomap.go index 448a351..8ef2f1d 100644 --- a/internal/aistudio/waa/goja/object_gomap.go +++ b/internal/engine/aistudio/waa/goja/object_gomap.go @@ -3,7 +3,7 @@ package goja import ( "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type objectGoMapSimple struct { diff --git a/internal/aistudio/waa/goja/object_gomap_reflect.go b/internal/engine/aistudio/waa/goja/object_gomap_reflect.go similarity index 99% rename from internal/aistudio/waa/goja/object_gomap_reflect.go rename to internal/engine/aistudio/waa/goja/object_gomap_reflect.go index 9b6074c..661f6b5 100644 --- a/internal/aistudio/waa/goja/object_gomap_reflect.go +++ b/internal/engine/aistudio/waa/goja/object_gomap_reflect.go @@ -4,7 +4,7 @@ import ( "fmt" "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type objectGoMapReflect struct { diff --git a/internal/aistudio/waa/goja/object_goreflect.go b/internal/engine/aistudio/waa/goja/object_goreflect.go similarity index 99% rename from internal/aistudio/waa/goja/object_goreflect.go rename to internal/engine/aistudio/waa/goja/object_goreflect.go index cfd8e78..33be005 100644 --- a/internal/aistudio/waa/goja/object_goreflect.go +++ b/internal/engine/aistudio/waa/goja/object_goreflect.go @@ -6,8 +6,8 @@ import ( "reflect" "strings" - "web2api/internal/aistudio/waa/goja/parser" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) // JsonEncodable allows custom JSON encoding by JSON.stringify() diff --git a/internal/aistudio/waa/goja/object_goslice.go b/internal/engine/aistudio/waa/goja/object_goslice.go similarity index 99% rename from internal/aistudio/waa/goja/object_goslice.go rename to internal/engine/aistudio/waa/goja/object_goslice.go index da1a54f..8f20995 100644 --- a/internal/aistudio/waa/goja/object_goslice.go +++ b/internal/engine/aistudio/waa/goja/object_goslice.go @@ -6,7 +6,7 @@ import ( "reflect" "strconv" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type objectGoSlice struct { diff --git a/internal/aistudio/waa/goja/object_goslice_reflect.go b/internal/engine/aistudio/waa/goja/object_goslice_reflect.go similarity index 97% rename from internal/aistudio/waa/goja/object_goslice_reflect.go rename to internal/engine/aistudio/waa/goja/object_goslice_reflect.go index d054d1f..0243001 100644 --- a/internal/aistudio/waa/goja/object_goslice_reflect.go +++ b/internal/engine/aistudio/waa/goja/object_goslice_reflect.go @@ -5,7 +5,7 @@ import ( "math/bits" "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type objectGoSliceReflect struct { diff --git a/internal/aistudio/waa/goja/object_template.go b/internal/engine/aistudio/waa/goja/object_template.go similarity index 99% rename from internal/aistudio/waa/goja/object_template.go rename to internal/engine/aistudio/waa/goja/object_template.go index 820d896..e365c6e 100644 --- a/internal/aistudio/waa/goja/object_template.go +++ b/internal/engine/aistudio/waa/goja/object_template.go @@ -5,7 +5,7 @@ import ( "math" "reflect" "sort" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type templatePropFactory func(*Runtime) Value diff --git a/internal/aistudio/waa/goja/order.go b/internal/engine/aistudio/waa/goja/order.go similarity index 94% rename from internal/aistudio/waa/goja/order.go rename to internal/engine/aistudio/waa/goja/order.go index ec084b6..d320a85 100644 --- a/internal/aistudio/waa/goja/order.go +++ b/internal/engine/aistudio/waa/goja/order.go @@ -1,6 +1,6 @@ package goja -import "web2api/internal/aistudio/waa/goja/unistring" +import "web2api/internal/engine/aistudio/waa/goja/unistring" // orderOwnKeys 按 SpiderMonkey 的定义顺序重排对象的字符串键,未列出的键按原相对顺序排在其后 func (o *baseObject) orderOwnKeys(names ...string) { diff --git a/internal/aistudio/waa/goja/parser/error.go b/internal/engine/aistudio/waa/goja/parser/error.go similarity index 98% rename from internal/aistudio/waa/goja/parser/error.go rename to internal/engine/aistudio/waa/goja/parser/error.go index df63812..7250550 100644 --- a/internal/aistudio/waa/goja/parser/error.go +++ b/internal/engine/aistudio/waa/goja/parser/error.go @@ -4,8 +4,8 @@ import ( "fmt" "sort" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" ) const ( diff --git a/internal/aistudio/waa/goja/parser/expression.go b/internal/engine/aistudio/waa/goja/parser/expression.go similarity index 99% rename from internal/aistudio/waa/goja/parser/expression.go rename to internal/engine/aistudio/waa/goja/parser/expression.go index ec1c2d2..3e2c573 100644 --- a/internal/aistudio/waa/goja/parser/expression.go +++ b/internal/engine/aistudio/waa/goja/parser/expression.go @@ -3,10 +3,10 @@ package parser import ( "strings" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) func (self *_parser) parseIdentifier() *ast.Identifier { diff --git a/internal/aistudio/waa/goja/parser/lexer.go b/internal/engine/aistudio/waa/goja/parser/lexer.go similarity index 99% rename from internal/aistudio/waa/goja/parser/lexer.go rename to internal/engine/aistudio/waa/goja/parser/lexer.go index a2d50e9..2236285 100644 --- a/internal/aistudio/waa/goja/parser/lexer.go +++ b/internal/engine/aistudio/waa/goja/parser/lexer.go @@ -12,9 +12,9 @@ import ( "golang.org/x/text/unicode/rangetable" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) var ( diff --git a/internal/aistudio/waa/goja/parser/parser.go b/internal/engine/aistudio/waa/goja/parser/parser.go similarity index 96% rename from internal/aistudio/waa/goja/parser/parser.go rename to internal/engine/aistudio/waa/goja/parser/parser.go index 5160c6c..39e0884 100644 --- a/internal/aistudio/waa/goja/parser/parser.go +++ b/internal/engine/aistudio/waa/goja/parser/parser.go @@ -2,7 +2,7 @@ Package parser implements a parser for JavaScript. import ( - "web2api/internal/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/parser" ) Parse and return an AST @@ -38,10 +38,10 @@ import ( "io" "os" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) // A Mode value is a set of flags (or 0). They control optional parser functionality. diff --git a/internal/aistudio/waa/goja/parser/regexp.go b/internal/engine/aistudio/waa/goja/parser/regexp.go similarity index 100% rename from internal/aistudio/waa/goja/parser/regexp.go rename to internal/engine/aistudio/waa/goja/parser/regexp.go diff --git a/internal/aistudio/waa/goja/parser/scope.go b/internal/engine/aistudio/waa/goja/parser/scope.go similarity index 90% rename from internal/aistudio/waa/goja/parser/scope.go rename to internal/engine/aistudio/waa/goja/parser/scope.go index 2d22137..42c14d6 100644 --- a/internal/aistudio/waa/goja/parser/scope.go +++ b/internal/engine/aistudio/waa/goja/parser/scope.go @@ -1,8 +1,8 @@ package parser import ( - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type _scope struct { diff --git a/internal/aistudio/waa/goja/parser/statement.go b/internal/engine/aistudio/waa/goja/parser/statement.go similarity index 99% rename from internal/aistudio/waa/goja/parser/statement.go rename to internal/engine/aistudio/waa/goja/parser/statement.go index 4a849a3..48920f9 100644 --- a/internal/aistudio/waa/goja/parser/statement.go +++ b/internal/engine/aistudio/waa/goja/parser/statement.go @@ -7,9 +7,9 @@ import ( "strings" "github.com/go-sourcemap/sourcemap" - "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/token" + "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/token" ) func (self *_parser) parseBlockStatement() *ast.BlockStatement { diff --git a/internal/aistudio/waa/goja/profiler.go b/internal/engine/aistudio/waa/goja/profiler.go similarity index 100% rename from internal/aistudio/waa/goja/profiler.go rename to internal/engine/aistudio/waa/goja/profiler.go diff --git a/internal/aistudio/waa/goja/proxy.go b/internal/engine/aistudio/waa/goja/proxy.go similarity index 99% rename from internal/aistudio/waa/goja/proxy.go rename to internal/engine/aistudio/waa/goja/proxy.go index a74636b..3f00ae2 100644 --- a/internal/aistudio/waa/goja/proxy.go +++ b/internal/engine/aistudio/waa/goja/proxy.go @@ -4,7 +4,7 @@ import ( "fmt" "reflect" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) // Proxy is a Go wrapper around ECMAScript Proxy. Calling Runtime.ToValue() on it diff --git a/internal/aistudio/waa/goja/regexp.go b/internal/engine/aistudio/waa/goja/regexp.go similarity index 99% rename from internal/aistudio/waa/goja/regexp.go rename to internal/engine/aistudio/waa/goja/regexp.go index 8080a9e..a9c4591 100644 --- a/internal/aistudio/waa/goja/regexp.go +++ b/internal/engine/aistudio/waa/goja/regexp.go @@ -9,7 +9,7 @@ import ( "unicode/utf16" "github.com/dlclark/regexp2/v2" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type regexp2MatchCache struct { diff --git a/internal/aistudio/waa/goja/runtime.go b/internal/engine/aistudio/waa/goja/runtime.go similarity index 99% rename from internal/aistudio/waa/goja/runtime.go rename to internal/engine/aistudio/waa/goja/runtime.go index c365a12..cc0e03e 100644 --- a/internal/aistudio/waa/goja/runtime.go +++ b/internal/engine/aistudio/waa/goja/runtime.go @@ -17,10 +17,10 @@ import ( "golang.org/x/text/collate" - js_ast "web2api/internal/aistudio/waa/goja/ast" - "web2api/internal/aistudio/waa/goja/file" - "web2api/internal/aistudio/waa/goja/parser" - "web2api/internal/aistudio/waa/goja/unistring" + js_ast "web2api/internal/engine/aistudio/waa/goja/ast" + "web2api/internal/engine/aistudio/waa/goja/file" + "web2api/internal/engine/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const ( diff --git a/internal/aistudio/waa/goja/string.go b/internal/engine/aistudio/waa/goja/string.go similarity index 99% rename from internal/aistudio/waa/goja/string.go rename to internal/engine/aistudio/waa/goja/string.go index e0c14f5..f6c8722 100644 --- a/internal/aistudio/waa/goja/string.go +++ b/internal/engine/aistudio/waa/goja/string.go @@ -6,7 +6,7 @@ import ( "strings" "unicode/utf8" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const ( diff --git a/internal/aistudio/waa/goja/string_ascii.go b/internal/engine/aistudio/waa/goja/string_ascii.go similarity index 99% rename from internal/aistudio/waa/goja/string_ascii.go rename to internal/engine/aistudio/waa/goja/string_ascii.go index 348f89c..41d3c2f 100644 --- a/internal/aistudio/waa/goja/string_ascii.go +++ b/internal/engine/aistudio/waa/goja/string_ascii.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type asciiString string diff --git a/internal/aistudio/waa/goja/string_imported.go b/internal/engine/aistudio/waa/goja/string_imported.go similarity index 98% rename from internal/aistudio/waa/goja/string_imported.go rename to internal/engine/aistudio/waa/goja/string_imported.go index 754050a..6c28f9a 100644 --- a/internal/aistudio/waa/goja/string_imported.go +++ b/internal/engine/aistudio/waa/goja/string_imported.go @@ -9,8 +9,8 @@ import ( "unicode/utf16" "unicode/utf8" - "web2api/internal/aistudio/waa/goja/parser" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/unistring" "golang.org/x/text/cases" "golang.org/x/text/language" diff --git a/internal/aistudio/waa/goja/string_unicode.go b/internal/engine/aistudio/waa/goja/string_unicode.go similarity index 99% rename from internal/aistudio/waa/goja/string_unicode.go rename to internal/engine/aistudio/waa/goja/string_unicode.go index 165d45d..5f3553d 100644 --- a/internal/aistudio/waa/goja/string_unicode.go +++ b/internal/engine/aistudio/waa/goja/string_unicode.go @@ -15,8 +15,8 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" - "web2api/internal/aistudio/waa/goja/parser" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/parser" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const utf16IndexNaiveCrossover = 32 diff --git a/internal/aistudio/waa/goja/token/token.go b/internal/engine/aistudio/waa/goja/token/token.go similarity index 100% rename from internal/aistudio/waa/goja/token/token.go rename to internal/engine/aistudio/waa/goja/token/token.go diff --git a/internal/aistudio/waa/goja/token/token_const.go b/internal/engine/aistudio/waa/goja/token/token_const.go similarity index 100% rename from internal/aistudio/waa/goja/token/token_const.go rename to internal/engine/aistudio/waa/goja/token/token_const.go diff --git a/internal/aistudio/waa/goja/typedarrays.go b/internal/engine/aistudio/waa/goja/typedarrays.go similarity index 99% rename from internal/aistudio/waa/goja/typedarrays.go rename to internal/engine/aistudio/waa/goja/typedarrays.go index 81d3e9a..6f7a591 100644 --- a/internal/aistudio/waa/goja/typedarrays.go +++ b/internal/engine/aistudio/waa/goja/typedarrays.go @@ -7,7 +7,7 @@ import ( "strconv" "unsafe" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) type byteOrder bool diff --git a/internal/aistudio/waa/goja/unistring/string.go b/internal/engine/aistudio/waa/goja/unistring/string.go similarity index 100% rename from internal/aistudio/waa/goja/unistring/string.go rename to internal/engine/aistudio/waa/goja/unistring/string.go diff --git a/internal/aistudio/waa/goja/value.go b/internal/engine/aistudio/waa/goja/value.go similarity index 99% rename from internal/aistudio/waa/goja/value.go rename to internal/engine/aistudio/waa/goja/value.go index 000409c..eb81714 100644 --- a/internal/aistudio/waa/goja/value.go +++ b/internal/engine/aistudio/waa/goja/value.go @@ -9,8 +9,8 @@ import ( "strconv" "unsafe" - "web2api/internal/aistudio/waa/goja/ftoa" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/ftoa" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) var ( diff --git a/internal/aistudio/waa/goja/vm.go b/internal/engine/aistudio/waa/goja/vm.go similarity index 99% rename from internal/aistudio/waa/goja/vm.go rename to internal/engine/aistudio/waa/goja/vm.go index 41d67c2..171c5fb 100644 --- a/internal/aistudio/waa/goja/vm.go +++ b/internal/engine/aistudio/waa/goja/vm.go @@ -10,7 +10,7 @@ import ( "sync/atomic" "time" - "web2api/internal/aistudio/waa/goja/unistring" + "web2api/internal/engine/aistudio/waa/goja/unistring" ) const ( diff --git a/internal/aistudio/waa/host.go b/internal/engine/aistudio/waa/host.go similarity index 99% rename from internal/aistudio/waa/host.go rename to internal/engine/aistudio/waa/host.go index 130e50c..f9c35e0 100644 --- a/internal/aistudio/waa/host.go +++ b/internal/engine/aistudio/waa/host.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "web2api/internal/aistudio/waa/goja" + "web2api/internal/engine/aistudio/waa/goja" ) //go:embed dom.js diff --git a/internal/aistudio/waa/loop.go b/internal/engine/aistudio/waa/loop.go similarity index 100% rename from internal/aistudio/waa/loop.go rename to internal/engine/aistudio/waa/loop.go diff --git a/internal/aistudio/waa/realm.go b/internal/engine/aistudio/waa/realm.go similarity index 99% rename from internal/aistudio/waa/realm.go rename to internal/engine/aistudio/waa/realm.go index 35b2981..924b247 100644 --- a/internal/aistudio/waa/realm.go +++ b/internal/engine/aistudio/waa/realm.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "web2api/internal/aistudio/waa/goja" + "web2api/internal/engine/aistudio/waa/goja" ) // maxCallStackDepth 为 JavaScript 调用栈深度上限,超过时抛出 InternalError diff --git a/internal/aistudio/waa/runtime.go b/internal/engine/aistudio/waa/runtime.go similarity index 99% rename from internal/aistudio/waa/runtime.go rename to internal/engine/aistudio/waa/runtime.go index 851e69d..de6251d 100644 --- a/internal/aistudio/waa/runtime.go +++ b/internal/engine/aistudio/waa/runtime.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "web2api/internal/aistudio/waa/goja" + "web2api/internal/engine/aistudio/waa/goja" ) // Options 定义一份 BotGuard VM 的解释器、宿主现场与网络依赖 diff --git a/internal/aistudio/waa/timezone.go b/internal/engine/aistudio/waa/timezone.go similarity index 100% rename from internal/aistudio/waa/timezone.go rename to internal/engine/aistudio/waa/timezone.go diff --git a/internal/aistudio/waa/timezones.json.gz b/internal/engine/aistudio/waa/timezones.json.gz similarity index 100% rename from internal/aistudio/waa/timezones.json.gz rename to internal/engine/aistudio/waa/timezones.json.gz diff --git a/internal/aistudio/worker_options.go b/internal/engine/aistudio/worker_options.go similarity index 100% rename from internal/aistudio/worker_options.go rename to internal/engine/aistudio/worker_options.go diff --git a/internal/aistudio/youtube.go b/internal/engine/aistudio/youtube.go similarity index 100% rename from internal/aistudio/youtube.go rename to internal/engine/aistudio/youtube.go diff --git a/internal/upstream/antigravity/antigravity.go b/internal/engine/antigravity/antigravity.go similarity index 99% rename from internal/upstream/antigravity/antigravity.go rename to internal/engine/antigravity/antigravity.go index e5b6218..4058ce5 100644 --- a/internal/upstream/antigravity/antigravity.go +++ b/internal/engine/antigravity/antigravity.go @@ -983,6 +983,15 @@ func buildBody(req *Request) (*internalRequest, error) { continue // system 走 systemInstruction case "tool": // 工具结果必须以 functionResponse 回填,否则模型会反复发起同一调用。 + // 并行工具结果合并进同一条 user content(Gemini 惯例),避免连续 + // 多条 user 被上游当成非法多轮。 + if n := len(out.Request.Contents); n > 0 { + prev := &out.Request.Contents[n-1] + if prev.Role == "user" && geminiPartsAllFunctionResponses(prev.Parts) { + appendFunctionResponsePart(&prev.Parts, msg.ToolCallID, msg.ToolName, msg.Text) + continue + } + } content := geminiContent{Role: "user"} appendFunctionResponsePart(&content.Parts, msg.ToolCallID, msg.ToolName, msg.Text) out.Request.Contents = append(out.Request.Contents, content) diff --git a/internal/provider/engine_antigravity.go b/internal/engine/antigravity/engine.go similarity index 77% rename from internal/provider/engine_antigravity.go rename to internal/engine/antigravity/engine.go index 343245d..1e9a8d6 100644 --- a/internal/provider/engine_antigravity.go +++ b/internal/engine/antigravity/engine.go @@ -1,6 +1,6 @@ -// engine_antigravity.go 引擎 antigravity:Google Antigravity(Cloud AI Companion) +// engine_go 引擎 antigravity:Google Antigravity(Cloud AI Companion) // 订阅号池。凭据为 Google OAuth refresh_token,project_id 自动 onboard。 -package provider +package antigravity import ( "context" @@ -20,11 +20,10 @@ import ( "web2api/internal/engine" "web2api/internal/model" "web2api/internal/store" - "web2api/internal/upstream/antigravity" ) -// AntigravityCredentials antigravity 引擎凭据(accounts.credentials JSON)。 -type AntigravityCredentials struct { +// Credentials antigravity 引擎凭据(accounts.credentials JSON)。 +type Credentials struct { RefreshToken string `json:"refresh_token"` Email string `json:"email,omitempty"` ProjectID string `json:"project_id,omitempty"` // cloudaicompanion 项目,留空自动 onboard @@ -35,7 +34,7 @@ type AntigravityCredentials struct { type antigravityAccount struct { id, label string mu sync.Mutex - creds AntigravityCredentials + creds Credentials enabled bool accessToken string @@ -44,9 +43,9 @@ type antigravityAccount struct { lastErr string planType string // 订阅档位 credits string // AI Credits 余额描述 - models []antigravity.ModelMeta // 上游真实模型目录(网关可路由的全量) + models []ModelMeta // 上游真实模型目录(网关可路由的全量) selector []string // 官方下拉模型(agentModelSorts ∩ 目录) - quotaGroups []antigravity.QuotaGroup // 官方用量分组(weekly + 5h 窗口) + quotaGroups []QuotaGroup // 官方用量分组(weekly + 5h 窗口) cooldownUntil time.Time cooldownReason string @@ -59,8 +58,8 @@ func (a *antigravityAccount) proxy(engineProxy string) string { return engineProxy } -// AntigravityEngine antigravity 引擎。 -type AntigravityEngine struct { +// Engine antigravity 引擎。 +type Engine struct { name string proxy string st *store.Store @@ -75,8 +74,8 @@ type AntigravityEngine struct { } // NewAntigravity 构建 antigravity 引擎。 -func NewAntigravity(cfg config.AntigravityConfig, st *store.Store) *AntigravityEngine { - return &AntigravityEngine{ +func New(cfg config.AntigravityConfig, st *store.Store) *Engine { + return &Engine{ name: "antigravity", proxy: strings.TrimSpace(cfg.Proxy), st: st, @@ -85,7 +84,7 @@ func NewAntigravity(cfg config.AntigravityConfig, st *store.Store) *AntigravityE } // AddAccount 添加账号并异步初始化(刷新 token + 获取 project_id)。 -func (e *AntigravityEngine) AddAccount(id, label string, creds AntigravityCredentials) error { +func (e *Engine) AddAccount(id, label string, creds Credentials) error { if strings.TrimSpace(creds.RefreshToken) == "" { return errors.New("refresh_token 不能为空") } @@ -103,7 +102,7 @@ func (e *AntigravityEngine) AddAccount(id, label string, creds AntigravityCreden } // initialize 首次初始化:刷新 token → 确保 project_id。 -func (e *AntigravityEngine) initialize(acc *antigravityAccount) { +func (e *Engine) initialize(acc *antigravityAccount) { acc.mu.Lock() refreshToken := acc.creds.RefreshToken proxy := acc.proxy(e.proxy) @@ -111,7 +110,7 @@ func (e *AntigravityEngine) initialize(acc *antigravityAccount) { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - tok, err := antigravity.Refresh(ctx, proxy, refreshToken) + tok, err := Refresh(ctx, proxy, refreshToken) if err != nil { acc.mu.Lock() acc.ready = false @@ -134,7 +133,7 @@ func (e *AntigravityEngine) initialize(acc *antigravityAccount) { acc.mu.Unlock() // 先查账号信息(套餐 + AI Credits + project 兜底) - if info, lerr := antigravity.LoadCodeAssist(ctx, proxy, tok.AccessToken); lerr == nil && info != nil { + if info, lerr := LoadCodeAssist(ctx, proxy, tok.AccessToken); lerr == nil && info != nil { planType = info.PlanType credits = info.Credits if projectID == "" && info.ProjectID != "" { @@ -142,7 +141,7 @@ func (e *AntigravityEngine) initialize(acc *antigravityAccount) { } } if projectID == "" { - info, err := antigravity.EnsureProject(ctx, proxy, tok.AccessToken) + info, err := EnsureProject(ctx, proxy, tok.AccessToken) if err != nil { acc.mu.Lock() acc.ready = false @@ -160,7 +159,7 @@ func (e *AntigravityEngine) initialize(acc *antigravityAccount) { acc.mu.Unlock() if e.st != nil { if dbID, perr := strconv.ParseInt(acc.id, 10, 64); perr == nil { - _ = e.st.UpdateAccountRawCredentials(dbID, marshalCompact(acc.creds)) + _ = e.st.UpdateAccountRawCredentials(dbID, engine.MarshalCompact(acc.creds)) } } } @@ -182,7 +181,7 @@ func (e *AntigravityEngine) initialize(acc *antigravityAccount) { if needPersist && e.st != nil { if dbID, perr := strconv.ParseInt(acc.id, 10, 64); perr == nil { acc.mu.Lock() - raw := marshalCompact(acc.creds) + raw := engine.MarshalCompact(acc.creds) acc.mu.Unlock() _ = e.st.UpdateAccountRawCredentials(dbID, raw) } @@ -195,7 +194,7 @@ func (e *AntigravityEngine) initialize(acc *antigravityAccount) { } // refreshCatalog 拉取账号的真实模型目录与官方下拉模型列表。 -func (e *AntigravityEngine) refreshCatalog(acc *antigravityAccount) { +func (e *Engine) refreshCatalog(acc *antigravityAccount) { acc.mu.Lock() proxy := acc.proxy(e.proxy) accessToken := acc.accessToken @@ -204,7 +203,7 @@ func (e *AntigravityEngine) refreshCatalog(acc *antigravityAccount) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - catalog, err := antigravity.FetchCatalog(ctx, proxy, accessToken, projectID) + catalog, err := FetchCatalog(ctx, proxy, accessToken, projectID) if err != nil { return // 目录拉取失败时继续用已有目录 } @@ -217,7 +216,7 @@ func (e *AntigravityEngine) refreshCatalog(acc *antigravityAccount) { // assembleQuota 组合权益展示(对齐官方 IDE "View Usage"): // AI Credits 余额 + 各模型分组的周/5 小时剩余比例(含重置倒计时)。 // 官方分组数据缺失时回退为模型 5h 剩余比例的最小值摘要。 -func assembleQuota(credits string, groups []antigravity.QuotaGroup, models []antigravity.ModelMeta) string { +func assembleQuota(credits string, groups []QuotaGroup, models []ModelMeta) string { var parts []string if s := strings.TrimSpace(credits); s != "" { parts = append(parts, s) @@ -260,7 +259,7 @@ func assembleQuota(credits string, groups []antigravity.QuotaGroup, models []ant } // formatQuotaGroup 格式化一个官方分组:"Gemini 周69%(5d3h) · 5h100%"。 -func formatQuotaGroup(g antigravity.QuotaGroup) string { +func formatQuotaGroup(g QuotaGroup) string { var buckets []string for _, b := range g.Buckets { if s := formatQuotaBucket(b); s != "" { @@ -287,7 +286,7 @@ func shortGroupName(display string) string { // formatQuotaBucket 格式化单个配额窗口:"周69%(5d3h)" / "5h100%"。 // 剩余 100% 或无重置时间时不带倒计时。 -func formatQuotaBucket(b antigravity.QuotaBucket) string { +func formatQuotaBucket(b QuotaBucket) string { window := quotaWindow(b) if window == "" { return "" @@ -307,7 +306,7 @@ func formatQuotaBucket(b antigravity.QuotaBucket) string { } // quotaWindow 判定配额窗口类型(优先 Window 字段,回退解析 ID)。 -func quotaWindow(b antigravity.QuotaBucket) string { +func quotaWindow(b QuotaBucket) string { if w := strings.ToLower(strings.TrimSpace(b.Window)); w == "weekly" || w == "5h" { return w } @@ -345,7 +344,7 @@ func formatCountdown(t time.Time) string { } // RemoveAccount 移除账号。 -func (e *AntigravityEngine) RemoveAccount(id string) error { +func (e *Engine) RemoveAccount(id string) error { e.mu.Lock() idx, ok := e.ids[id] if !ok { @@ -364,7 +363,7 @@ func (e *AntigravityEngine) RemoveAccount(id string) error { } // SetEnabled 启停账号。 -func (e *AntigravityEngine) SetEnabled(id string, enabled bool) error { +func (e *Engine) SetEnabled(id string, enabled bool) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -378,7 +377,7 @@ func (e *AntigravityEngine) SetEnabled(id string, enabled bool) error { } // ReplaceCredentials 更新凭据(重建账号)。 -func (e *AntigravityEngine) ReplaceCredentials(id string, creds AntigravityCredentials) error { +func (e *Engine) ReplaceCredentials(id string, creds Credentials) error { e.mu.RLock() idx, ok := e.ids[id] label := id @@ -396,17 +395,17 @@ func (e *AntigravityEngine) ReplaceCredentials(id string, creds AntigravityCrede } // AccountStates 全部账号状态(管理台)。 -func (e *AntigravityEngine) AccountStates() []AccountState { +func (e *Engine) AccountStates() []engine.AccountState { e.mu.RLock() defer e.mu.RUnlock() - out := make([]AccountState, 0, len(e.accounts)) + out := make([]engine.AccountState, 0, len(e.accounts)) for _, acc := range e.accounts { acc.mu.Lock() models := len(acc.models) if len(acc.selector) > 0 { models = len(acc.selector) // 对齐官方下拉的模型数量 } - state := AccountState{ + state := engine.AccountState{ ID: acc.id, Label: acc.label, Enabled: acc.enabled, @@ -440,7 +439,7 @@ func (e *AntigravityEngine) AccountStates() []AccountState { } // ensureToken 刷新账号 token。 -func (e *AntigravityEngine) ensureToken(acc *antigravityAccount) error { +func (e *Engine) ensureToken(acc *antigravityAccount) error { acc.mu.Lock() refreshToken := acc.creds.RefreshToken proxy := acc.proxy(e.proxy) @@ -448,7 +447,7 @@ func (e *AntigravityEngine) ensureToken(acc *antigravityAccount) error { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - tok, err := antigravity.Refresh(ctx, proxy, refreshToken) + tok, err := Refresh(ctx, proxy, refreshToken) acc.mu.Lock() defer acc.mu.Unlock() if err != nil { @@ -466,7 +465,7 @@ func (e *AntigravityEngine) ensureToken(acc *antigravityAccount) error { acc.lastErr = "" if e.st != nil { if dbID, perr := strconv.ParseInt(acc.id, 10, 64); perr == nil { - _ = e.st.UpdateAccountRawCredentials(dbID, marshalCompact(acc.creds)) + _ = e.st.UpdateAccountRawCredentials(dbID, engine.MarshalCompact(acc.creds)) } } e.setErr(nil) @@ -478,10 +477,10 @@ func (e *AntigravityEngine) ensureToken(acc *antigravityAccount) error { } // refreshEntitlement 刷新账号套餐与 AI Credits 余额(best-effort,失败不影响 ready)。 -func (e *AntigravityEngine) refreshEntitlement(acc *antigravityAccount, proxy, accessToken string) { +func (e *Engine) refreshEntitlement(acc *antigravityAccount, proxy, accessToken string) { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - info, err := antigravity.LoadCodeAssist(ctx, proxy, accessToken) + info, err := LoadCodeAssist(ctx, proxy, accessToken) if err != nil || info == nil { return } @@ -498,7 +497,7 @@ func (e *AntigravityEngine) refreshEntitlement(acc *antigravityAccount, proxy, a // refreshQuotaSummary 拉取官方用量分组(weekly + 5h 剩余,对齐 IDE "View Usage")。 // best-effort,失败不影响 ready;注意 RetrieveQuotaSummary 走直连 HTTP/2,不经过代理。 -func (e *AntigravityEngine) refreshQuotaSummary(acc *antigravityAccount) { +func (e *Engine) refreshQuotaSummary(acc *antigravityAccount) { acc.mu.Lock() accessToken := acc.accessToken acc.mu.Unlock() @@ -507,7 +506,7 @@ func (e *AntigravityEngine) refreshQuotaSummary(acc *antigravityAccount) { } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - groups, err := antigravity.RetrieveQuotaSummary(ctx, accessToken) + groups, err := RetrieveQuotaSummary(ctx, accessToken) if err != nil || len(groups) == 0 { return } @@ -517,7 +516,7 @@ func (e *AntigravityEngine) refreshQuotaSummary(acc *antigravityAccount) { } // token 返回有效 access_token + project_id。 -func (e *AntigravityEngine) token(acc *antigravityAccount) (string, string, error) { +func (e *Engine) token(acc *antigravityAccount) (string, string, error) { acc.mu.Lock() needRefresh := acc.accessToken == "" || (acc.expiresAt.After(time.Time{}) && time.Until(acc.expiresAt) < 3*time.Minute) tok := acc.accessToken @@ -543,14 +542,14 @@ func (e *AntigravityEngine) token(acc *antigravityAccount) (string, string, erro // ---- Engine 接口 ---- -func (e *AntigravityEngine) Name() string { return e.name } +func (e *Engine) Name() string { return e.name } -func (e *AntigravityEngine) Describe() string { +func (e *Engine) Describe() string { return "Google Antigravity(Cloud AI Companion 订阅号池,v1internal 协议)" } // Ready 至少一个账号可用。 -func (e *AntigravityEngine) Ready() bool { +func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() for _, acc := range e.accounts { @@ -566,7 +565,7 @@ func (e *AntigravityEngine) Ready() bool { // ListModels antigravity 模型目录:优先上游真实目录(fetchAvailableModels), // 拉取失败时回落内置默认目录。 -func (e *AntigravityEngine) ListModels() []model.ModelInfo { +func (e *Engine) ListModels() []model.ModelInfo { e.mu.RLock() enabled := 0 real := map[string]bool{} @@ -605,7 +604,7 @@ func (e *AntigravityEngine) ListModels() []model.ModelInfo { } return out } - for _, id := range antigravity.DefaultModels { + for _, id := range DefaultModels { add(id) } return out @@ -614,7 +613,7 @@ func (e *AntigravityEngine) ListModels() []model.ModelInfo { // resolveModel 请求模型名 → 上游真实模型 ID。 // 顺序:真实目录精确命中 → 内置目录精确命中 → 裸名变体解析(gemini-3-pro → // gemini-3-pro-preview/-high/-low/-tiered 中存在的一个)→ 原样直传。 -func (e *AntigravityEngine) resolveModel(acc *antigravityAccount, name string) string { +func (e *Engine) resolveModel(acc *antigravityAccount, name string) string { lower := strings.ToLower(strings.TrimSpace(name)) // 收集该账号可见的真实目录 acc.mu.Lock() @@ -641,14 +640,14 @@ func (e *AntigravityEngine) resolveModel(acc *antigravityAccount, name string) s return lower // 真实目录存在但未命中:透传(让上游给出明确错误) } // 无真实目录:内置目录兼容映射 - if mapped, ok := antigravity.DefaultModelVariants[lower]; ok { + if mapped, ok := DefaultModelVariants[lower]; ok { return mapped } return lower } // pick 轮询选择可用账号。 -func (e *AntigravityEngine) pick() *antigravityAccount { +func (e *Engine) pick() *antigravityAccount { e.mu.RLock() defer e.mu.RUnlock() n := len(e.accounts) @@ -668,38 +667,32 @@ func (e *AntigravityEngine) pick() *antigravityAccount { return nil } -func (e *AntigravityEngine) markCooldown(acc *antigravityAccount, reason string, d time.Duration) { +func (e *Engine) markCooldown(acc *antigravityAccount, reason string, d time.Duration) { acc.mu.Lock() acc.cooldownUntil = time.Now().Add(d) acc.cooldownReason = reason acc.mu.Unlock() } -// antigravityToolsSupported 是否把 tools 下发给 Antigravity 上游。 -// Claude 等非 Gemini 模型走同一条 v1internal,但不接受 Gemini functionDeclarations; -// v0.3.8 一律透传后,Codex 自带 tools 会让这些模型整请求 400。Gemini 才开透传。 -func antigravityToolsSupported(modelName string) bool { - m := strings.ToLower(strings.TrimSpace(modelName)) - m = strings.TrimPrefix(m, "models/") - return strings.Contains(m, "gemini") -} - -// toAntigravityRequest ChatRequest → antigravity.Request。 -func toAntigravityRequest(req model.ChatRequest, projectID string) *antigravity.Request { - out := &antigravity.Request{ +// toAntigravityRequest ChatRequest → Request。 +// 是否透传 tools 由能力表决定:Claude 等非 Gemini 模型走同一条 v1internal, +// 但不接受 Gemini functionDeclarations,v0.3.8 一律透传后 Codex 自带 tools +// 会让这些模型整请求 400——只有 Gemini 系才开透传(CapsFor)。 +func toAntigravityRequest(req model.ChatRequest, projectID string) *Request { + out := &Request{ Model: req.Model, - System: subSystemText(req), - MaxTokens: derefInt(req.MaxTokens), + System: engine.SubSystemText(req), + MaxTokens: engine.DerefInt(req.MaxTokens), ProjectID: projectID, } - withTools := antigravityToolsSupported(req.Model) + withTools := engine.CapsFor("antigravity", req.Model).Tools if withTools { out.ToolChoice = req.ToolChoice for _, tool := range req.Tools { if tool.Function == nil || strings.TrimSpace(tool.Function.Name) == "" { continue } - out.Tools = append(out.Tools, antigravity.Tool{ + out.Tools = append(out.Tools, Tool{ Name: tool.Function.Name, Description: tool.Function.Description, Parameters: tool.Function.Parameters, @@ -713,7 +706,7 @@ func toAntigravityRequest(req model.ChatRequest, projectID string) *antigravity. continue case "tool": if withTools { - out.Messages = append(out.Messages, antigravity.Message{ + out.Messages = append(out.Messages, Message{ Role: "tool", Text: m.Text(), ToolCallID: m.ToolCallID, @@ -725,17 +718,17 @@ func toAntigravityRequest(req model.ChatRequest, projectID string) *antigravity. if name := strings.TrimSpace(m.Name); name != "" { text = "[" + name + "] " + text } - out.Messages = append(out.Messages, antigravity.Message{Role: "user", Text: text}) + out.Messages = append(out.Messages, Message{Role: "user", Text: text}) } case "assistant": - msg := antigravity.Message{ + msg := Message{ Role: "assistant", Text: m.Text(), - ImageURLs: subImageURLs(m.Content), + ImageURLs: engine.SubImageURLs(m.Content), } if withTools { for _, call := range m.ToolCalls { - msg.ToolCalls = append(msg.ToolCalls, antigravity.ToolCall{ + msg.ToolCalls = append(msg.ToolCalls, ToolCall{ ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments, @@ -743,12 +736,17 @@ func toAntigravityRequest(req model.ChatRequest, projectID string) *antigravity. }) } } + // 无工具通道时丢掉「只含 tool_calls」的空 assistant,避免上游收到 + // 连续空 model/user 回合(多轮 Codex 工具史)。 + if strings.TrimSpace(msg.Text) == "" && len(msg.ToolCalls) == 0 && len(msg.ImageURLs) == 0 { + continue + } out.Messages = append(out.Messages, msg) default: - out.Messages = append(out.Messages, antigravity.Message{ + out.Messages = append(out.Messages, Message{ Role: m.PlainRole(), Text: m.Text(), - ImageURLs: subImageURLs(m.Content), + ImageURLs: engine.SubImageURLs(m.Content), }) } } @@ -772,18 +770,18 @@ func thoughtSigFromExtra(extra json.RawMessage) string { } // chatOnce 单账号一次请求(401 刷新重试;带 tools 的 400 会无工具再试一次)。 -func (e *AntigravityEngine) chatOnce(ctx context.Context, acc *antigravityAccount, req *antigravity.Request, onDelta func(string) error, onTool func(antigravity.ToolCallDelta) error) (*antigravity.Result, error) { - attempt := func(r *antigravity.Request, toolsCB func(antigravity.ToolCallDelta) error) (*antigravity.Result, error) { +func (e *Engine) chatOnce(ctx context.Context, acc *antigravityAccount, req *Request, onDelta func(string) error, onTool func(ToolCallDelta) error) (*Result, error) { + attempt := func(r *Request, toolsCB func(ToolCallDelta) error) (*Result, error) { accessToken, projectID, err := e.token(acc) if err != nil { return nil, err } r.ProjectID = projectID r.Model = e.resolveModel(acc, r.Model) - return antigravity.StreamWithTools(ctx, acc.proxy(e.proxy), accessToken, r, onDelta, toolsCB) + return StreamWithTools(ctx, acc.proxy(e.proxy), accessToken, r, onDelta, toolsCB) } res, err := attempt(req, onTool) - if err != nil && antigravity.IsUnauthorized(err) { + if err != nil && IsUnauthorized(err) { if refreshErr := e.ensureToken(acc); refreshErr != nil { return nil, fmt.Errorf("token 已失效且刷新失败: %w", refreshErr) } @@ -791,21 +789,37 @@ func (e *AntigravityEngine) chatOnce(ctx context.Context, acc *antigravityAccoun } // Gemini 工具协议偶发不被上游接受(schema / 模型档位):去掉 tools 再试, // 恢复成 v0.3.7「静默丢工具、纯文本仍可用」的行为,避免整请求挂死。 - if err != nil && len(req.Tools) > 0 && antigravity.IsBadRequest(err) { + // 仅对开了工具透传的模型(能力表 RetryNoToolsOn400)生效;req.Tools + // 非空本身就说明走的是 Gemini 工具通道。 + // Messages 必须深拷贝:浅拷贝会改坏原 req,换号重试时工具历史会被污染。 + if err != nil && len(req.Tools) > 0 && engine.CapsFor("antigravity", req.Model).RetryNoToolsOn400 && IsBadRequest(err) { stripped := *req stripped.Tools = nil stripped.ToolChoice = nil - for i := range stripped.Messages { - stripped.Messages[i].ToolCalls = nil - if strings.EqualFold(stripped.Messages[i].Role, "tool") { - stripped.Messages[i].Role = "user" + stripped.Messages = make([]Message, 0, len(req.Messages)) + for _, m := range req.Messages { + cp := m + cp.ToolCalls = nil + if strings.EqualFold(cp.Role, "tool") { + text := cp.Text + if name := strings.TrimSpace(cp.ToolName); name != "" { + text = "[" + name + "] " + text + } + cp.Role = "user" + cp.Text = text + cp.ToolCallID = "" + cp.ToolName = "" + } + if strings.EqualFold(cp.Role, "assistant") && strings.TrimSpace(cp.Text) == "" && len(m.ToolCalls) > 0 && len(cp.ImageURLs) == 0 { + continue } + stripped.Messages = append(stripped.Messages, cp) } if retry, retryErr := attempt(&stripped, nil); retryErr == nil { return retry, nil } } - if err != nil && antigravity.IsResourceExhausted(err) { + if err != nil && IsResourceExhausted(err) { e.markCooldown(acc, "上游 429 限流", 5*time.Minute) go e.refreshCatalog(acc) go e.refreshQuotaSummary(acc) @@ -814,9 +828,9 @@ func (e *AntigravityEngine) chatOnce(ctx context.Context, acc *antigravityAccoun } // chatLoop 轮询账号直到成功或池耗尽。 -func (e *AntigravityEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *Engine) chatLoop(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { if !e.Ready() { - return nil, engineUnavailableError(e) + return nil, engine.UnavailableError(e) } if sink == nil { sink = model.TextSink(nil) @@ -829,7 +843,7 @@ func (e *AntigravityEngine) chatLoop(ctx context.Context, req model.ChatRequest, if acc == nil { break } - res, err := e.chatOnce(ctx, acc, cReq, sink.Text, func(delta antigravity.ToolCallDelta) error { + res, err := e.chatOnce(ctx, acc, cReq, sink.Text, func(delta ToolCallDelta) error { extra := json.RawMessage(nil) if strings.TrimSpace(delta.ThoughtSig) != "" { b, mErr := json.Marshal(map[string]any{ @@ -855,7 +869,7 @@ func (e *AntigravityEngine) chatLoop(ctx context.Context, req model.ChatRequest, Text: res.Text, Engine: e.name, Model: req.Model, - Usage: subResultUsage(res.PromptTokens, res.CompletionTokens+res.ReasoningTokens, res.ReasoningTokens), + Usage: engine.SubResultUsage(res.PromptTokens, res.CompletionTokens+res.ReasoningTokens, res.ReasoningTokens), } for _, call := range res.ToolCalls { tc := model.ToolCall{ @@ -885,7 +899,7 @@ func (e *AntigravityEngine) chatLoop(ctx context.Context, req model.ChatRequest, if ctx.Err() != nil { return nil, ctx.Err() } - if antigravity.IsUnauthorized(err) || strings.Contains(err.Error(), "刷新失败") || strings.Contains(err.Error(), "project_id") { + if IsUnauthorized(err) || strings.Contains(err.Error(), "刷新失败") || strings.Contains(err.Error(), "project_id") { e.markCooldown(acc, "凭据/项目异常", 10*time.Minute) continue } @@ -896,7 +910,7 @@ func (e *AntigravityEngine) chatLoop(ctx context.Context, req model.ChatRequest, return nil, errors.New("antigravity 没有可用账号") } -func (e *AntigravityEngine) usableCount() int { +func (e *Engine) usableCount() int { e.mu.RLock() defer e.mu.RUnlock() n := 0 @@ -914,28 +928,28 @@ func (e *AntigravityEngine) usableCount() int { } // Chat 非流式对话。 -func (e *AntigravityEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { return e.chatLoop(ctx, req, nil) } // ChatStream 流式对话。 -func (e *AntigravityEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { return e.chatLoop(ctx, req, model.TextSink(onDelta)) } // ChatStreamSink 流式对话(支持工具调用增量)。 -func (e *AntigravityEngine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *Engine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { return e.chatLoop(ctx, req, sink) } -func (e *AntigravityEngine) setErr(err error) { +func (e *Engine) setErr(err error) { e.errMu.Lock() e.lastErr = err e.errMu.Unlock() } // Status 引擎运行状态。 -func (e *AntigravityEngine) Status() map[string]any { +func (e *Engine) Status() map[string]any { accounts := e.AccountStates() ready := 0 for _, st := range accounts { @@ -960,11 +974,11 @@ func (e *AntigravityEngine) Status() map[string]any { } // Passthrough antigravity 不支持多模态端点透传。 -func (e *AntigravityEngine) Passthrough() bool { return false } +func (e *Engine) Passthrough() bool { return false } -func (e *AntigravityEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("antigravity 引擎不支持透传") } // Close 关闭引擎。 -func (e *AntigravityEngine) Close() {} +func (e *Engine) Close() {} diff --git a/internal/provider/antigravity_tools_test.go b/internal/engine/antigravity/engine_test.go similarity index 86% rename from internal/provider/antigravity_tools_test.go rename to internal/engine/antigravity/engine_test.go index 1cd0313..1ed9d0d 100644 --- a/internal/provider/antigravity_tools_test.go +++ b/internal/engine/antigravity/engine_test.go @@ -1,4 +1,4 @@ -package provider +package antigravity import ( "encoding/json" @@ -65,6 +65,10 @@ func TestToAntigravityRequestStripsToolsForClaude(t *testing.T) { if len(out.ToolChoice) != 0 { t.Fatalf("claude should not receive tool_choice") } + // 只含 tool_calls 的空 assistant 应被丢掉,tool 折成 user:user + user。 + if len(out.Messages) != 2 { + t.Fatalf("want 2 messages after strip, got %#v", out.Messages) + } for _, m := range out.Messages { if len(m.ToolCalls) != 0 { t.Fatalf("assistant tool_calls should be stripped for claude") @@ -72,6 +76,12 @@ func TestToAntigravityRequestStripsToolsForClaude(t *testing.T) { if strings.EqualFold(m.Role, "tool") { t.Fatalf("tool role should be flattened for claude") } + if strings.EqualFold(m.Role, "assistant") { + t.Fatalf("empty tool-only assistant should be dropped, got %#v", m) + } + } + if out.Messages[1].Role != "user" || !strings.Contains(out.Messages[1].Text, "ok") { + t.Fatalf("tool fold=%#v", out.Messages[1]) } } diff --git a/internal/upstream/antigravity/tools.go b/internal/engine/antigravity/tools.go similarity index 93% rename from internal/upstream/antigravity/tools.go rename to internal/engine/antigravity/tools.go index 34cf2f8..961d9db 100644 --- a/internal/upstream/antigravity/tools.go +++ b/internal/engine/antigravity/tools.go @@ -137,6 +137,19 @@ func appendFunctionCallPart(parts *[]geminiPart, call ToolCall) { *parts = append(*parts, part) } +// geminiPartsAllFunctionResponses 判断 content 是否只含 functionResponse(可安全合并并行工具结果)。 +func geminiPartsAllFunctionResponses(parts []geminiPart) bool { + if len(parts) == 0 { + return false + } + for _, p := range parts { + if p.FunctionResponse == nil { + return false + } + } + return true +} + // appendFunctionResponsePart 把工具执行结果写成 user 角色的 functionResponse part。 func appendFunctionResponsePart(parts *[]geminiPart, callID, name, output string) { name = strings.TrimSpace(name) diff --git a/internal/upstream/antigravity/tools_test.go b/internal/engine/antigravity/tools_test.go similarity index 79% rename from internal/upstream/antigravity/tools_test.go rename to internal/engine/antigravity/tools_test.go index dc84ac7..27cb0b7 100644 --- a/internal/upstream/antigravity/tools_test.go +++ b/internal/engine/antigravity/tools_test.go @@ -69,6 +69,37 @@ func TestBuildBodyToolResultAsFunctionResponse(t *testing.T) { } } +func TestBuildBodyMergesParallelFunctionResponses(t *testing.T) { + body, err := buildBody(&Request{ + Model: "gemini-3.8-flash", + Messages: []Message{ + {Role: "user", Text: "run two"}, + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "c1", Name: "a", Arguments: `{}`}, + {ID: "c2", Name: "b", Arguments: `{}`}, + }}, + {Role: "tool", ToolCallID: "c1", ToolName: "a", Text: "one"}, + {Role: "tool", ToolCallID: "c2", ToolName: "b", Text: "two"}, + }, + }) + if err != nil { + t.Fatal(err) + } + userCount := 0 + for _, c := range body.Request.Contents { + if c.Role != "user" { + continue + } + userCount++ + if userCount == 2 && len(c.Parts) != 2 { + t.Fatalf("parallel tool results should merge into one user with 2 parts, got %d parts", len(c.Parts)) + } + } + if userCount != 2 { + t.Fatalf("want 2 user turns (prompt + merged tools), got %d", userCount) + } +} + func TestParseSSEFunctionCallOnly(t *testing.T) { sse := strings.Join([]string{ `data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"exec_command","args":{"cmd":"ls"}}}]}}]}}`, diff --git a/internal/engine/capability.go b/internal/engine/capability.go new file mode 100644 index 0000000..91e4e99 --- /dev/null +++ b/internal/engine/capability.go @@ -0,0 +1,72 @@ +package engine + +import "strings" + +// EngineCaps 描述「引擎 × 模型族」对 function calling 与消息清洗的兼容 +// 行为。适配器据此决定 IR → 上游报文的改写方式;路由层不查能力表—— +// 引擎选择仍由模型目录与可用性决定。 +// +// 首版能力表(完整背景见 docs/web2api-responses-adapter-plan.md §3): +// +// 能力 antigravity+Gemini antigravity+其他 custom/MiniMax aistudio/codex(OpenAI 兼容) +// 透传 function tools 是 否(先剥) 是 是 +// assistant content=null 不适用(Text 协议) 折文本时清洗 出站改 "" 出站改 "" +// 空 tool-only assistant 保留 去掉 按上游 按上游 +// tool 历史折成 user 文本 否 是 仅 400 降级时 仅 400 降级时 +// 带 tools 的 400 无工具重试 是 — 是 否 +// +// assistant content=null → "" 的改写在 OpenAI 兼容出站客户端 +// (engine/upstream.chatPayload)统一执行,故不设能力位。 +// 失败用量记 Engine 是全局行为(Manager.Chat/ChatStream 出错仍返回带 +// Engine 的 ChatResult),同样不设能力位。 +type EngineCaps struct { + // Tools 是否把 function tools / tool_choice 下发给上游。 + Tools bool + // FlattenToolHistory 无函数调用通道:把 tool 历史折成 user 文本、 + // 丢弃只含 tool_calls 的空 assistant(Claude 清洗 / 纯文本引擎)。 + FlattenToolHistory bool + // RetryNoToolsOn400 带 tools 的请求被上游 400 拒绝时,去掉 tools + // 降级重试一次,保证纯文本对话仍可用。 + RetryNoToolsOn400 bool +} + +// antigravityCaps / customCaps / openaiCompatCaps 是能力表首版的具名值, +// 适配器与测试都从这里取,避免字面量散落。 +var ( + antigravityGeminiCaps = EngineCaps{Tools: true, RetryNoToolsOn400: true} + antigravityPlainCaps = EngineCaps{FlattenToolHistory: true} + customCaps = EngineCaps{Tools: true, RetryNoToolsOn400: true} + openaiCompatCaps = EngineCaps{Tools: true} + plainTextCaps = EngineCaps{FlattenToolHistory: true} +) + +// CapsFor 查询能力表。engine 是引擎注册名(geminiweb/aistudio/codex/antigravity/kiro/ +// workbuddy/custom),未知引擎一律取保守值:不透传工具、折纯文本。 +func CapsFor(engine, modelName string) EngineCaps { + switch engine { + case "antigravity": + // Antigravity 目录里只有 Gemini 系模型有可用的 function calling + // 通道;Claude 等模型不收 Gemini tools 协议,先剥成纯文本。 + if antigravityGeminiFamily(modelName) { + return antigravityGeminiCaps + } + return antigravityPlainCaps + case "custom": + // MiniMax 等第三方 OpenAI 兼容上游能收 function tools,但 Codex + // 自带的复杂 schema 常被整请求 400,需要无工具降级兜底。 + return customCaps + case "aistudio", "codex": + // AI Studio / Codex 官方端点:协议标准,直接透传。 + return openaiCompatCaps + default: + // a / kiro / workbuddy 及未知引擎:没有函数调用通道。 + return plainTextCaps + } +} + +// antigravityGeminiFamily Antigravity 上的模型是否属于 Gemini 系。 +func antigravityGeminiFamily(modelName string) bool { + m := strings.ToLower(strings.TrimSpace(modelName)) + m = strings.TrimPrefix(m, "models/") + return strings.Contains(m, "gemini") +} diff --git a/internal/engine/capability_test.go b/internal/engine/capability_test.go new file mode 100644 index 0000000..7801f12 --- /dev/null +++ b/internal/engine/capability_test.go @@ -0,0 +1,41 @@ +package engine + +import "testing" + +func TestCapsForAntigravitySplitsByModelFamily(t *testing.T) { + gemini := CapsFor("antigravity", "gemini-3.8-flash") + if !gemini.Tools || !gemini.RetryNoToolsOn400 || gemini.FlattenToolHistory { + t.Fatalf("gemini caps=%#v", gemini) + } + claude := CapsFor("antigravity", "claude-sonnet-4") + if claude.Tools || !claude.FlattenToolHistory || claude.RetryNoToolsOn400 { + t.Fatalf("claude caps=%#v", claude) + } + if !CapsFor("antigravity", "models/gemini-2.5-pro").Tools { + t.Fatal("models/ prefix should be tolerated") + } +} + +func TestCapsForOpenAICompatFamily(t *testing.T) { + for _, eng := range []string{"custom", "aistudio", "codex"} { + caps := CapsFor(eng, "whatever-model") + if !caps.Tools || caps.FlattenToolHistory { + t.Fatalf("%s caps=%#v", eng, caps) + } + if eng == "custom" && !caps.RetryNoToolsOn400 { + t.Fatalf("custom must retry without tools on 400: %#v", caps) + } + if (eng == "aistudio" || eng == "codex") && caps.RetryNoToolsOn400 { + t.Fatalf("%s should not silently drop tools: %#v", eng, caps) + } + } +} + +func TestCapsForUnknownEngineIsConservative(t *testing.T) { + for _, eng := range []string{"a", "kiro", "workbuddy", "no-such-engine"} { + caps := CapsFor(eng, "gemini-flash") + if caps.Tools || caps.RetryNoToolsOn400 || !caps.FlattenToolHistory { + t.Fatalf("%s caps=%#v", eng, caps) + } + } +} diff --git a/internal/upstream/codex/codex.go b/internal/engine/codex/codex.go similarity index 100% rename from internal/upstream/codex/codex.go rename to internal/engine/codex/codex.go diff --git a/internal/provider/engine_codex.go b/internal/engine/codex/engine.go similarity index 74% rename from internal/provider/engine_codex.go rename to internal/engine/codex/engine.go index a439d15..eaf7b84 100644 --- a/internal/provider/engine_codex.go +++ b/internal/engine/codex/engine.go @@ -1,6 +1,6 @@ -// engine_codex.go 引擎 codex:OpenAI Codex(ChatGPT 订阅号)账号池。 +// engine_go 引擎 codex:OpenAI Codex(ChatGPT 订阅号)账号池。 // 凭据为 OAuth refresh_token,运行时自动刷新 access_token,401 自动重试。 -package provider +package codex import ( "context" @@ -17,11 +17,10 @@ import ( "web2api/internal/engine" "web2api/internal/model" "web2api/internal/store" - "web2api/internal/upstream/codex" ) -// CodexCredentials codex 引擎凭据(accounts.credentials JSON)。 -type CodexCredentials struct { +// Credentials codex 引擎凭据(accounts.credentials JSON)。 +type Credentials struct { RefreshToken string `json:"refresh_token"` Email string `json:"email,omitempty"` ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` @@ -32,7 +31,7 @@ type CodexCredentials struct { type codexAccount struct { id, label string mu sync.Mutex - creds CodexCredentials + creds Credentials enabled bool accessToken string @@ -52,8 +51,8 @@ func (a *codexAccount) proxy(engineProxy string) string { return engineProxy } -// CodexEngine codex 引擎。 -type CodexEngine struct { +// Engine codex 引擎。 +type Engine struct { name string proxy string st *store.Store @@ -68,8 +67,8 @@ type CodexEngine struct { } // NewCodex 构建 codex 引擎(账号通过 AddAccount 动态注入)。 -func NewCodex(cfg config.CodexConfig, st *store.Store) *CodexEngine { - return &CodexEngine{ +func New(cfg config.CodexConfig, st *store.Store) *Engine { + return &Engine{ name: "codex", proxy: strings.TrimSpace(cfg.Proxy), st: st, @@ -78,7 +77,7 @@ func NewCodex(cfg config.CodexConfig, st *store.Store) *CodexEngine { } // AddAccount 添加账号并异步首刷 token。 -func (e *CodexEngine) AddAccount(id, label string, creds CodexCredentials) error { +func (e *Engine) AddAccount(id, label string, creds Credentials) error { if strings.TrimSpace(creds.RefreshToken) == "" { return errors.New("refresh_token 不能为空") } @@ -96,7 +95,7 @@ func (e *CodexEngine) AddAccount(id, label string, creds CodexCredentials) error } // RemoveAccount 移除账号。 -func (e *CodexEngine) RemoveAccount(id string) error { +func (e *Engine) RemoveAccount(id string) error { e.mu.Lock() idx, ok := e.ids[id] if !ok { @@ -115,7 +114,7 @@ func (e *CodexEngine) RemoveAccount(id string) error { } // SetEnabled 启停账号。 -func (e *CodexEngine) SetEnabled(id string, enabled bool) error { +func (e *Engine) SetEnabled(id string, enabled bool) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -129,7 +128,7 @@ func (e *CodexEngine) SetEnabled(id string, enabled bool) error { } // ReplaceCredentials 更新凭据(重建账号)。 -func (e *CodexEngine) ReplaceCredentials(id string, creds CodexCredentials) error { +func (e *Engine) ReplaceCredentials(id string, creds Credentials) error { label, err := e.labelOf(id) if err != nil { return err @@ -140,7 +139,7 @@ func (e *CodexEngine) ReplaceCredentials(id string, creds CodexCredentials) erro return e.AddAccount(id, label, creds) } -func (e *CodexEngine) labelOf(id string) (string, error) { +func (e *Engine) labelOf(id string) (string, error) { e.mu.RLock() defer e.mu.RUnlock() idx, ok := e.ids[id] @@ -151,20 +150,20 @@ func (e *CodexEngine) labelOf(id string) (string, error) { } // AccountStates 全部账号状态(管理台)。 -func (e *CodexEngine) AccountStates() []AccountState { +func (e *Engine) AccountStates() []engine.AccountState { e.mu.RLock() defer e.mu.RUnlock() - out := make([]AccountState, 0, len(e.accounts)) + out := make([]engine.AccountState, 0, len(e.accounts)) for _, acc := range e.accounts { acc.mu.Lock() - state := AccountState{ + state := engine.AccountState{ ID: acc.id, Label: acc.label, Enabled: acc.enabled, Detail: acc.lastErr, Tier: acc.creds.Email, Quota: acc.quota, - Models: len(codex.DefaultModels), + Models: len(DefaultModels), } switch { case !acc.enabled: @@ -190,7 +189,7 @@ func (e *CodexEngine) AccountStates() []AccountState { // ---- token 生命周期 ---- // ensureToken 刷新账号 token(加锁防并发),成功回写池内状态。 -func (e *CodexEngine) ensureToken(acc *codexAccount) error { +func (e *Engine) ensureToken(acc *codexAccount) error { acc.mu.Lock() refreshToken := acc.creds.RefreshToken proxy := acc.proxy(e.proxy) @@ -198,7 +197,7 @@ func (e *CodexEngine) ensureToken(acc *codexAccount) error { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - tok, err := codex.Refresh(ctx, proxy, refreshToken) + tok, err := Refresh(ctx, proxy, refreshToken) acc.mu.Lock() defer acc.mu.Unlock() if err != nil { @@ -223,7 +222,7 @@ func (e *CodexEngine) ensureToken(acc *codexAccount) error { // 回写凭据(refresh_token 轮换),不动账号状态 if e.st != nil { if dbID, err := strconv.ParseInt(acc.id, 10, 64); err == nil { - _ = e.st.UpdateAccountRawCredentials(dbID, marshalCompact(acc.creds)) + _ = e.st.UpdateAccountRawCredentials(dbID, engine.MarshalCompact(acc.creds)) } } e.setErr(nil) @@ -231,7 +230,7 @@ func (e *CodexEngine) ensureToken(acc *codexAccount) error { } // token 返回有效 access_token;过期(提前 3 分钟)时刷新。 -func (e *CodexEngine) token(acc *codexAccount) (string, string, error) { +func (e *Engine) token(acc *codexAccount) (string, string, error) { acc.mu.Lock() needRefresh := acc.accessToken == "" || (acc.expiresAt.After(time.Time{}) && time.Until(acc.expiresAt) < 3*time.Minute) tok := acc.accessToken @@ -255,14 +254,14 @@ func (e *CodexEngine) token(acc *codexAccount) (string, string, error) { // ---- Engine 接口 ---- -func (e *CodexEngine) Name() string { return e.name } +func (e *Engine) Name() string { return e.name } -func (e *CodexEngine) Describe() string { +func (e *Engine) Describe() string { return "OpenAI Codex(ChatGPT 订阅号池,Responses 协议)" } // Ready 至少一个账号可用。 -func (e *CodexEngine) Ready() bool { +func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() for _, acc := range e.accounts { @@ -277,7 +276,7 @@ func (e *CodexEngine) Ready() bool { } // ListModels codex 模型目录。 -func (e *CodexEngine) ListModels() []model.ModelInfo { +func (e *Engine) ListModels() []model.ModelInfo { e.mu.RLock() enabled := 0 for _, acc := range e.accounts { @@ -288,8 +287,8 @@ func (e *CodexEngine) ListModels() []model.ModelInfo { acc.mu.Unlock() } e.mu.RUnlock() - out := make([]model.ModelInfo, 0, len(codex.DefaultModels)) - for _, id := range codex.DefaultModels { + out := make([]model.ModelInfo, 0, len(DefaultModels)) + for _, id := range DefaultModels { out = append(out, model.ModelInfo{ ID: id, Object: "model", @@ -302,7 +301,7 @@ func (e *CodexEngine) ListModels() []model.ModelInfo { } // pick 轮询选择可用账号。 -func (e *CodexEngine) pick() *codexAccount { +func (e *Engine) pick() *codexAccount { e.mu.RLock() defer e.mu.RUnlock() n := len(e.accounts) @@ -322,31 +321,31 @@ func (e *CodexEngine) pick() *codexAccount { return nil } -func (e *CodexEngine) markCooldown(acc *codexAccount, reason string, d time.Duration) { +func (e *Engine) markCooldown(acc *codexAccount, reason string, d time.Duration) { acc.mu.Lock() acc.cooldownUntil = time.Now().Add(d) acc.cooldownReason = reason acc.mu.Unlock() } -// toCodexRequest ChatRequest → codex.Request。 -func toCodexRequest(req model.ChatRequest) *codex.Request { - out := &codex.Request{ +// toCodexRequest ChatRequest → Request。 +func toCodexRequest(req model.ChatRequest) *Request { + out := &Request{ Model: req.Model, - MaxTokens: derefInt(req.MaxTokens), + MaxTokens: engine.DerefInt(req.MaxTokens), Tools: toCodexTools(req.Tools), ToolChoice: req.ToolChoice, } for _, m := range req.Messages { - msg := codex.Message{ + msg := Message{ Role: m.Role, Text: m.Text(), - ImageURLs: subImageURLs(m.Content), + ImageURLs: engine.SubImageURLs(m.Content), ToolCallID: m.ToolCallID, Name: m.Name, } for _, call := range m.ToolCalls { - msg.ToolCalls = append(msg.ToolCalls, codex.ToolCall{ + msg.ToolCalls = append(msg.ToolCalls, ToolCall{ ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments, @@ -358,13 +357,13 @@ func toCodexRequest(req model.ChatRequest) *codex.Request { } // toCodexTools 把 OpenAI 工具定义转成 Responses 格式(name 等字段平铺)。 -func toCodexTools(tools []model.Tool) []codex.Tool { - out := make([]codex.Tool, 0, len(tools)) +func toCodexTools(tools []model.Tool) []Tool { + out := make([]Tool, 0, len(tools)) for _, tool := range tools { if tool.Function == nil || strings.TrimSpace(tool.Function.Name) == "" { continue } - out = append(out, codex.Tool{ + out = append(out, Tool{ Type: "function", Name: tool.Function.Name, Description: tool.Function.Description, @@ -375,23 +374,23 @@ func toCodexTools(tools []model.Tool) []codex.Tool { } // chatOnce 单账号一次请求(401 时刷新重试一次)。 -func (e *CodexEngine) chatOnce(ctx context.Context, acc *codexAccount, req *codex.Request, onDelta func(string) error, onTool func(codex.ToolCallDelta) error) (*codex.Result, error) { - attempt := func() (*codex.Result, error) { +func (e *Engine) chatOnce(ctx context.Context, acc *codexAccount, req *Request, onDelta func(string) error, onTool func(ToolCallDelta) error) (*Result, error) { + attempt := func() (*Result, error) { accessToken, accountID, err := e.token(acc) if err != nil { return nil, err } - return codex.StreamWithTools(ctx, acc.proxy(e.proxy), accessToken, accountID, req, onDelta, onTool) + return StreamWithTools(ctx, acc.proxy(e.proxy), accessToken, accountID, req, onDelta, onTool) } res, err := attempt() - if err != nil && codex.IsUnauthorized(err) { + if err != nil && IsUnauthorized(err) { // token 失效:强制刷新后重试一次 if refreshErr := e.ensureToken(acc); refreshErr != nil { return nil, fmt.Errorf("token 已失效且刷新失败: %w", refreshErr) } res, err = attempt() } - if err != nil && codex.IsRateLimited(err) { + if err != nil && IsRateLimited(err) { e.markCooldown(acc, "上游限流", 5*time.Minute) } if err == nil && res != nil && res.PrimaryUsedPercent > 0 { @@ -409,9 +408,9 @@ func (e *CodexEngine) chatOnce(ctx context.Context, acc *codexAccount, req *code } // chatLoop 轮询账号直到成功或池耗尽。 -func (e *CodexEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *Engine) chatLoop(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { if !e.Ready() { - return nil, engineUnavailableError(e) + return nil, engine.UnavailableError(e) } if sink == nil { sink = model.TextSink(nil) @@ -423,7 +422,7 @@ func (e *CodexEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink if acc == nil { break } - res, err := e.chatOnce(ctx, acc, cReq, sink.Text, func(delta codex.ToolCallDelta) error { + res, err := e.chatOnce(ctx, acc, cReq, sink.Text, func(delta ToolCallDelta) error { return sink.ToolCall(model.ToolCallDelta{ Index: delta.Index, ID: delta.ID, @@ -439,7 +438,7 @@ func (e *CodexEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink Text: res.Text, Engine: e.name, Model: req.Model, - Usage: subResultUsage(res.PromptTokens, res.CompletionTokens, res.ReasoningTokens), + Usage: engine.SubResultUsage(res.PromptTokens, res.CompletionTokens, res.ReasoningTokens), } for _, call := range res.ToolCalls { result.ToolCalls = append(result.ToolCalls, model.ToolCall{ @@ -464,7 +463,7 @@ func (e *CodexEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink return nil, err } // 401 刷新已失败 → 账号短暂冷却,换下一个 - if codex.IsUnauthorized(err) || strings.Contains(err.Error(), "刷新失败") { + if IsUnauthorized(err) || strings.Contains(err.Error(), "刷新失败") { e.markCooldown(acc, "凭据失效", 10*time.Minute) continue } @@ -476,7 +475,7 @@ func (e *CodexEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink return nil, errors.New("codex 没有可用账号") } -func (e *CodexEngine) usableCount() int { +func (e *Engine) usableCount() int { e.mu.RLock() defer e.mu.RUnlock() n := 0 @@ -494,28 +493,28 @@ func (e *CodexEngine) usableCount() int { } // Chat 非流式对话。 -func (e *CodexEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { return e.chatLoop(ctx, req, nil) } // ChatStream 流式对话。 -func (e *CodexEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { return e.chatLoop(ctx, req, model.TextSink(onDelta)) } // ChatStreamSink 流式对话(支持工具调用增量)。 -func (e *CodexEngine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *Engine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { return e.chatLoop(ctx, req, sink) } -func (e *CodexEngine) setErr(err error) { +func (e *Engine) setErr(err error) { e.errMu.Lock() e.lastErr = err e.errMu.Unlock() } // Status 引擎运行状态。 -func (e *CodexEngine) Status() map[string]any { +func (e *Engine) Status() map[string]any { accounts := e.AccountStates() ready := 0 for _, st := range accounts { @@ -540,18 +539,11 @@ func (e *CodexEngine) Status() map[string]any { } // Passthrough codex 不支持多模态端点透传。 -func (e *CodexEngine) Passthrough() bool { return false } +func (e *Engine) Passthrough() bool { return false } -func (e *CodexEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("codex 引擎不支持透传") } // Close 关闭引擎(账号无后台协程,无需清理)。 -func (e *CodexEngine) Close() {} - -func derefInt(p *int) int { - if p == nil { - return 0 - } - return *p -} +func (e *Engine) Close() {} diff --git a/internal/provider/engine_custom.go b/internal/engine/custom/engine.go similarity index 79% rename from internal/provider/engine_custom.go rename to internal/engine/custom/engine.go index c2d773a..f00f077 100644 --- a/internal/provider/engine_custom.go +++ b/internal/engine/custom/engine.go @@ -1,6 +1,6 @@ // engine_custom.go 第三方 Key 引擎: // 管理台填写 base_url + api_key,按 OpenAI 兼容协议转发 /v1/chat/completions。 -package provider +package custom import ( "context" @@ -17,12 +17,12 @@ import ( "web2api/internal/engine/upstream" "web2api/internal/model" "web2api/internal/store" - "web2api/internal/upstream/codex" - "web2api/internal/upstream/custombalance" + "web2api/internal/engine/codex" + "web2api/internal/engine/custombalance" ) -// CustomCredentials 第三方 Key 凭据(accounts.credentials JSON)。 -type CustomCredentials struct { +// Credentials 第三方 Key 凭据(accounts.credentials JSON)。 +type Credentials struct { BaseURL string `json:"base_url"` // OpenAI 兼容根地址,可带或不带 /v1 APIKey string `json:"api_key"` // 上游 API Key Models []string `json:"models,omitempty"` // 可选静态模型列表(上游无 /v1/models 时用) @@ -33,7 +33,7 @@ type CustomCredentials struct { type customAccount struct { id, label string mu sync.Mutex - creds CustomCredentials + creds Credentials enabled bool ready bool lastErr string @@ -82,8 +82,8 @@ func normalizeModelID(id string) string { return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(id), "models/")) } -// CustomEngine 第三方 OpenAI 兼容 Key 号池。 -type CustomEngine struct { +// Engine 第三方 OpenAI 兼容 Key 号池。 +type Engine struct { name string proxy string st *store.Store @@ -98,8 +98,8 @@ type CustomEngine struct { } // NewCustom 构建第三方 Key 引擎。 -func NewCustom(cfg config.CustomConfig, st *store.Store) *CustomEngine { - return &CustomEngine{ +func New(cfg config.CustomConfig, st *store.Store) *Engine { + return &Engine{ name: "custom", proxy: strings.TrimSpace(cfg.Proxy), st: st, @@ -107,7 +107,7 @@ func NewCustom(cfg config.CustomConfig, st *store.Store) *CustomEngine { } } -func (e *CustomEngine) buildUpstream(creds CustomCredentials) (*upstream.OpenAI, error) { +func (e *Engine) buildUpstream(creds Credentials) (*upstream.OpenAI, error) { baseURL := upstream.NormalizeBaseURL(creds.BaseURL) if baseURL == "" { return nil, errors.New("base_url 不能为空") @@ -149,7 +149,7 @@ func staticModels(ids []string, engineName string, available bool) []model.Model } // AddAccount 添加第三方供应商账号。 -func (e *CustomEngine) AddAccount(id, label string, creds CustomCredentials) error { +func (e *Engine) AddAccount(id, label string, creds Credentials) error { up, err := e.buildUpstream(creds) if err != nil { return err @@ -177,7 +177,7 @@ func (e *CustomEngine) AddAccount(id, label string, creds CustomCredentials) err return nil } -func (e *CustomEngine) refreshAccount(ctx context.Context, acc *customAccount) { +func (e *Engine) refreshAccount(ctx context.Context, acc *customAccount) { acc.mu.Lock() up := acc.up static := append([]string{}, acc.creds.Models...) @@ -222,7 +222,7 @@ func (e *CustomEngine) refreshAccount(ctx context.Context, acc *customAccount) { } // refreshBalance 查询余额(best-effort):先官方供应商,再 Sub2API /v1/usage → /user/balance → NewAPI。 -func (e *CustomEngine) refreshBalance(acc *customAccount) { +func (e *Engine) refreshBalance(acc *customAccount) { acc.mu.Lock() baseURL := acc.creds.BaseURL apiKey := acc.creds.APIKey @@ -261,7 +261,7 @@ func (e *CustomEngine) refreshBalance(acc *customAccount) { } // RemoveAccount 移除账号。 -func (e *CustomEngine) RemoveAccount(id string) error { +func (e *Engine) RemoveAccount(id string) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -279,7 +279,7 @@ func (e *CustomEngine) RemoveAccount(id string) error { } // SetEnabled 启停账号。 -func (e *CustomEngine) SetEnabled(id string, enabled bool) error { +func (e *Engine) SetEnabled(id string, enabled bool) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -293,7 +293,7 @@ func (e *CustomEngine) SetEnabled(id string, enabled bool) error { } // ReplaceCredentials 更新凭据(重建上游客户端)。 -func (e *CustomEngine) ReplaceCredentials(id string, creds CustomCredentials) error { +func (e *Engine) ReplaceCredentials(id string, creds Credentials) error { e.mu.RLock() idx, ok := e.ids[id] label := id @@ -311,17 +311,17 @@ func (e *CustomEngine) ReplaceCredentials(id string, creds CustomCredentials) er } // AccountStates 管理台账号状态。 -func (e *CustomEngine) AccountStates() []AccountState { +func (e *Engine) AccountStates() []engine.AccountState { e.mu.RLock() defer e.mu.RUnlock() - out := make([]AccountState, 0, len(e.accounts)) + out := make([]engine.AccountState, 0, len(e.accounts)) for _, acc := range e.accounts { acc.mu.Lock() tier := acc.provider if tier == "" { tier = upstream.NormalizeBaseURL(acc.creds.BaseURL) } - state := AccountState{ + state := engine.AccountState{ ID: acc.id, Label: acc.label, Enabled: acc.enabled, @@ -361,15 +361,15 @@ func (e *CustomEngine) AccountStates() []AccountState { // ---- Engine 接口 ---- -func (e *CustomEngine) Name() string { return e.name } +func (e *Engine) Name() string { return e.name } -func (e *CustomEngine) Describe() string { +func (e *Engine) Describe() string { return "第三方 OpenAI 兼容 Key(base_url + api_key,常见供应商余额查询)" } // Ready 是否至少有一个「能真正接单」的账号:启用、就绪、未冷却,且目录非空。 // 目录为空的账号已经不能承接任何模型(不再兜底),计入就绪只会误导管理台与 /health。 -func (e *CustomEngine) Ready() bool { +func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() for _, acc := range e.accounts { @@ -385,7 +385,7 @@ func (e *CustomEngine) Ready() bool { // ListModels 合并各供应商模型目录(去重)。 // available 的口径:该模型至少出现在一个可用账号(启用、就绪、未冷却)的目录里。 -func (e *CustomEngine) ListModels() []model.ModelInfo { +func (e *Engine) ListModels() []model.ModelInfo { e.mu.RLock() defer e.mu.RUnlock() seen := map[string]model.ModelInfo{} @@ -421,7 +421,7 @@ func (e *CustomEngine) ListModels() []model.ModelInfo { // 返回值:usable 为「声明了该模型且当前可用」的账号(按轮转起点排序); // declared 为声明了该模型的账号总数(不看可用态);total 为号池账号总数。 // 关键约束:没有声明该模型的账号一律不入选,目录为空的账号也不再兜底。 -func (e *CustomEngine) candidates(modelName string) (usable []*customAccount, declared int, total int) { +func (e *Engine) candidates(modelName string) (usable []*customAccount, declared int, total int) { e.mu.RLock() defer e.mu.RUnlock() n := len(e.accounts) @@ -450,7 +450,7 @@ func (e *CustomEngine) candidates(modelName string) (usable []*customAccount, de // pick 按模型精确选号:只返回目录里明确声明了该模型的可用账号。 // 这里刻意不再保留「找不到就随便抓一个就绪号」的兜底——那会把 lyria / veo // 这类模型打到完全不相干的第三方供应商上,最后以误导性的上游 404 收场。 -func (e *CustomEngine) pick(modelName string) (*customAccount, error) { +func (e *Engine) pick(modelName string) (*customAccount, error) { usable, declared, total := e.candidates(modelName) if len(usable) > 0 { return usable[0], nil @@ -467,17 +467,17 @@ func (e *CustomEngine) pick(modelName string) (*customAccount, error) { } // missError 生成「没有账号声明该模型」的明确错误,附带号池现状便于排查。 -func (e *CustomEngine) missError(modelName string) error { +func (e *Engine) missError(modelName string) error { want := normalizeModelID(modelName) if want == "" { - return newModelNotFound("", "请求未指定模型,custom 号池无法选号") + return engine.NewModelNotFound("", "请求未指定模型,custom 号池无法选号") } display := strings.TrimPrefix(strings.TrimSpace(modelName), "models/") e.mu.RLock() accounts := append([]*customAccount(nil), e.accounts...) e.mu.RUnlock() if len(accounts) == 0 { - return newModelNotFound(display, fmt.Sprintf("custom 号池没有账号,无法承接模型 %q", display)) + return engine.NewModelNotFound(display, fmt.Sprintf("custom 号池没有账号,无法承接模型 %q", display)) } const shown = 3 empty := 0 @@ -509,7 +509,7 @@ func (e *CustomEngine) missError(modelName string) error { if empty > 0 { detail += fmt.Sprintf(";其中 %d 个账号没有模型目录,未拉到目录的账号不再兜底接单,请在管理台获取模型列表或手填 models", empty) } - return newModelNotFound(display, detail) + return engine.NewModelNotFound(display, detail) } // isMediaMimeRejection 上游以「产出不了这种 MIME 类型」为由拒绝请求。 @@ -523,7 +523,7 @@ func isMediaMimeRejection(msg string) bool { return strings.Contains(msg, "audio/") || strings.Contains(msg, "video/") || strings.Contains(msg, "image/") } -func (e *CustomEngine) markCooldown(acc *customAccount, reason string, d time.Duration) { +func (e *Engine) markCooldown(acc *customAccount, reason string, d time.Duration) { acc.mu.Lock() acc.cooldownUntil = time.Now().Add(d) acc.cooldownReason = reason @@ -534,7 +534,7 @@ func (e *CustomEngine) markCooldown(acc *customAccount, reason string, d time.Du // ServeModelPassthrough 按模型把 OpenAI 兼容端点请求原样转发到拥有该模型的账号上游 // (/v1/embeddings、/v1/images/generations 等)。与 ServePassthrough 的区别:先解析 // 模型归属的账号再转发;解析失败(引擎不可用/无可用账号)时返回错误且未写出响应。 -func (e *CustomEngine) ServeModelPassthrough(ctx context.Context, modelID, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServeModelPassthrough(ctx context.Context, modelID, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { up, err := e.upstreamFor(modelID) if err != nil { return err @@ -543,7 +543,7 @@ func (e *CustomEngine) ServeModelPassthrough(ctx context.Context, modelID, metho } // ProbeImage 用账号上游探测图片模型(/v1/images/generations)。 -func (e *CustomEngine) ProbeImage(ctx context.Context, modelID string) (string, error) { +func (e *Engine) ProbeImage(ctx context.Context, modelID string) (string, error) { up, err := e.upstreamFor(modelID) if err != nil { return "", err @@ -552,7 +552,7 @@ func (e *CustomEngine) ProbeImage(ctx context.Context, modelID string) (string, } // ProbeEmbedding 用账号上游探测向量模型(/v1/embeddings)。 -func (e *CustomEngine) ProbeEmbedding(ctx context.Context, modelID string) (string, error) { +func (e *Engine) ProbeEmbedding(ctx context.Context, modelID string) (string, error) { up, err := e.upstreamFor(modelID) if err != nil { return "", err @@ -560,7 +560,7 @@ func (e *CustomEngine) ProbeEmbedding(ctx context.Context, modelID string) (stri return up.ProbeEmbedding(ctx, modelID) } -func (e *CustomEngine) upstreamFor(modelID string) (*upstream.OpenAI, error) { +func (e *Engine) upstreamFor(modelID string) (*upstream.OpenAI, error) { acc, err := e.pick(modelID) if err != nil { return nil, err @@ -574,7 +574,51 @@ func (e *CustomEngine) upstreamFor(modelID string) (*upstream.OpenAI, error) { return up, nil } -func (e *CustomEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +// stripChatTools 去掉 tools / tool_choice,并把 tool 历史折成普通文本。 +// custom 适配器专属的 400 降级路径:上游(MiniMax 等)拒收 Codex 自带工具 +// schema 时,按能力表 RetryNoToolsOn400 用纯文本重试一次。 +func stripChatTools(req model.ChatRequest) model.ChatRequest { + out := req + out.Tools = nil + out.ToolChoice = nil + out.ParallelToolCalls = nil + msgs := make([]model.ChatMessage, 0, len(req.Messages)) + for _, m := range req.Messages { + switch strings.ToLower(m.Role) { + case "tool": + text := m.Text() + if name := strings.TrimSpace(m.Name); name != "" { + text = "[" + name + "] " + text + } + msgs = append(msgs, model.ChatMessage{Role: "user", Content: text}) + case "assistant": + msg := m + msg.ToolCalls = nil + if msg.Content == nil { + msg.Content = "" + } + if strings.TrimSpace(msg.Text()) == "" && len(m.ToolCalls) > 0 { + continue + } + msgs = append(msgs, msg) + default: + msgs = append(msgs, m) + } + } + out.Messages = msgs + return out +} + +// isHTTPStatus 判断错误文案是否含「HTTP 」(上游 OpenAI 兼容客户端惯用格式)。 +func isHTTPStatus(err error, code int) bool { + if err == nil { + return false + } + needle := strings.ToLower(fmt.Sprintf("http %d", code)) + return strings.Contains(strings.ToLower(err.Error()), needle) +} + +func (e *Engine) chatLoop(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { // 严格按模型选号:只有目录里声明了该模型的账号才有资格接单。 // 没有任何账号声明 → 直接 model_not_found,绝不换号改投别的供应商。 usable, _, _ := e.candidates(req.Model) @@ -601,10 +645,19 @@ func (e *CustomEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink res *model.ChatResult err error ) - if sink != nil { - res, err = up.ChatStreamSink(ctx, req, sink) - } else { - res, err = up.Chat(ctx, req) + call := func(r model.ChatRequest) (*model.ChatResult, error) { + if sink != nil { + return up.ChatStreamSink(ctx, r, sink) + } + return up.Chat(ctx, r) + } + res, err = call(req) + // Codex / Responses 自带的复杂 tools 在 MiniMax 等上游常 400: + // 按能力表降级——去掉 tools 再试一次,保证纯文本对话可用。 + if err != nil && len(req.Tools) > 0 && engine.CapsFor(e.name, req.Model).RetryNoToolsOn400 && isHTTPStatus(err, 400) { + if retry, retryErr := call(stripChatTools(req)); retryErr == nil { + res, err = retry, nil + } } if err == nil { if res != nil { @@ -626,7 +679,7 @@ func (e *CustomEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink // 上游以「产出不了这种 MIME」为由拒绝:说明模型用途和端点不匹配 // (把音乐/语音模型发到了只能返回文本的 chat 端点),换成能看懂的提示。 if kind := model.ClassifyKind(req.Model, "", nil); model.ChatIncompatibleKind(kind) && isMediaMimeRejection(msg) { - return nil, newUnsupportedEndpoint(req.Model, kind) + return nil, engine.NewUnsupportedEndpoint(req.Model, kind) } // 模型不存在、参数非法等业务错误属于「这一次请求」的问题,不是账号健康问题: // 既不写进账号详情(避免错误详情挂到不相干的账号上),也不换号重试。 @@ -639,11 +692,11 @@ func (e *CustomEngine) chatLoop(ctx context.Context, req model.ChatRequest, sink return nil, errors.New("custom 没有可用账号") } -func (e *CustomEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { return e.chatLoop(ctx, req, nil) } -func (e *CustomEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { if onDelta == nil { return e.chatLoop(ctx, req, nil) } @@ -651,11 +704,11 @@ func (e *CustomEngine) ChatStream(ctx context.Context, req model.ChatRequest, on } // ChatStreamSink 流式对话(支持工具调用增量)。 -func (e *CustomEngine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { +func (e *Engine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { return e.chatLoop(ctx, req, sink) } -func (e *CustomEngine) Status() map[string]any { +func (e *Engine) Status() map[string]any { accounts := e.AccountStates() ready := 0 for _, st := range accounts { @@ -679,16 +732,16 @@ func (e *CustomEngine) Status() map[string]any { return out } -func (e *CustomEngine) Passthrough() bool { return false } +func (e *Engine) Passthrough() bool { return false } -func (e *CustomEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("custom 引擎不支持透传") } -func (e *CustomEngine) Close() {} +func (e *Engine) Close() {} // ProbeCustomModels 管理台「获取模型列表」:用临时凭据探测上游 /v1/models。 -func ProbeCustomModels(ctx context.Context, engineProxy string, creds CustomCredentials) ([]string, error) { +func ProbeCustomModels(ctx context.Context, engineProxy string, creds Credentials) ([]string, error) { baseURL := upstream.NormalizeBaseURL(creds.BaseURL) if baseURL == "" { return nil, errors.New("base_url 不能为空") diff --git a/internal/engine/custom/engine_test.go b/internal/engine/custom/engine_test.go new file mode 100644 index 0000000..8332d6b --- /dev/null +++ b/internal/engine/custom/engine_test.go @@ -0,0 +1,41 @@ +package custom + +import ( + "encoding/json" + "errors" + "testing" + + "web2api/internal/model" +) + +func TestStripChatToolsFlattensHistory(t *testing.T) { + req := model.ChatRequest{ + Model: "MiniMax-M2", + Tools: []model.Tool{{Type: "function", Function: &model.ToolFunc{Name: "exec_command"}}}, + ToolChoice: json.RawMessage(`"auto"`), + Messages: []model.ChatMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: nil, ToolCalls: []model.ToolCall{{ID: "c1", Type: "function", Function: model.ToolFunction{Name: "exec_command", Arguments: "{}"}}}}, + {Role: "tool", Name: "exec_command", ToolCallID: "c1", Content: "ok"}, + }, + } + out := stripChatTools(req) + if len(out.Tools) != 0 || len(out.ToolChoice) != 0 { + t.Fatalf("tools should be cleared: tools=%d choice=%s", len(out.Tools), out.ToolChoice) + } + if len(out.Messages) != 2 { + t.Fatalf("tool-only assistant should be dropped, tool folded to user: %#v", out.Messages) + } + if out.Messages[1].Role != "user" || out.Messages[1].Content != "[exec_command] ok" { + t.Fatalf("tool fold=%#v", out.Messages[1]) + } +} + +func TestIsHTTPStatus(t *testing.T) { + if !isHTTPStatus(errors.New("上游返回 HTTP 400: bad tools"), 400) { + t.Fatal("want true") + } + if isHTTPStatus(errors.New("上游返回 HTTP 429"), 400) { + t.Fatal("429 is not 400") + } +} diff --git a/internal/upstream/custombalance/balance.go b/internal/engine/custombalance/balance.go similarity index 100% rename from internal/upstream/custombalance/balance.go rename to internal/engine/custombalance/balance.go diff --git a/internal/engine/errors.go b/internal/engine/errors.go new file mode 100644 index 0000000..ba2faa4 --- /dev/null +++ b/internal/engine/errors.go @@ -0,0 +1,214 @@ +// errors.go 路由层与引擎共用的语义化错误:区分「没人声明这个模型」与 +// 「引擎挂了」。放 engine 基础包,让引擎适配器在构造错误时不依赖 provider。 +package engine + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" +) + +// ModelNotFoundError 表示没有任何引擎/账号声明过该模型。 +// 网关据此返回 404 + code=model_not_found,而不是把「路由不到」包装成 +// 上游 502——后者会让下游误以为是临时故障而反复重试。 +type ModelNotFoundError struct { + Model string // 请求的模型名(已去掉 models/ 前缀) + Detail string // 面向人的补充说明:查过哪些号池、为什么没命中 +} + +func (e *ModelNotFoundError) Error() string { + detail := strings.TrimSpace(e.Detail) + if detail == "" { + detail = fmt.Sprintf("当前号池没有引擎或账号声明模型 %q", e.Model) + } + return "model_not_found: " + detail +} + +// NewModelNotFound 构造模型未声明错误。 +func NewModelNotFound(model, detail string) error { + return &ModelNotFoundError{Model: strings.TrimPrefix(strings.TrimSpace(model), "models/"), Detail: detail} +} + +// IsModelNotFound 判定错误是否为「无人声明该模型」。 +func IsModelNotFound(err error) bool { + var target *ModelNotFoundError + return errors.As(err, &target) +} + +// UnsupportedEndpointError 模型存在,但当前端点产不出这种类型的输出。 +// 例如把音乐模型发到 /v1/chat/completions:chat 只能返回文本,上游必然拒绝。 +// 网关据此返回 400,并告诉调用方该换哪个端点。 +type UnsupportedEndpointError struct { + Model string + Kind string + Detail string +} + +func (e *UnsupportedEndpointError) Error() string { + detail := strings.TrimSpace(e.Detail) + if detail == "" { + detail = fmt.Sprintf("模型 %q 是%s模型,当前端点无法返回该类型的输出", e.Model, KindLabel(e.Kind)) + } + return "unsupported_endpoint: " + detail +} + +// IsUnsupportedEndpoint 判定错误是否为「端点与模型用途不匹配」。 +func IsUnsupportedEndpoint(err error) bool { + var target *UnsupportedEndpointError + return errors.As(err, &target) +} + +// NewUnsupportedEndpoint 构造「该换端点」的错误,文案里直接给出正确入口。 +func NewUnsupportedEndpoint(model, kind string) error { + name := strings.TrimPrefix(strings.TrimSpace(model), "models/") + return &UnsupportedEndpointError{ + Model: name, + Kind: kind, + Detail: fmt.Sprintf("模型 %q 是%s模型,/v1/chat/completions 只能返回文本,上游会直接拒绝(如 Unhandled generated data mime type: audio/mpeg)。请改用 %s;或启用引擎B 原生模式(它可以在 chat 响应里直接带回媒体)", + name, KindLabel(kind), EndpointForKind(kind)), + } +} + +// EndpointForKind 该用途应使用的端点。 +func EndpointForKind(kind string) string { + switch kind { + case "music": + return "POST /v1/music/generations" + case "video": + return "POST /v1/videos" + case "embedding": + return "POST /v1/embeddings" + case "image": + return "POST /v1/images/generations" + case "speech": + return "POST /v1/audio/speech" + default: + return "POST /v1/chat/completions" + } +} + +// KindLabel 模型用途的中文标签(错误文案用)。 +func KindLabel(kind string) string { + switch kind { + case "video": + return "视频" + case "music": + return "音乐" + case "speech": + return "语音" + case "image": + return "生图" + case "embedding": + return "向量" + default: + return "对话" + } +} + +// UnavailableError 保留引擎最近一次初始化/健康检查错误,避免线上只看到 +// 「引擎不可用」而无法判断是账号、代理、网络还是上游协议问题。 +func UnavailableError(eng Engine) error { + msg := fmt.Sprintf("引擎 %s 不可用", eng.Name()) + if status := eng.Status(); status != nil { + switch { + case statusString(status["error"]) != "": + msg += ": " + statusString(status["error"]) + case statusString(status["worker_error"]) != "": + // 账号 ready 但 WAA Worker 起不来:这层信息最容易被漏掉,单独透出。 + msg += ": WAA Worker 未就绪(worker_ready=" + statusInt(status["worker_ready"]) + "): " + statusString(status["worker_error"]) + case status["worker_ready"] != nil && statusInt(status["worker_ready"]) == "0": + msg += ": WAA Worker 未就绪(worker_ready=0),账号在池但没有任何可用的工人" + case emptyStatusList(status["accounts"]): + msg += ": 没有可用账号(请在管理台添加账号,或检查 auth_states 配置)" + case firstAccountError(status["accounts"]) != "": + // 账号级错误(凭据失效 / 冷却中等) + msg += ": " + firstAccountError(status["accounts"]) + } + } + return errors.New(msg) +} + +func statusString(v any) string { + s, _ := v.(string) + return strings.TrimSpace(s) +} + +// statusInt 安全读取状态里的整数字段(数字或字符串形式)。 +func statusInt(v any) string { + switch n := v.(type) { + case int: + return strconv.Itoa(n) + case int64: + return strconv.FormatInt(n, 10) + case float64: + return strconv.FormatInt(int64(n), 10) + case string: + return strings.TrimSpace(n) + default: + return "0" + } +} + +// firstAccountError 从账号状态列表中提取第一条错误描述。列表既可能是 +// []map[string]any,也可能是引擎自定义的账号状态切片(字段 Status/Detail/Label), +// 统一用反射读,避免 engine 包反向依赖具体引擎类型。 +func firstAccountError(v any) string { + if list, ok := v.([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + status, _ := m["status"].(string) + detail, _ := m["detail"].(string) + label, _ := m["label"].(string) + if status == "error" && strings.TrimSpace(detail) != "" { + if label != "" { + return label + ": " + strings.TrimSpace(detail) + } + return strings.TrimSpace(detail) + } + } + } + return "" + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice { + return "" + } + for i := 0; i < rv.Len(); i++ { + ev := rv.Index(i) + if ev.Kind() == reflect.Ptr { + ev = ev.Elem() + } + if ev.Kind() != reflect.Struct { + continue + } + status, detail, label := ev.FieldByName("Status"), ev.FieldByName("Detail"), ev.FieldByName("Label") + if !status.IsValid() || !detail.IsValid() { + continue + } + if status.Kind() == reflect.String && status.String() == "error" && strings.TrimSpace(detail.String()) != "" { + if label.IsValid() && label.Kind() == reflect.String && strings.TrimSpace(label.String()) != "" { + return strings.TrimSpace(label.String()) + ": " + strings.TrimSpace(detail.String()) + } + return strings.TrimSpace(detail.String()) + } + } + return "" +} + +func emptyStatusList(v any) bool { + switch list := v.(type) { + case []map[string]any: + return len(list) == 0 + case []any: + return len(list) == 0 + default: + // 订阅引擎的 []engine.AccountState 等具体切片类型 + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Slice { + return rv.Len() == 0 + } + return false + } +} diff --git a/internal/engine/geminiweb/credentials.go b/internal/engine/geminiweb/credentials.go new file mode 100644 index 0000000..1bfec45 --- /dev/null +++ b/internal/engine/geminiweb/credentials.go @@ -0,0 +1,9 @@ +package geminiweb + + + +// Credentials 引擎A(gemini.google.com 网页版)账号凭据。 +type Credentials struct { + PSID string `json:"psid"` + PSIDTS string `json:"psidts"` +} diff --git a/internal/provider/engine_a.go b/internal/engine/geminiweb/engine.go similarity index 73% rename from internal/provider/engine_a.go rename to internal/engine/geminiweb/engine.go index b955d40..3e15658 100644 --- a/internal/provider/engine_a.go +++ b/internal/engine/geminiweb/engine.go @@ -1,4 +1,4 @@ -package provider +package geminiweb import ( "context" @@ -13,7 +13,6 @@ import ( "web2api/internal/config" "web2api/internal/engine" - "web2api/internal/engine/geminiweb" "web2api/internal/model" "web2api/internal/store" ) @@ -22,13 +21,13 @@ import ( type geminiAccount struct { id string label string - client *geminiweb.Client + client *Client enabled bool cancel context.CancelFunc } -// GeminiWebEngine 引擎A:gemini.google.com 网页版原生逆向账号池(支持运行时增删启停)。 -type GeminiWebEngine struct { +// Engine 引擎A:gemini.google.com 网页版原生逆向账号池(支持运行时增删启停)。 +type Engine struct { name string proxy string refresh time.Duration @@ -45,7 +44,7 @@ type GeminiWebEngine struct { } // NewGeminiWeb 构建引擎A(账号通过 AddAccount 动态注入)。 -func NewGeminiWeb(cfg config.EngineAConfig) (*GeminiWebEngine, error) { +func New(cfg config.EngineAConfig) (*Engine, error) { // 允许测试、嵌入式调用和手写配置省略这些值;避免零值 duration // 让后台初始化立即超时或自动续期循环失效。 if cfg.RefreshSeconds <= 0 { @@ -54,8 +53,8 @@ func NewGeminiWeb(cfg config.EngineAConfig) (*GeminiWebEngine, error) { if cfg.TimeoutSeconds <= 0 { cfg.TimeoutSeconds = 300 } - return &GeminiWebEngine{ - name: "a", + return &Engine{ + name: "geminiweb", proxy: cfg.Proxy, refresh: time.Duration(cfg.RefreshSeconds) * time.Second, timeout: time.Duration(cfg.TimeoutSeconds) * time.Second, @@ -63,7 +62,7 @@ func NewGeminiWeb(cfg config.EngineAConfig) (*GeminiWebEngine, error) { }, nil } -func (e *GeminiWebEngine) SetStore(st *store.Store) { e.store = st } +func (e *Engine) SetStore(st *store.Store) { e.store = st } type accountCookies struct { store *store.Store @@ -83,7 +82,7 @@ func (a accountCookies) DeleteCookies() error { // AddAccount 添加账号并异步初始化(Cookie 校验在后台完成)。 // id 为外部(数据库)主键;label 仅用于展示。 -func (e *GeminiWebEngine) AddAccount(id, label, psid, psidts string) error { +func (e *Engine) AddAccount(id, label, psid, psidts string) error { if strings.TrimSpace(psid) == "" { return errors.New("__Secure-1PSID 不能为空") } @@ -92,7 +91,7 @@ func (e *GeminiWebEngine) AddAccount(id, label, psid, psidts string) error { e.mu.Unlock() return fmt.Errorf("账号 %s 已在池中", id) } - client := geminiweb.NewClient(label, psid, psidts, e.proxy, e.timeout) + client := NewClient(label, psid, psidts, e.proxy, e.timeout) if e.store != nil { accountID, err := strconv.ParseInt(id, 10, 64) if err != nil { @@ -120,7 +119,7 @@ func (e *GeminiWebEngine) AddAccount(id, label, psid, psidts string) error { } // RemoveAccount 移除账号。 -func (e *GeminiWebEngine) RemoveAccount(id string) error { +func (e *Engine) RemoveAccount(id string) error { e.mu.Lock() idx, ok := e.ids[id] if !ok { @@ -141,7 +140,7 @@ func (e *GeminiWebEngine) RemoveAccount(id string) error { return nil } -func (e *GeminiWebEngine) Close() { +func (e *Engine) Close() { e.mu.Lock() defer e.mu.Unlock() for _, account := range e.accounts { @@ -151,7 +150,7 @@ func (e *GeminiWebEngine) Close() { } // SetEnabled 启停账号。 -func (e *GeminiWebEngine) SetEnabled(id string, enabled bool) error { +func (e *Engine) SetEnabled(id string, enabled bool) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -163,7 +162,7 @@ func (e *GeminiWebEngine) SetEnabled(id string, enabled bool) error { } // ReplaceCredentials 更新凭据(重建客户端)。 -func (e *GeminiWebEngine) ReplaceCredentials(id, psid, psidts string) error { +func (e *Engine) ReplaceCredentials(id, psid, psidts string) error { e.mu.RLock() label := id if idx, ok := e.ids[id]; ok { @@ -176,34 +175,13 @@ func (e *GeminiWebEngine) ReplaceCredentials(id, psid, psidts string) error { return e.AddAccount(id, label, psid, psidts) } -// AccountCooldown 账号仍在生效的额度冷却。Until 为 Unix 秒。 -type AccountCooldown struct { - Scope string `json:"scope"` - Until int64 `json:"until"` - Reason string `json:"reason,omitempty"` -} - -// AccountState 账号实时状态(供管理台)。 -type AccountState struct { - ID string `json:"id"` - Label string `json:"label"` - Enabled bool `json:"enabled"` - Ready bool `json:"ready"` - Status string `json:"status"` - Detail string `json:"detail,omitempty"` - Models int `json:"models"` - Tier string `json:"tier,omitempty"` - Quota string `json:"quota,omitempty"` // 用量/余额描述(订阅引擎) - Cooldowns []AccountCooldown `json:"cooldowns,omitempty"` -} - // AccountStates 全部账号状态。 -func (e *GeminiWebEngine) AccountStates() []AccountState { +func (e *Engine) AccountStates() []engine.AccountState { e.mu.RLock() defer e.mu.RUnlock() - out := make([]AccountState, 0, len(e.accounts)) + out := make([]engine.AccountState, 0, len(e.accounts)) for _, acc := range e.accounts { - state := AccountState{ + state := engine.AccountState{ ID: acc.id, Label: acc.label, Enabled: acc.enabled, @@ -216,13 +194,13 @@ func (e *GeminiWebEngine) AccountStates() []AccountState { state.Detail = "等待 Cookie 初始化" } else { switch acc.client.AccountStatus() { - case geminiweb.StatusAvailable: + case StatusAvailable: state.Status = "ok" state.Ready = true - case geminiweb.StatusUnauthenticated: + case StatusUnauthenticated: state.Status = "error" state.Detail = "登录态失效,请重新提取 Cookie" - case geminiweb.StatusTempUnavailable: + case StatusTempUnavailable: state.Status = "error" state.Detail = "账号临时不可用" default: @@ -236,7 +214,7 @@ func (e *GeminiWebEngine) AccountStates() []AccountState { } // EnabledLabels 兼容旧接口:启用的账号名列表。 -func (e *GeminiWebEngine) EnabledLabels() []string { +func (e *Engine) EnabledLabels() []string { e.mu.RLock() defer e.mu.RUnlock() out := make([]string, 0, len(e.accounts)) @@ -248,32 +226,32 @@ func (e *GeminiWebEngine) EnabledLabels() []string { return out } -func (e *GeminiWebEngine) setErr(err error) { +func (e *Engine) setErr(err error) { e.errMu.Lock() e.lastErr = err e.errMu.Unlock() } -func (e *GeminiWebEngine) getErr() error { +func (e *Engine) getErr() error { e.errMu.Lock() defer e.errMu.Unlock() return e.lastErr } // Name 引擎标识。 -func (e *GeminiWebEngine) Name() string { return e.name } +func (e *Engine) Name() string { return e.name } // Describe 引擎说明。 -func (e *GeminiWebEngine) Describe() string { +func (e *Engine) Describe() string { return "gemini.google.com 网页版(原生 Go 逆向,Cookie 会话)" } // Ready 至少一个账号可用。 -func (e *GeminiWebEngine) Ready() bool { +func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() for _, acc := range e.accounts { - if acc.enabled && acc.client.Ready() && acc.client.AccountStatus() == geminiweb.StatusAvailable { + if acc.enabled && acc.client.Ready() && acc.client.AccountStatus() == StatusAvailable { return true } } @@ -281,7 +259,7 @@ func (e *GeminiWebEngine) Ready() bool { } // ListModels 聚合全部账号的模型(去重)。 -func (e *GeminiWebEngine) ListModels() []model.ModelInfo { +func (e *Engine) ListModels() []model.ModelInfo { e.mu.RLock() accounts := append([]*geminiAccount(nil), e.accounts...) e.mu.RUnlock() @@ -302,7 +280,7 @@ func (e *GeminiWebEngine) ListModels() []model.ModelInfo { Object: "model", DisplayName: m.DisplayName, Available: m.Available, - Engine: "a", + Engine: "geminiweb", Kind: model.ClassifyKind(m.Name, m.DisplayName, nil), }) } @@ -311,7 +289,7 @@ func (e *GeminiWebEngine) ListModels() []model.ModelInfo { } // pick 轮询选择可用账号;返回 nil 表示无可用。 -func (e *GeminiWebEngine) pick() *geminiAccount { +func (e *Engine) pick() *geminiAccount { e.mu.RLock() defer e.mu.RUnlock() n := len(e.accounts) @@ -321,7 +299,7 @@ func (e *GeminiWebEngine) pick() *geminiAccount { start := int(e.next.Add(1)) for i := 0; i < n; i++ { acc := e.accounts[(start+i)%n] - if acc.enabled && acc.client.Ready() && acc.client.AccountStatus() == geminiweb.StatusAvailable { + if acc.enabled && acc.client.Ready() && acc.client.AccountStatus() == StatusAvailable { return acc } } @@ -329,12 +307,12 @@ func (e *GeminiWebEngine) pick() *geminiAccount { } // usableCount 可用账号数。 -func (e *GeminiWebEngine) usableCount() int { +func (e *Engine) usableCount() int { e.mu.RLock() defer e.mu.RUnlock() count := 0 for _, acc := range e.accounts { - if acc.enabled && acc.client.Ready() && acc.client.AccountStatus() == geminiweb.StatusAvailable { + if acc.enabled && acc.client.Ready() && acc.client.AccountStatus() == StatusAvailable { count++ } } @@ -364,7 +342,7 @@ func messagesToPrompt(messages []model.ChatMessage) string { } // Chat 非流式对话(可用账号间故障切换)。 -func (e *GeminiWebEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { prompt := messagesToPrompt(req.Messages) attempts := e.usableCount() var lastErr error @@ -378,7 +356,7 @@ func (e *GeminiWebEngine) Chat(ctx context.Context, req model.ChatRequest) (*mod } text, err := acc.client.Generate(ctx, prompt, req.Model) if err == nil { - return &model.ChatResult{Text: text, Engine: "a", Model: req.Model}, nil + return &model.ChatResult{Text: text, Engine: "geminiweb", Model: req.Model}, nil } lastErr = err } @@ -386,7 +364,7 @@ func (e *GeminiWebEngine) Chat(ctx context.Context, req model.ChatRequest) (*mod } // ChatStream 流式对话(可用账号间故障切换)。 -func (e *GeminiWebEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { prompt := messagesToPrompt(req.Messages) attempts := e.usableCount() var lastErr error @@ -400,7 +378,7 @@ func (e *GeminiWebEngine) ChatStream(ctx context.Context, req model.ChatRequest, } text, err := acc.client.GenerateStream(ctx, prompt, req.Model, onDelta) if err == nil { - return &model.ChatResult{Text: text, Engine: "a", Model: req.Model}, nil + return &model.ChatResult{Text: text, Engine: "geminiweb", Model: req.Model}, nil } lastErr = err } @@ -408,15 +386,15 @@ func (e *GeminiWebEngine) ChatStream(ctx context.Context, req model.ChatRequest, } // Passthrough 引擎A 不支持多模态透传。 -func (e *GeminiWebEngine) Passthrough() bool { return false } +func (e *Engine) Passthrough() bool { return false } // ServePassthrough 不适用。 -func (e *GeminiWebEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("引擎A 不支持多模态透传") } // Status 引擎状态(旧接口保留)。 -func (e *GeminiWebEngine) Status() map[string]any { +func (e *Engine) Status() map[string]any { states := e.AccountStates() accounts := make([]map[string]any, 0, len(states)) for _, st := range states { @@ -434,7 +412,7 @@ func (e *GeminiWebEngine) Status() map[string]any { errMsg = err.Error() } return map[string]any{ - "engine": "a", + "engine": "geminiweb", "ready": e.Ready(), "accounts": accounts, "error": errMsg, @@ -443,11 +421,11 @@ func (e *GeminiWebEngine) Status() map[string]any { func statusText(code int) string { switch code { - case geminiweb.StatusAvailable: + case StatusAvailable: return "available" - case geminiweb.StatusUnauthenticated: + case StatusUnauthenticated: return "unauthenticated" - case geminiweb.StatusTempUnavailable: + case StatusTempUnavailable: return "temporarily_unavailable" default: return fmt.Sprintf("status_%d", code) diff --git a/internal/provider/engine_kiro.go b/internal/engine/kiro/engine.go similarity index 75% rename from internal/provider/engine_kiro.go rename to internal/engine/kiro/engine.go index f98415a..7695998 100644 --- a/internal/provider/engine_kiro.go +++ b/internal/engine/kiro/engine.go @@ -1,6 +1,6 @@ -// engine_kiro.go 引擎 kiro:AWS Kiro(CodeWhisperer 订阅号池)。 +// engine_go 引擎 kiro:AWS Kiro(CodeWhisperer 订阅号池)。 // 凭据为 Kiro IDE 的 refresh_token(旋转式,刷新后自动回写数据库)。 -package provider +package kiro import ( "context" @@ -17,11 +17,10 @@ import ( "web2api/internal/engine" "web2api/internal/model" "web2api/internal/store" - "web2api/internal/upstream/kiro" ) -// KiroCredentials kiro 引擎凭据(accounts.credentials JSON)。 -type KiroCredentials struct { +// Credentials kiro 引擎凭据(accounts.credentials JSON)。 +type Credentials struct { RefreshToken string `json:"refresh_token"` Email string `json:"email,omitempty"` Proxy string `json:"proxy,omitempty"` @@ -31,7 +30,7 @@ type KiroCredentials struct { type kiroAccount struct { id, label string mu sync.Mutex - creds KiroCredentials + creds Credentials enabled bool accessToken string @@ -54,8 +53,8 @@ func (a *kiroAccount) proxy(engineProxy string) string { return engineProxy } -// KiroEngine kiro 引擎。 -type KiroEngine struct { +// Engine kiro 引擎。 +type Engine struct { name string proxy string st *store.Store @@ -70,8 +69,8 @@ type KiroEngine struct { } // NewKiro 构建 kiro 引擎。 -func NewKiro(cfg config.KiroConfig, st *store.Store) *KiroEngine { - return &KiroEngine{ +func New(cfg config.KiroConfig, st *store.Store) *Engine { + return &Engine{ name: "kiro", proxy: strings.TrimSpace(cfg.Proxy), st: st, @@ -80,7 +79,7 @@ func NewKiro(cfg config.KiroConfig, st *store.Store) *KiroEngine { } // AddAccount 添加账号并异步首刷 token。 -func (e *KiroEngine) AddAccount(id, label string, creds KiroCredentials) error { +func (e *Engine) AddAccount(id, label string, creds Credentials) error { if strings.TrimSpace(creds.RefreshToken) == "" { return errors.New("refresh_token 不能为空") } @@ -98,7 +97,7 @@ func (e *KiroEngine) AddAccount(id, label string, creds KiroCredentials) error { } // RemoveAccount 移除账号。 -func (e *KiroEngine) RemoveAccount(id string) error { +func (e *Engine) RemoveAccount(id string) error { e.mu.Lock() idx, ok := e.ids[id] if !ok { @@ -117,7 +116,7 @@ func (e *KiroEngine) RemoveAccount(id string) error { } // SetEnabled 启停账号。 -func (e *KiroEngine) SetEnabled(id string, enabled bool) error { +func (e *Engine) SetEnabled(id string, enabled bool) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -131,7 +130,7 @@ func (e *KiroEngine) SetEnabled(id string, enabled bool) error { } // ReplaceCredentials 更新凭据(重建账号)。 -func (e *KiroEngine) ReplaceCredentials(id string, creds KiroCredentials) error { +func (e *Engine) ReplaceCredentials(id string, creds Credentials) error { e.mu.RLock() idx, ok := e.ids[id] label := id @@ -149,20 +148,20 @@ func (e *KiroEngine) ReplaceCredentials(id string, creds KiroCredentials) error } // AccountStates 全部账号状态(管理台)。 -func (e *KiroEngine) AccountStates() []AccountState { +func (e *Engine) AccountStates() []engine.AccountState { e.mu.RLock() defer e.mu.RUnlock() - out := make([]AccountState, 0, len(e.accounts)) + out := make([]engine.AccountState, 0, len(e.accounts)) for _, acc := range e.accounts { acc.mu.Lock() - state := AccountState{ + state := engine.AccountState{ ID: acc.id, Label: acc.label, Enabled: acc.enabled, Detail: acc.lastErr, Tier: acc.creds.Email, Quota: acc.quota, - Models: len(kiro.DefaultModels), + Models: len(DefaultModels), } if acc.email != "" { state.Tier = acc.email @@ -192,7 +191,7 @@ func (e *KiroEngine) AccountStates() []AccountState { } // ensureToken 刷新账号 token(Kiro refresh_token 旋转式,必须回写)。 -func (e *KiroEngine) ensureToken(acc *kiroAccount) error { +func (e *Engine) ensureToken(acc *kiroAccount) error { acc.mu.Lock() refreshToken := acc.refreshToken proxy := acc.proxy(e.proxy) @@ -200,7 +199,7 @@ func (e *KiroEngine) ensureToken(acc *kiroAccount) error { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - tok, err := kiro.Refresh(ctx, proxy, refreshToken) + tok, err := Refresh(ctx, proxy, refreshToken) acc.mu.Lock() if err != nil { acc.ready = false @@ -218,7 +217,7 @@ func (e *KiroEngine) ensureToken(acc *kiroAccount) error { if e.st != nil && tok.RefreshToken != acc.creds.RefreshToken { acc.creds.RefreshToken = tok.RefreshToken if dbID, perr := strconv.ParseInt(acc.id, 10, 64); perr == nil { - _ = e.st.UpdateAccountRawCredentials(dbID, marshalCompact(acc.creds)) + _ = e.st.UpdateAccountRawCredentials(dbID, engine.MarshalCompact(acc.creds)) } } accessToken := acc.accessToken @@ -230,10 +229,10 @@ func (e *KiroEngine) ensureToken(acc *kiroAccount) error { } // refreshUsage 拉取账号用量与订阅信息(CREDIT 剩余额度)。 -func (e *KiroEngine) refreshUsage(acc *kiroAccount, proxy, accessToken string) { +func (e *Engine) refreshUsage(acc *kiroAccount, proxy, accessToken string) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - limits, err := kiro.GetUsageLimits(ctx, proxy, accessToken) + limits, err := GetUsageLimits(ctx, proxy, accessToken) if err != nil { return // best-effort } @@ -263,7 +262,7 @@ func (e *KiroEngine) refreshUsage(acc *kiroAccount, proxy, accessToken string) { } // token 返回有效 access_token(提前 5 分钟刷新)。 -func (e *KiroEngine) token(acc *kiroAccount) (string, error) { +func (e *Engine) token(acc *kiroAccount) (string, error) { acc.mu.Lock() needRefresh := acc.accessToken == "" || (acc.expiresAt.After(time.Time{}) && time.Until(acc.expiresAt) < 5*time.Minute) tok := acc.accessToken @@ -284,14 +283,14 @@ func (e *KiroEngine) token(acc *kiroAccount) (string, error) { // ---- Engine 接口 ---- -func (e *KiroEngine) Name() string { return e.name } +func (e *Engine) Name() string { return e.name } -func (e *KiroEngine) Describe() string { +func (e *Engine) Describe() string { return "AWS Kiro(CodeWhisperer 订阅号池,Claude 模型)" } // Ready 至少一个账号可用。 -func (e *KiroEngine) Ready() bool { +func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() for _, acc := range e.accounts { @@ -306,7 +305,7 @@ func (e *KiroEngine) Ready() bool { } // ListModels kiro 模型目录。 -func (e *KiroEngine) ListModels() []model.ModelInfo { +func (e *Engine) ListModels() []model.ModelInfo { e.mu.RLock() enabled := 0 for _, acc := range e.accounts { @@ -317,8 +316,8 @@ func (e *KiroEngine) ListModels() []model.ModelInfo { acc.mu.Unlock() } e.mu.RUnlock() - out := make([]model.ModelInfo, 0, len(kiro.DefaultModels)) - for _, id := range kiro.DefaultModels { + out := make([]model.ModelInfo, 0, len(DefaultModels)) + for _, id := range DefaultModels { out = append(out, model.ModelInfo{ ID: id, Object: "model", @@ -331,7 +330,7 @@ func (e *KiroEngine) ListModels() []model.ModelInfo { } // pick 轮询选择可用账号。 -func (e *KiroEngine) pick() *kiroAccount { +func (e *Engine) pick() *kiroAccount { e.mu.RLock() defer e.mu.RUnlock() n := len(e.accounts) @@ -351,53 +350,53 @@ func (e *KiroEngine) pick() *kiroAccount { return nil } -func (e *KiroEngine) markCooldown(acc *kiroAccount, reason string, d time.Duration) { +func (e *Engine) markCooldown(acc *kiroAccount, reason string, d time.Duration) { acc.mu.Lock() acc.cooldownUntil = time.Now().Add(d) acc.cooldownReason = reason acc.mu.Unlock() } -// toKiroRequest ChatRequest → kiro.Request。 -func toKiroRequest(req model.ChatRequest) (*kiro.Request, error) { - out := &kiro.Request{Model: req.Model} +// toKiroRequest ChatRequest → Request。 +func toKiroRequest(req model.ChatRequest) (*Request, error) { + out := &Request{Model: req.Model} for _, m := range req.Messages { - out.Messages = append(out.Messages, kiro.Message{Role: m.PlainRole(), Text: m.Text()}) + out.Messages = append(out.Messages, Message{Role: m.PlainRole(), Text: m.Text()}) } return out, nil } // chatOnce 单账号一次请求(401/403 刷新重试一次)。 -func (e *KiroEngine) chatOnce(ctx context.Context, acc *kiroAccount, req *kiro.Request, onDelta func(string) error) (*kiro.Result, error) { - attempt := func() (*kiro.Result, error) { +func (e *Engine) chatOnce(ctx context.Context, acc *kiroAccount, req *Request, onDelta func(string) error) (*Result, error) { + attempt := func() (*Result, error) { accessToken, err := e.token(acc) if err != nil { return nil, err } - return kiro.Stream(ctx, acc.proxy(e.proxy), accessToken, req, onDelta) + return Stream(ctx, acc.proxy(e.proxy), accessToken, req, onDelta) } res, err := attempt() - if err != nil && kiro.IsSuspended(err) { + if err != nil && IsSuspended(err) { // 账号被上游封停(安全锁定):刷新 token 无效,长冷却并透出恢复方式 - e.markCooldown(acc, "账号被上游封停(安全锁定),需到 app.kiro.dev/account/usage 验证身份恢复", time.Hour) + e.markCooldown(acc, "账号被上游封停(安全锁定),需到 app.dev/account/usage 验证身份恢复", time.Hour) return nil, err } - if err != nil && kiro.IsUnauthorized(err) { + if err != nil && IsUnauthorized(err) { if refreshErr := e.ensureToken(acc); refreshErr != nil { return nil, fmt.Errorf("token 已失效且刷新失败: %w", refreshErr) } res, err = attempt() } - if err != nil && kiro.IsRateLimited(err) { + if err != nil && IsRateLimited(err) { e.markCooldown(acc, "上游限流", 3*time.Minute) } return res, err } // chatLoop 轮询账号直到成功或池耗尽。 -func (e *KiroEngine) chatLoop(ctx context.Context, req model.ChatRequest, onDelta func(string) error) (*model.ChatResult, error) { +func (e *Engine) chatLoop(ctx context.Context, req model.ChatRequest, onDelta func(string) error) (*model.ChatResult, error) { if !e.Ready() { - return nil, engineUnavailableError(e) + return nil, engine.UnavailableError(e) } cReq, err := toKiroRequest(req) if err != nil { @@ -422,7 +421,7 @@ func (e *KiroEngine) chatLoop(ctx context.Context, req model.ChatRequest, onDelt if ctx.Err() != nil { return nil, ctx.Err() } - if kiro.IsUnauthorized(err) || strings.Contains(err.Error(), "刷新失败") { + if IsUnauthorized(err) || strings.Contains(err.Error(), "刷新失败") { e.markCooldown(acc, "凭据失效", 10*time.Minute) continue } @@ -433,7 +432,7 @@ func (e *KiroEngine) chatLoop(ctx context.Context, req model.ChatRequest, onDelt return nil, errors.New("kiro 没有可用账号") } -func (e *KiroEngine) usableCount() int { +func (e *Engine) usableCount() int { e.mu.RLock() defer e.mu.RUnlock() n := 0 @@ -451,12 +450,12 @@ func (e *KiroEngine) usableCount() int { } // Chat 非流式对话。 -func (e *KiroEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { return e.chatLoop(ctx, req, nil) } // ChatStream 流式对话。 -func (e *KiroEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { return e.chatLoop(ctx, req, func(delta string) error { if onDelta != nil { return onDelta(delta) @@ -465,14 +464,14 @@ func (e *KiroEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDe }) } -func (e *KiroEngine) setErr(err error) { +func (e *Engine) setErr(err error) { e.errMu.Lock() e.lastErr = err e.errMu.Unlock() } // Status 引擎运行状态。 -func (e *KiroEngine) Status() map[string]any { +func (e *Engine) Status() map[string]any { accounts := e.AccountStates() ready := 0 for _, st := range accounts { @@ -497,11 +496,11 @@ func (e *KiroEngine) Status() map[string]any { } // Passthrough kiro 不支持多模态端点透传。 -func (e *KiroEngine) Passthrough() bool { return false } +func (e *Engine) Passthrough() bool { return false } -func (e *KiroEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("kiro 引擎不支持透传") } // Close 关闭引擎。 -func (e *KiroEngine) Close() {} +func (e *Engine) Close() {} diff --git a/internal/upstream/kiro/kiro.go b/internal/engine/kiro/kiro.go similarity index 100% rename from internal/upstream/kiro/kiro.go rename to internal/engine/kiro/kiro.go diff --git a/internal/provider/engine_sub_common.go b/internal/engine/support.go similarity index 64% rename from internal/provider/engine_sub_common.go rename to internal/engine/support.go index 3e85a17..e51b94b 100644 --- a/internal/provider/engine_sub_common.go +++ b/internal/engine/support.go @@ -1,6 +1,7 @@ -// engine_sub_common.go 订阅引擎(codex/antigravity/kiro/workbuddy)共享的 -// 请求转换与账号池辅助逻辑。 -package provider +// support.go 订阅引擎(codex/antigravity/kiro/workbuddy)与各引擎适配器 +// 共享的请求转换与账号池辅助逻辑。引擎实现包必须能免循环依赖地使用它们, +// 因此放在 engine 基础包而不是 provider。 +package engine import ( "encoding/json" @@ -10,8 +11,8 @@ import ( "web2api/internal/model" ) -// subImageURLs 从 ChatMessage.Content(string 或分段数组)中提取图片 URL。 -func subImageURLs(content any) []string { +// SubImageURLs 从 ChatMessage.Content(string 或分段数组)中提取图片 URL。 +func SubImageURLs(content any) []string { var out []string appendURL := func(raw string) { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "data:image/") { @@ -53,8 +54,8 @@ func subImageURLs(content any) []string { return out } -// subSystemText 提取全部 system 消息文本。 -func subSystemText(req model.ChatRequest) string { +// SubSystemText 提取全部 system 消息文本。 +func SubSystemText(req model.ChatRequest) string { var parts []string for _, m := range req.Messages { if strings.EqualFold(m.Role, "system") { @@ -66,14 +67,14 @@ func subSystemText(req model.ChatRequest) string { return strings.Join(parts, "\n\n") } -// subCooldown 判断账号冷却是否仍在生效。 -func subCooldownActive(until time.Time) bool { +// SubCooldownActive 判断账号冷却是否仍在生效。 +func SubCooldownActive(until time.Time) bool { return time.Now().Before(until) } -// subResultUsage 组装 TokenUsage;reasoningTokens 计入 completion(上游口径已含), +// SubResultUsage 组装 TokenUsage;reasoningTokens 计入 completion(上游口径已含), // Estimated=false 表示上游权威返回。 -func subResultUsage(prompt, completion, reasoning int64) *model.TokenUsage { +func SubResultUsage(prompt, completion, reasoning int64) *model.TokenUsage { if prompt <= 0 && completion <= 0 { return nil // 上游无用量 → 网关按文本估算 } @@ -85,11 +86,19 @@ func subResultUsage(prompt, completion, reasoning int64) *model.TokenUsage { } } -// marshalCompact 序列化为紧凑 JSON(凭据落库)。 -func marshalCompact(v any) string { +// MarshalCompact 序列化为紧凑 JSON(凭据落库)。 +func MarshalCompact(v any) string { b, err := json.Marshal(v) if err != nil { return "{}" } return string(b) } + +// DerefInt 安全解引用整型指针。 +func DerefInt(p *int) int { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/engine/upstream/openai.go b/internal/engine/upstream/openai.go index 802adf5..39802c3 100644 --- a/internal/engine/upstream/openai.go +++ b/internal/engine/upstream/openai.go @@ -261,12 +261,31 @@ func withThoughtSignatures(modelName string, messages []model.ChatMessage) []mod // BaseURL 上游地址。 func (o *OpenAI) BaseURL() string { return o.baseURL } +// normalizeAssistantContent 把「仅有 tool_calls」的 assistant.content 从 nil 改成 ""。 +// encoding/json 会把 nil any 编成 null;MiniMax 等第三方对 content=null 直接 400。 +func normalizeAssistantContent(messages []model.ChatMessage) []model.ChatMessage { + var out []model.ChatMessage + for i, m := range messages { + if !strings.EqualFold(m.Role, "assistant") || m.Content != nil || len(m.ToolCalls) == 0 { + continue + } + if out == nil { + out = append([]model.ChatMessage(nil), messages...) + } + out[i].Content = "" + } + if out == nil { + return messages + } + return out +} + // chatPayload 组装 /v1/chat/completions 请求体。工具定义只在非空时下发: // 部分第三方上游看到空的 tools 数组会直接 400。 func (o *OpenAI) chatPayload(req model.ChatRequest, stream bool) map[string]any { payload := map[string]any{ "model": req.Model, - "messages": withThoughtSignatures(req.Model, req.Messages), + "messages": withThoughtSignatures(req.Model, normalizeAssistantContent(req.Messages)), "stream": stream, } if req.Temperature != nil { diff --git a/internal/engine/upstream/openai_test.go b/internal/engine/upstream/openai_test.go index 0f5f361..27afddd 100644 --- a/internal/engine/upstream/openai_test.go +++ b/internal/engine/upstream/openai_test.go @@ -8,6 +8,28 @@ import ( "web2api/internal/model" ) +func TestChatPayloadAssistantToolCallsContentNotNull(t *testing.T) { + o := New("t", "http://x", "", false, 0) + req := model.ChatRequest{ + Model: "MiniMax-M2", + Messages: []model.ChatMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", ToolCalls: []model.ToolCall{{ID: "c1", Type: "function", Function: model.ToolFunction{Name: "exec_command", Arguments: "{}"}}}}, + {Role: "tool", Name: "exec_command", ToolCallID: "c1", Content: "ok"}, + }, + } + data, err := json.Marshal(o.chatPayload(req, false)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"content":null`) { + t.Fatalf("assistant content must not be null: %s", data) + } + if !strings.Contains(string(data), `"content":""`) { + t.Fatalf("assistant content should be empty string: %s", data) + } +} + func TestChatPayloadToolChoiceNotBase64(t *testing.T) { o := New("t", "http://x", "", false, 0) req := model.ChatRequest{ diff --git a/internal/provider/engine_workbuddy.go b/internal/engine/workbuddy/engine.go similarity index 72% rename from internal/provider/engine_workbuddy.go rename to internal/engine/workbuddy/engine.go index c9fed1b..001f678 100644 --- a/internal/provider/engine_workbuddy.go +++ b/internal/engine/workbuddy/engine.go @@ -1,6 +1,6 @@ -// engine_workbuddy.go 引擎 workbuddy:腾讯 CodeBuddy/WorkBuddy 订阅号池。 +// engine_go 引擎 workbuddy:腾讯 CodeBuddy/WorkBuddy 订阅号池。 // 凭据为 www.codebuddy.ai 的 Bearer token(OAuth 设备授权流程获取,暂无刷新接口)。 -package provider +package workbuddy import ( "context" @@ -16,11 +16,10 @@ import ( "web2api/internal/engine" "web2api/internal/model" "web2api/internal/store" - "web2api/internal/upstream/workbuddy" ) -// WorkBuddyCredentials workbuddy 引擎凭据(accounts.credentials JSON)。 -type WorkBuddyCredentials struct { +// Credentials workbuddy 引擎凭据(accounts.credentials JSON)。 +type Credentials struct { Token string `json:"token"` // Bearer accessToken RefreshToken string `json:"refresh_token,omitempty"` Email string `json:"email,omitempty"` @@ -32,7 +31,7 @@ type WorkBuddyCredentials struct { type workbuddyAccount struct { id, label string mu sync.Mutex - creds WorkBuddyCredentials + creds Credentials enabled bool ready bool @@ -57,8 +56,8 @@ func (a *workbuddyAccount) tokenExpired() bool { return time.Now().Unix() > a.creds.ExpiresAt } -// WorkBuddyEngine workbuddy 引擎。 -type WorkBuddyEngine struct { +// Engine workbuddy 引擎。 +type Engine struct { name string proxy string baseURL string @@ -74,12 +73,12 @@ type WorkBuddyEngine struct { } // NewWorkBuddy 构建 workbuddy 引擎。 -func NewWorkBuddy(cfg config.WorkBuddyConfig, st *store.Store) *WorkBuddyEngine { +func New(cfg config.WorkBuddyConfig, st *store.Store) *Engine { baseURL := strings.TrimSpace(cfg.BaseURL) if baseURL == "" { - baseURL = workbuddy.DefaultBaseURL + baseURL = DefaultBaseURL } - return &WorkBuddyEngine{ + return &Engine{ name: "workbuddy", proxy: strings.TrimSpace(cfg.Proxy), baseURL: baseURL, @@ -89,7 +88,7 @@ func NewWorkBuddy(cfg config.WorkBuddyConfig, st *store.Store) *WorkBuddyEngine } // AddAccount 添加账号(token 即时校验延后到首次请求)。 -func (e *WorkBuddyEngine) AddAccount(id, label string, creds WorkBuddyCredentials) error { +func (e *Engine) AddAccount(id, label string, creds Credentials) error { if strings.TrimSpace(creds.Token) == "" { return errors.New("token 不能为空") } @@ -106,7 +105,7 @@ func (e *WorkBuddyEngine) AddAccount(id, label string, creds WorkBuddyCredential } // RemoveAccount 移除账号。 -func (e *WorkBuddyEngine) RemoveAccount(id string) error { +func (e *Engine) RemoveAccount(id string) error { e.mu.Lock() idx, ok := e.ids[id] if !ok { @@ -125,7 +124,7 @@ func (e *WorkBuddyEngine) RemoveAccount(id string) error { } // SetEnabled 启停账号。 -func (e *WorkBuddyEngine) SetEnabled(id string, enabled bool) error { +func (e *Engine) SetEnabled(id string, enabled bool) error { e.mu.Lock() defer e.mu.Unlock() idx, ok := e.ids[id] @@ -139,7 +138,7 @@ func (e *WorkBuddyEngine) SetEnabled(id string, enabled bool) error { } // ReplaceCredentials 更新凭据(重建账号)。 -func (e *WorkBuddyEngine) ReplaceCredentials(id string, creds WorkBuddyCredentials) error { +func (e *Engine) ReplaceCredentials(id string, creds Credentials) error { e.mu.RLock() idx, ok := e.ids[id] label := id @@ -157,19 +156,19 @@ func (e *WorkBuddyEngine) ReplaceCredentials(id string, creds WorkBuddyCredentia } // AccountStates 全部账号状态(管理台)。 -func (e *WorkBuddyEngine) AccountStates() []AccountState { +func (e *Engine) AccountStates() []engine.AccountState { e.mu.RLock() defer e.mu.RUnlock() - out := make([]AccountState, 0, len(e.accounts)) + out := make([]engine.AccountState, 0, len(e.accounts)) for _, acc := range e.accounts { acc.mu.Lock() - state := AccountState{ + state := engine.AccountState{ ID: acc.id, Label: acc.label, Enabled: acc.enabled, Detail: acc.lastErr, Tier: acc.creds.Email, - Models: len(workbuddy.DefaultModels), + Models: len(DefaultModels), } switch { case !acc.enabled: @@ -194,14 +193,14 @@ func (e *WorkBuddyEngine) AccountStates() []AccountState { // ---- Engine 接口 ---- -func (e *WorkBuddyEngine) Name() string { return e.name } +func (e *Engine) Name() string { return e.name } -func (e *WorkBuddyEngine) Describe() string { +func (e *Engine) Describe() string { return "腾讯 CodeBuddy/WorkBuddy(SaaS 订阅号池)" } // Ready 至少一个账号可用。 -func (e *WorkBuddyEngine) Ready() bool { +func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() for _, acc := range e.accounts { @@ -216,7 +215,7 @@ func (e *WorkBuddyEngine) Ready() bool { } // ListModels workbuddy 模型目录。 -func (e *WorkBuddyEngine) ListModels() []model.ModelInfo { +func (e *Engine) ListModels() []model.ModelInfo { e.mu.RLock() enabled := 0 for _, acc := range e.accounts { @@ -227,8 +226,8 @@ func (e *WorkBuddyEngine) ListModels() []model.ModelInfo { acc.mu.Unlock() } e.mu.RUnlock() - out := make([]model.ModelInfo, 0, len(workbuddy.DefaultModels)) - for _, id := range workbuddy.DefaultModels { + out := make([]model.ModelInfo, 0, len(DefaultModels)) + for _, id := range DefaultModels { out = append(out, model.ModelInfo{ ID: id, Object: "model", @@ -241,7 +240,7 @@ func (e *WorkBuddyEngine) ListModels() []model.ModelInfo { } // pick 轮询选择可用账号。 -func (e *WorkBuddyEngine) pick() *workbuddyAccount { +func (e *Engine) pick() *workbuddyAccount { e.mu.RLock() defer e.mu.RUnlock() n := len(e.accounts) @@ -261,30 +260,30 @@ func (e *WorkBuddyEngine) pick() *workbuddyAccount { return nil } -func (e *WorkBuddyEngine) markCooldown(acc *workbuddyAccount, reason string, d time.Duration) { +func (e *Engine) markCooldown(acc *workbuddyAccount, reason string, d time.Duration) { acc.mu.Lock() acc.cooldownUntil = time.Now().Add(d) acc.cooldownReason = reason acc.mu.Unlock() } -// toWorkBuddyRequest ChatRequest → workbuddy.Request。 -func toWorkBuddyRequest(req model.ChatRequest) *workbuddy.Request { - out := &workbuddy.Request{ +// toWorkBuddyRequest ChatRequest → Request。 +func toWorkBuddyRequest(req model.ChatRequest) *Request { + out := &Request{ Model: req.Model, - MaxTokens: derefInt(req.MaxTokens), + MaxTokens: engine.DerefInt(req.MaxTokens), Temperature: req.Temperature, } for _, m := range req.Messages { - out.Messages = append(out.Messages, workbuddy.Message{Role: m.PlainRole(), Text: m.Text()}) + out.Messages = append(out.Messages, Message{Role: m.PlainRole(), Text: m.Text()}) } return out } // chatLoop 轮询账号直到成功或池耗尽。 -func (e *WorkBuddyEngine) chatLoop(ctx context.Context, req model.ChatRequest, onDelta func(string) error) (*model.ChatResult, error) { +func (e *Engine) chatLoop(ctx context.Context, req model.ChatRequest, onDelta func(string) error) (*model.ChatResult, error) { if !e.Ready() { - return nil, engineUnavailableError(e) + return nil, engine.UnavailableError(e) } cReq := toWorkBuddyRequest(req) var lastErr error @@ -298,27 +297,27 @@ func (e *WorkBuddyEngine) chatLoop(ctx context.Context, req model.ChatRequest, o userID := acc.creds.Email proxy := acc.proxy(e.proxy) acc.mu.Unlock() - res, err := workbuddy.Stream(ctx, proxy, e.baseURL, token, userID, cReq, onDelta) + res, err := Stream(ctx, proxy, e.baseURL, token, userID, cReq, onDelta) if err == nil { return &model.ChatResult{ Text: res.Text, Engine: e.name, Model: req.Model, - Usage: subResultUsage(res.PromptTokens, res.CompletionTokens, 0), + Usage: engine.SubResultUsage(res.PromptTokens, res.CompletionTokens, 0), }, nil } lastErr = err if ctx.Err() != nil { return nil, ctx.Err() } - if workbuddy.IsUnauthorized(err) { + if IsUnauthorized(err) { e.markCooldown(acc, "token 失效,请重新授权", 30*time.Minute) acc.mu.Lock() acc.lastErr = "token 失效,请重新授权" acc.mu.Unlock() continue } - if workbuddy.IsRateLimited(err) { + if IsRateLimited(err) { e.markCooldown(acc, "上游限流", 3*time.Minute) continue } @@ -329,7 +328,7 @@ func (e *WorkBuddyEngine) chatLoop(ctx context.Context, req model.ChatRequest, o return nil, errors.New("workbuddy 没有可用账号") } -func (e *WorkBuddyEngine) usableCount() int { +func (e *Engine) usableCount() int { e.mu.RLock() defer e.mu.RUnlock() n := 0 @@ -347,12 +346,12 @@ func (e *WorkBuddyEngine) usableCount() int { } // Chat 非流式对话。 -func (e *WorkBuddyEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { +func (e *Engine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { return e.chatLoop(ctx, req, nil) } // ChatStream 流式对话。 -func (e *WorkBuddyEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { +func (e *Engine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { return e.chatLoop(ctx, req, func(delta string) error { if onDelta != nil { return onDelta(delta) @@ -361,14 +360,14 @@ func (e *WorkBuddyEngine) ChatStream(ctx context.Context, req model.ChatRequest, }) } -func (e *WorkBuddyEngine) setErr(err error) { +func (e *Engine) setErr(err error) { e.errMu.Lock() e.lastErr = err e.errMu.Unlock() } // Status 引擎运行状态。 -func (e *WorkBuddyEngine) Status() map[string]any { +func (e *Engine) Status() map[string]any { accounts := e.AccountStates() ready := 0 for _, st := range accounts { @@ -393,11 +392,11 @@ func (e *WorkBuddyEngine) Status() map[string]any { } // Passthrough workbuddy 不支持多模态端点透传。 -func (e *WorkBuddyEngine) Passthrough() bool { return false } +func (e *Engine) Passthrough() bool { return false } -func (e *WorkBuddyEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { +func (e *Engine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { return errors.New("workbuddy 引擎不支持透传") } // Close 关闭引擎。 -func (e *WorkBuddyEngine) Close() {} +func (e *Engine) Close() {} diff --git a/internal/upstream/workbuddy/workbuddy.go b/internal/engine/workbuddy/workbuddy.go similarity index 100% rename from internal/upstream/workbuddy/workbuddy.go rename to internal/engine/workbuddy/workbuddy.go diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 464f569..69a4e1c 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "web2api/internal/ir" "web2api/internal/model" "web2api/internal/provider" "web2api/internal/store" @@ -32,6 +33,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if req.Model == "" { req.Model = "gemini-flash" } + // 统一 IR 入口:与 /v1/responses 走同一套规范化不变量(非 function + // 工具丢弃、空 assistant content、tool name 回填)。 + req = ir.NormalizeChat(req, s.logger.Printf) if !s.modelAllowed(w, r, req.Model) { return } @@ -79,7 +83,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { res, runErr := s.mgr.ChatStream(ctx, req, sink) if runErr != nil { s.logger.Printf("[chat] 流式请求失败 model=%q: %v", req.Model, runErr) - s.recordChat(r, req, nil, true, runErr, start, "/v1/chat/completions") + s.recordChat(r, req, res, true, runErr, start, "/v1/chat/completions") stop := "stop" _ = writeDelta(model.Delta{Content: "[Error] " + runErr.Error()}, &stop, nil) } else { @@ -116,7 +120,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { res, err := s.mgr.Chat(ctx, req) if err != nil { s.logger.Printf("[chat] 请求失败 model=%q: %v", req.Model, err) - s.recordChat(r, req, nil, false, err, start, "/v1/chat/completions") + s.recordChat(r, req, res, false, err, start, "/v1/chat/completions") // 「没有任何账号/引擎声明该模型」是客户端问题,返回 404 + model_not_found, // 不要包装成 502 让下游误以为可以重试。 if provider.IsModelNotFound(err) { diff --git a/internal/gateway/docs.go b/internal/gateway/docs.go index 60aa1de..59a798e 100644 --- a/internal/gateway/docs.go +++ b/internal/gateway/docs.go @@ -64,7 +64,7 @@ func publicAPISpec() map[string]any { "post": map[string]any{"tags": []string{"聊天"}, "summary": "创建聊天补全", "description": "stream=true 时返回 text/event-stream;普通请求返回 ChatCompletionResponse。", "requestBody": jsonBody("ChatCompletionRequest"), "responses": map[string]any{"200": jsonResponse("聊天结果或 SSE 流", "ChatCompletionResponse"), "400": jsonResponse("请求参数错误", "Error"), "401": jsonResponse("API Key 无效", "Error"), "502": jsonResponse("上游模型调用失败", "Error")}}, }, "/v1/responses": map[string]any{ - "post": map[string]any{"tags": []string{"聊天"}, "summary": "OpenAI Responses 兼容端点", "description": "把 Responses 请求(input/instructions/tools)转换为内部 Chat 请求执行:stream=true 返回 response.created / output_text.delta / function_call_arguments.delta / response.completed 事件流并带 usage,否则返回 Responses 对象。供 Codex 等只讲 Responses 的客户端直连,无需本地路由做协议转换。支持 function 类型的工具调用;local_shell/apply_patch 等内置工具会降级成同名 function,web_search 仍忽略;previous_response_id 等有状态字段不支持。", "requestBody": map[string]any{"required": true, "content": jsonContent(map[string]any{"type": "object"})}, "responses": map[string]any{"200": map[string]any{"description": "Responses 对象或 response.* 事件流"}, "400": jsonResponse("请求参数错误", "Error"), "401": jsonResponse("API Key 无效", "Error"), "404": jsonResponse("模型无人声明", "Error"), "502": jsonResponse("上游模型调用失败", "Error")}}, + "post": map[string]any{"tags": []string{"聊天"}, "summary": "OpenAI Responses 兼容端点", "description": "把 Responses 请求(input/instructions/tools)转换为内部 Chat 请求执行:stream=true 返回 response.created / output_text.delta / function_call_arguments.delta / response.completed 事件流并带 usage,否则返回 Responses 对象。供 Codex 等只讲 Responses 的客户端直连,无需本地路由做协议转换。支持 function 类型的工具调用;local_shell/apply_patch/web_search 等非 function 工具在入口丢弃(记日志),不进入上游报文;previous_response_id 等有状态字段不支持。", "requestBody": map[string]any{"required": true, "content": jsonContent(map[string]any{"type": "object"})}, "responses": map[string]any{"200": map[string]any{"description": "Responses 对象或 response.* 事件流"}, "400": jsonResponse("请求参数错误", "Error"), "401": jsonResponse("API Key 无效", "Error"), "404": jsonResponse("模型无人声明", "Error"), "502": jsonResponse("上游模型调用失败", "Error")}}, }, "/v1/videos": map[string]any{ "post": map[string]any{"tags": []string{"视频"}, "summary": "创建 Veo 视频任务", "requestBody": jsonBody("VideoCreateRequest"), "responses": map[string]any{"202": jsonResponse("任务已创建", "VideoResponse"), "400": jsonResponse("请求参数错误", "Error"), "401": jsonResponse("API Key 无效", "Error"), "429": jsonResponse("上游限流、配额不足或账户冷却", "Error"), "502": jsonResponse("Veo 协议调用失败", "Error")}}, @@ -107,7 +107,7 @@ func publicSchemas() map[string]any { "Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "object", "properties": map[string]any{"message": map[string]any{"type": "string"}, "type": map[string]any{"type": "string"}, "code": map[string]any{}}}}}, "HealthResponse": map[string]any{"type": "object", "properties": map[string]any{"status": map[string]any{"type": "string"}, "uptime": map[string]any{"type": "string"}, "engine_a": map[string]any{"type": "boolean"}, "engine_b": map[string]any{"type": "boolean"}}}, "ModelList": map[string]any{"type": "object", "properties": map[string]any{"object": map[string]any{"type": "string", "example": "list"}, "data": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/Model"}}}}, - "Model": map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}, "object": map[string]any{"type": "string"}, "display_name": map[string]any{"type": "string"}, "available": map[string]any{"type": "boolean"}, "engine": map[string]any{"type": "string", "enum": []string{"a", "b"}}, "kind": map[string]any{"type": "string", "enum": []string{"chat", "image", "video", "music", "speech", "embedding"}, "description": "用途:对话、生图、视频、音乐、语音、向量"}}}, + "Model": map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}, "object": map[string]any{"type": "string"}, "display_name": map[string]any{"type": "string"}, "available": map[string]any{"type": "boolean"}, "engine": map[string]any{"type": "string", "enum": []string{"geminiweb", "aistudio"}}, "kind": map[string]any{"type": "string", "enum": []string{"chat", "image", "video", "music", "speech", "embedding"}, "description": "用途:对话、生图、视频、音乐、语音、向量"}}}, "AccountsResponse": map[string]any{"type": "object", "properties": map[string]any{"object": map[string]any{"type": "string"}, "accounts": map[string]any{"type": "object", "additionalProperties": true}}}, "ChatCompletionRequest": map[string]any{"type": "object", "required": []string{"model", "messages"}, "properties": map[string]any{"model": map[string]any{"type": "string"}, "messages": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/ChatMessage"}}, "stream": map[string]any{"type": "boolean", "default": false}, "temperature": map[string]any{"type": "number"}, "top_p": map[string]any{"type": "number"}, "max_tokens": map[string]any{"type": "integer"}, "user": map[string]any{"type": "string"}, "image_size": map[string]any{"type": "string", "enum": []string{"512", "1K", "2K", "4K"}}, "resolution": map[string]any{"type": "string", "enum": []string{"360p", "720p", "1080p", "4k"}}}}, "ChatMessage": map[string]any{"type": "object", "required": []string{"role", "content"}, "properties": map[string]any{"role": map[string]any{"type": "string", "enum": []string{"system", "user", "assistant", "tool"}}, "content": map[string]any{}}}, diff --git a/internal/gateway/models.go b/internal/gateway/models.go index 515e45a..1f898d3 100644 --- a/internal/gateway/models.go +++ b/internal/gateway/models.go @@ -24,13 +24,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { out := map[string]any{ "status": "ok", "uptime": time.Since(s.startAt).String(), - "engine_a": s.mgr.EngineReady("a"), - "engine_b": s.mgr.EngineReady("b"), + "engine_geminiweb": s.mgr.EngineReady("geminiweb"), + "engine_aistudio": s.mgr.EngineReady("aistudio"), "gemini_api": s.gemini.Enabled(), } // 订阅引擎就绪状态(engine_codex / engine_antigravity / ...) for name, ready := range s.mgr.EngineReadyMap() { - if name == "a" || name == "b" { + if name == "geminiweb" || name == "aistudio" { continue } out["engine_"+name] = ready diff --git a/internal/gateway/responses.go b/internal/gateway/responses.go index 86f4046..84b659f 100644 --- a/internal/gateway/responses.go +++ b/internal/gateway/responses.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "web2api/internal/ir" "web2api/internal/model" "web2api/internal/provider" "web2api/internal/store" @@ -22,7 +23,8 @@ import ( // temperature/top_p/max_output_tokens、usage、response.* 事件流, // 以及 function calling(tools / tool_choice / function_call 输出项)。 // 未支持:previous_response_id 等有状态字段。 -// local_shell / apply_patch 等内置工具会降级成同名 function 下发;web_search 仍忽略。 +// local_shell / apply_patch / web_search 等非 function 工具在入口丢弃并记日志, +// 不进入任何上游报文。 // responsesRequest 是 Responses 请求体(网关支持的子集)。 type responsesRequest struct { @@ -82,11 +84,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error(), "invalid_request_error", nil) return } - chatReq, err := req.toChat() + chatReq, err := req.toChat(s.logger.Printf) if err != nil { writeError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nil) return } + // 统一 IR 入口:与 /v1/chat/completions 走同一套规范化不变量。 + chatReq = ir.NormalizeChat(chatReq, s.logger.Printf) if !s.modelAllowed(w, r, chatReq.Model) { return } @@ -100,7 +104,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { res, err := s.mgr.Chat(r.Context(), chatReq) if err != nil { s.logger.Printf("[responses] 请求失败 model=%q: %v", chatReq.Model, err) - s.recordChat(r, chatReq, nil, false, err, start, "/v1/responses") + s.recordChat(r, chatReq, res, false, err, start, "/v1/responses") s.writeResponsesError(w, err) return } @@ -243,7 +247,7 @@ func (s *Server) streamResponses(w http.ResponseWriter, r *http.Request, req mod res, runErr := s.mgr.ChatStream(r.Context(), req, sink) if runErr != nil { s.logger.Printf("[responses] 流式请求失败 model=%q: %v", req.Model, runErr) - s.recordChat(r, req, nil, true, runErr, start, "/v1/responses") + s.recordChat(r, req, res, true, runErr, start, "/v1/responses") failed := responsesObject(respID, req.Model, "failed", []any{}, nil, createdAt) failed["error"] = map[string]any{"code": "server_error", "message": runErr.Error()} emit("response.failed", map[string]any{"response": failed}) @@ -337,8 +341,9 @@ func (s *Server) writeResponsesError(w http.ResponseWriter, err error) { writeError(w, http.StatusBadGateway, err.Error(), "api_error", nil) } -// toChat 把 Responses 请求转换成内部 Chat 请求。 -func (req responsesRequest) toChat() (model.ChatRequest, error) { +// toChat 把 Responses 请求转换成内部 Chat 请求(近 IR;不明工具类型的 +// 丢弃与日志在此处理,其余不变量由 ir.NormalizeChat 统一兜底)。 +func (req responsesRequest) toChat(logf ir.Logf) (model.ChatRequest, error) { out := model.ChatRequest{ Model: strings.TrimSpace(req.Model), Stream: req.Stream, @@ -347,7 +352,7 @@ func (req responsesRequest) toChat() (model.ChatRequest, error) { MaxTokens: req.MaxOutputTokens, User: req.User, StreamOptions: req.StreamOptions, - Tools: req.toChatTools(), + Tools: req.toChatTools(logf), ToolChoice: toChatToolChoice(req.ToolChoice), ParallelToolCalls: req.ParallelToolCalls, } @@ -368,15 +373,22 @@ func (req responsesRequest) toChat() (model.ChatRequest, error) { return out, nil } -// toChatTools 把 Responses 格式的工具定义转成 Chat 格式。 +// toChatTools 把 Responses 格式的工具定义转成 Chat(IR)格式。 // -// Codex 0.16x 起 shell 类工具已是 type=function(exec_command 等),可直接透传。 -// 更老的客户端仍可能发 local_shell / apply_patch 等内置类型;它们在 Chat/Gemini -// 协议里没有同名形态,这里降级成同名 function,让非 OpenAI 上游也能发起调用, -// 客户端再按 function_call 执行本地工具。web_search 仍丢弃(需服务端检索能力)。 -// namespace 嵌套工具会被拍平为独立 function。 -func (req responsesRequest) toChatTools() []model.Tool { +// 只保留 type=function(namespace 嵌套拍平为独立 function)。local_shell / +// apply_patch / web_search 等内置工具在入口丢弃并记日志:它们依赖 OpenAI +// 服务端或客户端专用执行环境,任何上游报文里都不该出现(计划 §5.2); +// 降级成同名 function 只会把「模型发起了调用但客户端执行不了」的困惑 +// 从网关挪到上游。Codex 0.16x 起的 shell 工具本就是 type=function +// (exec_command 等),不受影响。 +func (req responsesRequest) toChatTools(logf ir.Logf) []model.Tool { out := make([]model.Tool, 0, len(req.Tools)) + var dropped []string + drop := func(typ string) { + if typ != "" { + dropped = append(dropped, typ) + } + } var appendTool func(tool responsesTool) appendTool = func(tool responsesTool) { typ := strings.ToLower(strings.TrimSpace(tool.Type)) @@ -384,6 +396,7 @@ func (req responsesRequest) toChatTools() []model.Tool { case "", "function": name := strings.TrimSpace(tool.Name) if name == "" { + drop("function(name缺失)") return } params := tool.Parameters @@ -402,43 +415,17 @@ func (req responsesRequest) toChatTools() []model.Tool { for _, nested := range tool.Tools { appendTool(nested) } - case "local_shell", "shell", "apply_patch", "shell_command": - name := strings.TrimSpace(tool.Name) - if name == "" { - name = typ - } - desc := strings.TrimSpace(tool.Description) - if desc == "" { - switch typ { - case "local_shell", "shell", "shell_command": - desc = "Run a shell command in the local session and return its output." - case "apply_patch": - desc = "Apply a patch to local files." - } - } - params := tool.Parameters - if len(params) == 0 || string(params) == "null" { - if typ == "apply_patch" { - params = json.RawMessage(`{"type":"object","properties":{"patch":{"type":"string"}},"required":["patch"]}`) - } else { - params = json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"},"workdir":{"type":"string"}},"required":["command"]}`) - } - } - out = append(out, model.Tool{ - Type: "function", - Function: &model.ToolFunc{ - Name: name, - Description: desc, - Parameters: params, - }, - }) default: - // web_search 等服务端内置工具:非 OpenAI 上游无法执行,丢弃。 + // local_shell / web_search 等内置工具:入口丢弃。 + drop(tool.Type) } } for _, tool := range req.Tools { appendTool(tool) } + if len(dropped) > 0 && logf != nil { + logf("[responses] 丢弃非 function 工具(不进入上游报文): %s", strings.Join(dropped, ", ")) + } return out } @@ -502,6 +489,9 @@ func parseResponsesInput(raw json.RawMessage) ([]model.ChatMessage, error) { return nil, fmt.Errorf("input 解析失败: %w", err) } out := make([]model.ChatMessage, 0, len(items)) + // callID → 函数名:Responses 的 function_call_output 通常只有 call_id, + // 不含 name;MiniMax / Gemini functionResponse / AI Studio 都需要 name。 + callNames := map[string]string{} for _, item := range items { switch item.Type { case "", "message": @@ -537,24 +527,36 @@ func parseResponsesInput(raw json.RawMessage) ([]model.ChatMessage, error) { if callID == "" { callID = "call_" + model.NewID(16) } + name := strings.TrimSpace(item.Name) + if name != "" { + callNames[callID] = name + } call := model.ToolCall{ ID: callID, Type: "function", Function: model.ToolFunction{ - Name: item.Name, + Name: name, Arguments: rawJSONString(item.Arguments), }, } // 连续的 function_call 属于同一个 assistant 回合,归并成一条消息。 + // Content 必须是 "" 而不是 nil:encoding/json 会把 nil any 编成 + // null,MiniMax 等第三方上游对 assistant.content=null 直接 400。 if n := len(out); n > 0 && out[n-1].Role == "assistant" && out[n-1].ToolCallID == "" { out[n-1].ToolCalls = append(out[n-1].ToolCalls, call) continue } - out = append(out, model.ChatMessage{Role: "assistant", ToolCalls: []model.ToolCall{call}}) + out = append(out, model.ChatMessage{Role: "assistant", Content: "", ToolCalls: []model.ToolCall{call}}) case "function_call_output": + callID := strings.TrimSpace(item.CallID) + name := strings.TrimSpace(item.Name) + if name == "" && callID != "" { + name = callNames[callID] + } out = append(out, model.ChatMessage{ Role: "tool", - ToolCallID: item.CallID, + Name: name, + ToolCallID: callID, Content: rawText(item.Output), }) case "reasoning": diff --git a/internal/gateway/responses_tools_test.go b/internal/gateway/responses_tools_test.go index 90625d7..8cd7b68 100644 --- a/internal/gateway/responses_tools_test.go +++ b/internal/gateway/responses_tools_test.go @@ -2,9 +2,37 @@ package gateway import ( "encoding/json" + "fmt" + "strings" "testing" ) +func TestParseResponsesInputFillsToolNameAndEmptyAssistantContent(t *testing.T) { + raw := json.RawMessage(`[ + {"type":"message","role":"user","content":"hi"}, + {"type":"function_call","call_id":"call_1","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"} + ]`) + msgs, err := parseResponsesInput(raw) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 3 { + t.Fatalf("msgs=%d %#v", len(msgs), msgs) + } + if msgs[1].Role != "assistant" || msgs[1].Content != "" { + t.Fatalf("assistant content must be empty string, got %#v", msgs[1].Content) + } + data, _ := json.Marshal(msgs[1]) + if strings.Contains(string(data), `"content":null`) { + t.Fatalf("content must not marshal as null: %s", data) + } + if msgs[2].Role != "tool" || msgs[2].Name != "exec_command" || msgs[2].ToolCallID != "call_1" { + t.Fatalf("tool msg=%#v", msgs[2]) + } +} + + func TestToChatToolsKeepsFunctionAndFlattensNamespace(t *testing.T) { req := responsesRequest{Tools: []responsesTool{ {Type: "function", Name: "exec_command", Description: "run", Parameters: json.RawMessage(`{"type":"object"}`)}, @@ -13,8 +41,13 @@ func TestToChatToolsKeepsFunctionAndFlattensNamespace(t *testing.T) { }}, {Type: "web_search"}, {Type: "local_shell"}, + {Type: "apply_patch"}, }} - tools := req.toChatTools() + var logs []string + logf := func(format string, args ...any) { + logs = append(logs, fmt.Sprintf(format, args...)) + } + tools := req.toChatTools(logf) names := map[string]bool{} for _, tool := range tools { if tool.Function == nil { @@ -22,31 +55,20 @@ func TestToChatToolsKeepsFunctionAndFlattensNamespace(t *testing.T) { } names[tool.Function.Name] = true } - for _, want := range []string{"exec_command", "spawn_agent", "local_shell"} { + for _, want := range []string{"exec_command", "spawn_agent"} { if !names[want] { t.Fatalf("missing %s in %#v", want, names) } } - if names["web_search"] || names["multi_agent_v1"] { - t.Fatalf("should drop web_search/namespace wrapper, got %#v", names) - } -} - -func TestToChatToolsLocalShellGetsCommandSchema(t *testing.T) { - req := responsesRequest{Tools: []responsesTool{{Type: "local_shell"}}} - tools := req.toChatTools() - if len(tools) != 1 || tools[0].Function == nil { - t.Fatalf("tools=%+v", tools) - } - if tools[0].Function.Name != "local_shell" { - t.Fatalf("name=%q", tools[0].Function.Name) - } - var schema map[string]any - if err := json.Unmarshal(tools[0].Function.Parameters, &schema); err != nil { - t.Fatal(err) + for _, gone := range []string{"web_search", "local_shell", "apply_patch", "multi_agent_v1"} { + if names[gone] { + t.Fatalf("non-function tool %s must not enter upstream payload, got %#v", gone, names) + } } - props, _ := schema["properties"].(map[string]any) - if _, ok := props["command"]; !ok { - t.Fatalf("expected command property, got %#v", schema) + joined := strings.Join(logs, "\n") + for _, gone := range []string{"web_search", "local_shell", "apply_patch"} { + if !strings.Contains(joined, gone) { + t.Fatalf("dropped tool %s should be logged, got %v", gone, logs) + } } } diff --git a/internal/gateway/videos.go b/internal/gateway/videos.go index a39c2e3..7a6d155 100644 --- a/internal/gateway/videos.go +++ b/internal/gateway/videos.go @@ -10,7 +10,7 @@ import ( "strings" "time" - aistudio "web2api/internal/aistudio" + aistudio "web2api/internal/engine/aistudio" "web2api/internal/store" ) @@ -88,7 +88,7 @@ func (s *Server) handleCreateVideo(w http.ResponseWriter, r *http.Request) { if videoPrice > 0 { info, _ := keyInfoFrom(r) if _, err := s.st.RecordRequest(store.UsageRecord{ - KeyID: info.ID, APIKey: s.currentKey(r), Engine: "b", Model: req.Model, Endpoint: "/v1/videos", + KeyID: info.ID, APIKey: s.currentKey(r), Engine: "aistudio", Model: req.Model, Endpoint: "/v1/videos", Success: true, TotalTokens: int64(seconds) * videoPrice, LatencyMs: time.Since(start).Milliseconds(), }); err != nil { s.logger.Printf("[video] 记录视频扣费失败 model=%q: %v", req.Model, err) diff --git a/internal/ir/ir.go b/internal/ir/ir.go new file mode 100644 index 0000000..3c00855 --- /dev/null +++ b/internal/ir/ir.go @@ -0,0 +1,111 @@ +// Package ir 定义网关内部协议(IR)。 +// +// Wire 入站(/v1/responses、/v1/chat/completions)解析完成后统一经过 +// NormalizeChat 规范化;之后路由层、引擎适配器拿到的都是满足下列不变量的 +// ChatRequest。适配器只做「IR → 上游报文」的改写,不再各自猜测入站格式, +// 平台差异(工具协议、空 content、400 降级)全部下沉到适配器与能力表 +// (见 provider.EngineCaps)。 +// +// IR 不变量: +// - tools 只含 type=function 且函数名非空;不明工具类型(web_search 等 +// 服务端内置工具、local_shell 等 Chat 形态的内置工具)在入口丢弃并记 +// 日志,不进入任何上游报文 +// - tool_choice 只在有工具存活时保留 +// - 带 tool_calls 的 assistant 消息 content 为 "" 而非 null: +// encoding/json 会把 nil any 编成 null,MiniMax 等第三方上游直接 400 +// - role=tool 消息尽量补全 name:按 assistant 历史里的 call_id → 函数名 +// 映射回填(Gemini functionResponse、MiniMax 都要求 name) +package ir + +import ( + "strings" + + "web2api/internal/model" +) + +// Logf 规范化过程的日志出口,签名与 log.Logger.Printf 一致;nil 时静默 +// 丢弃(单测场景)。 +type Logf func(format string, args ...any) + +// NormalizeChat 把 Wire 入站解析出的请求规范化为 IR。幂等:已满足不变量 +// 的请求原样返回(不拷贝消息切片)。 +func NormalizeChat(req model.ChatRequest, logf Logf) model.ChatRequest { + req.Tools = KeepFunctionTools(req.Tools, logf) + if len(req.Tools) == 0 { + req.ToolChoice = nil + } + req.Messages = normalizeMessages(req.Messages) + return req +} + +// KeepFunctionTools 只保留 function 工具,丢弃不明类型并记日志。 +// Responses 入站的同名转换(gateway.toChatTools)产出已满足本不变量, +// 这里兜住 /v1/chat/completions 直发非 function 工具的客户端。 +func KeepFunctionTools(tools []model.Tool, logf Logf) []model.Tool { + if len(tools) == 0 { + return nil + } + out := make([]model.Tool, 0, len(tools)) + var dropped []string + for _, tool := range tools { + if tool.Function == nil || strings.TrimSpace(tool.Function.Name) == "" { + typ := strings.TrimSpace(tool.Type) + if typ == "" { + typ = "(missing function)" + } + dropped = append(dropped, typ) + continue + } + tool.Type = "function" + out = append(out, tool) + } + if len(dropped) > 0 && logf != nil { + logf("[ir] 丢弃非 function 工具: %s", strings.Join(dropped, ", ")) + } + if len(out) == 0 { + return nil + } + return out +} + +// normalizeMessages 保证消息级不变量:空 assistant content、tool 消息的 +// name 回填。写时拷贝:没有任何消息需要改时不复制原切片。 +func normalizeMessages(messages []model.ChatMessage) []model.ChatMessage { + callNames := map[string]string{} + for _, m := range messages { + if !strings.EqualFold(m.Role, "assistant") { + continue + } + for _, call := range m.ToolCalls { + if name := strings.TrimSpace(call.Function.Name); name != "" { + callNames[strings.TrimSpace(call.ID)] = name + } + } + } + var out []model.ChatMessage + for i, m := range messages { + var fixed model.ChatMessage + switch { + case strings.EqualFold(m.Role, "assistant") && m.Content == nil && len(m.ToolCalls) > 0: + fixed = m + fixed.Content = "" + case strings.EqualFold(m.Role, "tool") && strings.TrimSpace(m.Name) == "": + name := callNames[strings.TrimSpace(m.ToolCallID)] + if name == "" { + continue + } + fixed = m + fixed.Name = name + default: + continue + } + if out == nil { + out = append([]model.ChatMessage(nil), messages...) + } + out[i] = fixed + } + if out == nil { + return messages + } + return out +} diff --git a/internal/ir/ir_test.go b/internal/ir/ir_test.go new file mode 100644 index 0000000..1d3a8c7 --- /dev/null +++ b/internal/ir/ir_test.go @@ -0,0 +1,99 @@ +package ir + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "web2api/internal/model" +) + +func TestNormalizeChatDropsNonFunctionTools(t *testing.T) { + req := model.ChatRequest{ + Model: "gemini-flash", + Tools: []model.Tool{ + {Type: "web_search"}, + {Type: "function", Function: &model.ToolFunc{Name: "exec_command"}}, + {Type: "local_shell"}, + }, + ToolChoice: json.RawMessage(`"auto"`), + Messages: []model.ChatMessage{{Role: "user", Content: "hi"}}, + } + var logs []string + out := NormalizeChat(req, func(format string, args ...any) { + logs = append(logs, strings.TrimSpace(fmt.Sprintf(format, args...))) + }) + if len(out.Tools) != 1 || out.Tools[0].Function.Name != "exec_command" { + t.Fatalf("only function tool should survive: %#v", out.Tools) + } + if out.Tools[0].Type != "function" { + t.Fatalf("tool type should be normalized to function: %#v", out.Tools[0]) + } + if len(logs) == 0 || !strings.Contains(logs[0], "web_search") || !strings.Contains(logs[0], "local_shell") { + t.Fatalf("dropped tools should be logged: %v", logs) + } + if string(out.ToolChoice) != `"auto"` { + t.Fatalf("tool_choice should stay when tools survive: %s", out.ToolChoice) + } +} + +func TestNormalizeChatClearsToolChoiceWhenAllToolsDropped(t *testing.T) { + req := model.ChatRequest{ + Tools: []model.Tool{{Type: "web_search"}}, + ToolChoice: json.RawMessage(`"auto"`), + Messages: []model.ChatMessage{{Role: "user", Content: "hi"}}, + } + out := NormalizeChat(req, nil) + if out.Tools != nil || out.ToolChoice != nil { + t.Fatalf("tools/tool_choice should be cleared: tools=%v choice=%s", out.Tools, out.ToolChoice) + } +} + +func TestNormalizeChatFillsToolNameAndEmptyContent(t *testing.T) { + req := model.ChatRequest{ + Messages: []model.ChatMessage{ + {Role: "user", Content: "run ls"}, + {Role: "assistant", Content: nil, ToolCalls: []model.ToolCall{ + {ID: "call_1", Type: "function", Function: model.ToolFunction{Name: "exec_command", Arguments: "{}"}}, + }}, + {Role: "tool", ToolCallID: "call_1", Content: "ok"}, // 无 name,Chat 客户端常见 + }, + } + out := NormalizeChat(req, nil) + if out.Messages[1].Content != "" { + t.Fatalf("assistant content must be empty string: %#v", out.Messages[1].Content) + } + if out.Messages[2].Name != "exec_command" { + t.Fatalf("tool name should be filled from call history: %#v", out.Messages[2]) + } + data, _ := json.Marshal(out.Messages[1]) + if strings.Contains(string(data), `"content":null`) { + t.Fatalf("content must not marshal as null: %s", data) + } +} + +func TestNormalizeChatIdempotent(t *testing.T) { + req := model.ChatRequest{ + Messages: []model.ChatMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "", ToolCalls: []model.ToolCall{ + {ID: "c1", Type: "function", Function: model.ToolFunction{Name: "f", Arguments: "{}"}}, + }}, + {Role: "tool", Name: "f", ToolCallID: "c1", Content: "ok"}, + }, + } + once := NormalizeChat(req, nil) + if len(once.Tools) != 0 || once.ToolChoice != nil { + t.Fatalf("unexpected tools: %v", once.Tools) + } + // 已满足不变量的消息切片应原样返回(不拷贝)。 + if &once.Messages[0] != &req.Messages[0] { + t.Fatal("normalized messages should not be copied when already conformant") + } + // 幂等:再跑一遍结果不变。 + twice := NormalizeChat(once, nil) + if len(twice.Messages) != len(once.Messages) || twice.Messages[2].Name != "f" { + t.Fatalf("normalize must be idempotent: %#v", twice.Messages) + } +} diff --git a/internal/provider/contract_matrix_test.go b/internal/provider/contract_matrix_test.go new file mode 100644 index 0000000..ed092c8 --- /dev/null +++ b/internal/provider/contract_matrix_test.go @@ -0,0 +1,49 @@ +package provider + +import ( + "context" + "errors" + "testing" + + "web2api/internal/model" +) + +// 契约矩阵(docs/web2api-responses-adapter-plan.md §4 阶段 3): +// 失败用量明细必须能显示引擎来源——Manager.Chat / ChatStream 出错时仍返回 +// 带 Engine 的 ChatResult,网关 recordChat 不再拿到 Engine 为空的记录。 +// 纯文本 / function tools / local_shell 入站被剥 / 400 降级分别在 +// gateway(responses_tools_test.go)、ir(ir_test.go)、upstream(openai_test.go)、 +// provider(engines_tools_test.go / antigravity_tools_test.go) 覆盖。 + +func TestManagerChatFailureCarriesEngine(t *testing.T) { + failing := &fakeEngine{name: "custom", ready: true, models: []string{"models/MiniMax-M2"}, chatErr: errors.New("上游返回 HTTP 400: bad tools")} + m := newTestManager(failing) + res, err := m.Chat(context.Background(), model.ChatRequest{Model: "MiniMax-M2"}) + if err == nil { + t.Fatal("want error") + } + if res == nil || res.Engine != "custom" { + t.Fatalf("failure result must carry engine, got %#v", res) + } +} + +func TestManagerChatStreamFailureCarriesEngine(t *testing.T) { + failing := &fakeEngine{name: "custom", ready: true, models: []string{"models/MiniMax-M2"}, streamErr: errors.New("上游返回 HTTP 429")} + m := newTestManager(failing) + res, err := m.ChatStream(context.Background(), model.ChatRequest{Model: "MiniMax-M2"}, model.TextSink(nil)) + if err == nil { + t.Fatal("want error") + } + if res == nil || res.Engine != "custom" { + t.Fatalf("failure result must carry engine, got %#v", res) + } +} + +func TestManagerChatModelNotFoundHasNoEngine(t *testing.T) { + // 「没有任何引擎声明该模型」发生在选引擎之前,没有引擎来源可记。 + m := newTestManager() + res, err := m.Chat(context.Background(), model.ChatRequest{Model: "nobody-declares-this"}) + if err == nil || res != nil { + t.Fatalf("model_not_found should return bare error, got res=%#v err=%v", res, err) + } +} diff --git a/internal/provider/engine_b.go b/internal/provider/engine_b.go deleted file mode 100644 index b639509..0000000 --- a/internal/provider/engine_b.go +++ /dev/null @@ -1,96 +0,0 @@ -package provider - -import ( - "context" - "errors" - "io" - "time" - - "web2api/internal/config" - "web2api/internal/engine" - "web2api/internal/engine/upstream" - "web2api/internal/model" -) - -// AIStudioEngine 引擎B:AI Studio(OpenAI 兼容上游,支持多模态透传)。 -type AIStudioEngine struct { - name string - up *upstream.OpenAI -} - -// NewAIStudio 从配置构建引擎B。 -func NewAIStudio(cfg config.EngineBConfig, timeout time.Duration) (*AIStudioEngine, error) { - if !cfg.Enabled { - return nil, nil - } - if cfg.BaseURL == "" { - return nil, errors.New("引擎B 未配置 base_url") - } - up := upstream.New("b", cfg.BaseURL, cfg.APIKey, cfg.Passthrough, timeout) - return &AIStudioEngine{name: "b", up: up}, nil -} - -// Init 探测引擎并拉取模型列表。 -func (b *AIStudioEngine) Init(ctx context.Context) error { - b.up.RefreshModels(ctx) - return nil -} - -// Name 引擎标识。 -func (b *AIStudioEngine) Name() string { return b.name } - -// Describe 引擎说明。 -func (b *AIStudioEngine) Describe() string { - return "aistudio.google.com(AI Studio,OpenAI 兼容上游,支持多模态)" -} - -// Ready 引擎可用。 -func (b *AIStudioEngine) Ready() bool { return b.up.Ready() } - -// ListModels 上游模型列表。 -func (b *AIStudioEngine) ListModels() []model.ModelInfo { - models := b.up.ListModels() - out := make([]model.ModelInfo, 0, len(models)) - for _, m := range models { - m.Engine = "b" - if m.Kind == "" { - m.Kind = model.ClassifyKind(m.ID, m.DisplayName, nil) - } - out = append(out, m) - } - return out -} - -// Chat 非流式。 -func (b *AIStudioEngine) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatResult, error) { - return b.up.Chat(ctx, req) -} - -// ChatStream 流式。 -func (b *AIStudioEngine) ChatStream(ctx context.Context, req model.ChatRequest, onDelta model.ChatStreamFunc) (*model.ChatResult, error) { - return b.up.ChatStream(ctx, req, onDelta) -} - -// ChatStreamSink 流式(支持工具调用增量)。 -func (b *AIStudioEngine) ChatStreamSink(ctx context.Context, req model.ChatRequest, sink model.StreamSink) (*model.ChatResult, error) { - return b.up.ChatStreamSink(ctx, req, sink) -} - -// Passthrough 是否透传多模态。 -func (b *AIStudioEngine) Passthrough() bool { return b.up.Passthrough() } - -// ServePassthrough 透传。 -func (b *AIStudioEngine) ServePassthrough(ctx context.Context, method, path string, body io.Reader, contentType string, w engine.PassthroughWriter) error { - return b.up.ServePassthrough(ctx, method, path, body, contentType, w) -} - -// Status 上游状态。 -func (b *AIStudioEngine) Status() map[string]any { - return map[string]any{ - "engine": "b", - "ready": b.Ready(), - "base_url": b.up.BaseURL(), - "passthrough": b.Passthrough(), - "error": b.up.ReadyErr(), - } -} diff --git a/internal/provider/engines_tools.go b/internal/provider/engines_tools.go index 4f5a7cf..d47134d 100644 --- a/internal/provider/engines_tools.go +++ b/internal/provider/engines_tools.go @@ -1,6 +1,12 @@ package provider -import "web2api/internal/engine" +import ( + "web2api/internal/engine" + "web2api/internal/engine/aistudio" + "web2api/internal/engine/antigravity" + "web2api/internal/engine/codex" + "web2api/internal/engine/custom" +) // 编译期断言:支持 function calling 的引擎必须实现 engine.ToolEngine。 // @@ -8,9 +14,9 @@ import "web2api/internal/engine" // 只会静默退化成「忽略 tools、仅回文本」——这种退化在人工测试里极难发现, // 所以在这里让编译期就拦住。 var ( - _ engine.ToolEngine = (*CustomEngine)(nil) - _ engine.ToolEngine = (*AIStudioEngine)(nil) - _ engine.ToolEngine = (*NativeAIStudioEngine)(nil) - _ engine.ToolEngine = (*CodexEngine)(nil) - _ engine.ToolEngine = (*AntigravityEngine)(nil) + _ engine.ToolEngine = (*custom.Engine)(nil) + _ engine.ToolEngine = (*aistudio.Engine)(nil) + _ engine.ToolEngine = (*aistudio.NativeEngine)(nil) + _ engine.ToolEngine = (*codex.Engine)(nil) + _ engine.ToolEngine = (*antigravity.Engine)(nil) ) diff --git a/internal/provider/errors.go b/internal/provider/errors.go index b3db794..9bf3151 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -1,87 +1,11 @@ -// errors.go 路由层的语义化错误:区分「没人声明这个模型」与「引擎挂了」。 +// errors.go 路由层语义化错误的兼容出口:实现已下沉到 engine 基础包 +// (见 internal/engine/errors.go),网关与管理台仍从 provider 引用。 package provider -import ( - "errors" - "fmt" - "strings" -) - -// ModelNotFoundError 表示没有任何引擎/账号声明过该模型。 -// 网关据此返回 404 + code=model_not_found,而不是把「路由不到」包装成 -// 上游 502——后者会让下游误以为是临时故障而反复重试。 -type ModelNotFoundError struct { - Model string // 请求的模型名(已去掉 models/ 前缀) - Detail string // 面向人的补充说明:查过哪些号池、为什么没命中 -} - -func (e *ModelNotFoundError) Error() string { - detail := strings.TrimSpace(e.Detail) - if detail == "" { - detail = fmt.Sprintf("当前号池没有引擎或账号声明模型 %q", e.Model) - } - return "model_not_found: " + detail -} - -// newModelNotFound 构造模型未声明错误。 -func newModelNotFound(model, detail string) error { - return &ModelNotFoundError{Model: strings.TrimPrefix(strings.TrimSpace(model), "models/"), Detail: detail} -} +import "web2api/internal/engine" // IsModelNotFound 判定错误是否为「无人声明该模型」。 -func IsModelNotFound(err error) bool { - var target *ModelNotFoundError - return errors.As(err, &target) -} - -// UnsupportedEndpointError 模型存在,但当前端点产不出这种类型的输出。 -// 例如把音乐模型发到 /v1/chat/completions:chat 只能返回文本,上游必然拒绝。 -// 网关据此返回 400,并告诉调用方该换哪个端点。 -type UnsupportedEndpointError struct { - Model string - Kind string - Detail string -} - -func (e *UnsupportedEndpointError) Error() string { - detail := strings.TrimSpace(e.Detail) - if detail == "" { - detail = fmt.Sprintf("模型 %q 是%s模型,当前端点无法返回该类型的输出", e.Model, kindLabel(e.Kind)) - } - return "unsupported_endpoint: " + detail -} +func IsModelNotFound(err error) bool { return engine.IsModelNotFound(err) } // IsUnsupportedEndpoint 判定错误是否为「端点与模型用途不匹配」。 -func IsUnsupportedEndpoint(err error) bool { - var target *UnsupportedEndpointError - return errors.As(err, &target) -} - -// newUnsupportedEndpoint 构造「该换端点」的错误,文案里直接给出正确入口。 -func newUnsupportedEndpoint(model, kind string) error { - name := strings.TrimPrefix(strings.TrimSpace(model), "models/") - return &UnsupportedEndpointError{ - Model: name, - Kind: kind, - Detail: fmt.Sprintf("模型 %q 是%s模型,/v1/chat/completions 只能返回文本,上游会直接拒绝(如 Unhandled generated data mime type: audio/mpeg)。请改用 %s;或启用引擎B 原生模式(它可以在 chat 响应里直接带回媒体)", - name, kindLabel(kind), endpointForKind(kind)), - } -} - -// endpointForKind 该用途应使用的端点。 -func endpointForKind(kind string) string { - switch kind { - case "music": - return "POST /v1/music/generations" - case "video": - return "POST /v1/videos" - case "embedding": - return "POST /v1/embeddings" - case "image": - return "POST /v1/images/generations" - case "speech": - return "POST /v1/audio/speech" - default: - return "POST /v1/chat/completions" - } -} +func IsUnsupportedEndpoint(err error) bool { return engine.IsUnsupportedEndpoint(err) } diff --git a/internal/provider/manager.go b/internal/provider/manager.go index 5c3fb28..8be9773 100644 --- a/internal/provider/manager.go +++ b/internal/provider/manager.go @@ -6,18 +6,21 @@ import ( "errors" "fmt" "io" - "reflect" - "strconv" "strings" "sync" "time" - aistudio "web2api/internal/aistudio" "web2api/internal/config" "web2api/internal/engine" + aistudio "web2api/internal/engine/aistudio" + "web2api/internal/engine/antigravity" + "web2api/internal/engine/codex" + "web2api/internal/engine/custom" + "web2api/internal/engine/geminiweb" + "web2api/internal/engine/kiro" + "web2api/internal/engine/workbuddy" "web2api/internal/model" "web2api/internal/store" - "web2api/internal/upstream/workbuddy" ) // Manager 引擎注册表、模型路由与号池动态管理。 @@ -28,7 +31,7 @@ type Manager struct { cfg *config.Config // 引擎A/B 为历史双引擎,保留专用字段以兼容 Veo 等能力接口。 - engineA *GeminiWebEngine + engineA *geminiweb.Engine engineB initializableEngine // 全部引擎(含 a/b)的注册表与顺序。 @@ -38,8 +41,8 @@ type Manager struct { pools map[string]accountPool defaultEngine string - engineAModels []string - engineBModels []string + geminiWebModels []string + aiStudioModels []string // 新引擎(codex/antigravity/kiro/workbuddy)的模型路由配置。 engineModels map[string][]string } @@ -48,7 +51,7 @@ type Manager struct { type accountPool interface { RemoveAccount(engineID string) error SetEnabled(engineID string, enabled bool) error - AccountStates() []AccountState + AccountStates() []engine.AccountState } func (m *Manager) GeminiAPIConfig() config.GeminiAPIConfig { return m.cfg.GeminiAPI } @@ -106,21 +109,7 @@ type initializableEngine interface { // AccountOverview 管理台账号视图:存储记录 + 引擎实时状态。 type AccountOverview struct { store.Account - Live AccountState `json:"live"` -} - -// GeminiCredentials 引擎A 凭据结构。 -type GeminiCredentials struct { - PSID string `json:"psid"` - PSIDTS string `json:"psidts"` -} - -// AIStudioCredentials 引擎B 凭据结构。 -type AIStudioCredentials struct { - StorageState json.RawMessage `json:"storage_state"` - Locale string `json:"locale,omitempty"` - Timezone string `json:"timezone,omitempty"` - Proxy string `json:"proxy,omitempty"` + Live engine.AccountState `json:"live"` } // AccountInput 是统一账号添加协议。引擎A/B 沿用 storage-state JSON; @@ -142,8 +131,8 @@ func NewManager(cfg *config.Config, st *store.Store) (*Manager, error) { st: st, cfg: cfg, defaultEngine: strings.ToLower(cfg.Routing.DefaultEngine), - engineAModels: cfg.Routing.EngineAModels, - engineBModels: cfg.Routing.EngineBModels, + geminiWebModels: cfg.Routing.GeminiWebModels, + aiStudioModels: cfg.Routing.AIStudioModels, engineModels: cfg.Routing.EngineModels, engines: map[string]engine.Engine{}, pools: map[string]accountPool{}, @@ -152,29 +141,29 @@ func NewManager(cfg *config.Config, st *store.Store) (*Manager, error) { m.defaultEngine = "auto" } - if cfg.EngineA.Enabled { - ea, err := NewGeminiWeb(cfg.EngineA) + if cfg.GeminiWeb.Enabled { + ea, err := geminiweb.New(cfg.GeminiWeb) if err != nil { return nil, fmt.Errorf("引擎A 初始化失败: %w", err) } m.engineA = ea ea.SetStore(st) - m.register("a", ea, ea) + m.register("geminiweb", ea, ea) } - if cfg.EngineB.Enabled { - mode := strings.ToLower(strings.TrimSpace(cfg.EngineB.Mode)) + if cfg.AIStudio.Enabled { + mode := strings.ToLower(strings.TrimSpace(cfg.AIStudio.Mode)) if mode == "" { mode = "native" } var eb initializableEngine if mode == "native" { - native, err := NewNativeAIStudio(cfg.EngineB) + native, err := aistudio.NewNative(cfg.AIStudio) if err != nil { return nil, err } eb = native } else { - upstreamEngine, err := NewAIStudio(cfg.EngineB, 300*time.Second) + upstreamEngine, err := aistudio.New(cfg.AIStudio, 300*time.Second) if err != nil { return nil, err } @@ -182,32 +171,32 @@ func NewManager(cfg *config.Config, st *store.Store) (*Manager, error) { } if eb != nil { m.engineB = eb - m.register("b", eb, nil) + m.register("aistudio", eb, nil) // native 模式的账号池能力在具体实例上 - if native, ok := eb.(*NativeAIStudioEngine); ok && native != nil { - m.pools["b"] = native + if native, ok := eb.(*aistudio.NativeEngine); ok && native != nil { + m.pools["aistudio"] = native } } } // 订阅引擎:codex / antigravity / kiro / workbuddy if cfg.Engines.Codex.Enabled { - ec := NewCodex(cfg.Engines.Codex, st) + ec := codex.New(cfg.Engines.Codex, st) m.register(ec.Name(), ec, ec) } if cfg.Engines.Antigravity.Enabled { - ea := NewAntigravity(cfg.Engines.Antigravity, st) + ea := antigravity.New(cfg.Engines.Antigravity, st) m.register(ea.Name(), ea, ea) } if cfg.Engines.Kiro.Enabled { - ek := NewKiro(cfg.Engines.Kiro, st) + ek := kiro.New(cfg.Engines.Kiro, st) m.register(ek.Name(), ek, ek) } if cfg.Engines.WorkBuddy.Enabled { - eb := NewWorkBuddy(cfg.Engines.WorkBuddy, st) + eb := workbuddy.New(cfg.Engines.WorkBuddy, st) m.register(eb.Name(), eb, eb) } if cfg.Engines.Custom.Enabled { - ec := NewCustom(cfg.Engines.Custom, st) + ec := custom.New(cfg.Engines.Custom, st) m.register(ec.Name(), ec, ec) } if m.engineA == nil && m.engineB == nil && !cfg.GeminiAPI.Enabled && len(m.engines) == 0 { @@ -243,12 +232,12 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { // 首次启动:把 config 里的静态账号迁移进存储(作为种子) if len(accounts) == 0 { - for _, acc := range m.cfg.EngineA.Accounts { + for _, acc := range m.cfg.GeminiWeb.Accounts { if strings.TrimSpace(acc.PSID) == "" { continue } - creds, _ := json.Marshal(GeminiCredentials{PSID: acc.PSID, PSIDTS: acc.PSIDTS}) - if _, err := m.st.CreateAccount("a", seedLabel("a", acc.Name), string(creds), true); err != nil { + creds, _ := json.Marshal(geminiweb.Credentials{PSID: acc.PSID, PSIDTS: acc.PSIDTS}) + if _, err := m.st.CreateAccount("geminiweb", seedLabel("geminiweb", acc.Name), string(creds), true); err != nil { continue } } @@ -257,35 +246,35 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { if strings.TrimSpace(acc.RefreshToken) == "" { continue } - creds := marshalCompact(CodexCredentials{RefreshToken: acc.RefreshToken, Email: acc.Email}) + creds := engine.MarshalCompact(codex.Credentials{RefreshToken: acc.RefreshToken, Email: acc.Email}) _, _ = m.st.CreateAccount("codex", seedLabel("codex", firstNonEmpty(acc.Name, acc.Email)), creds, true) } for _, acc := range m.cfg.Engines.Antigravity.Accounts { if strings.TrimSpace(acc.RefreshToken) == "" { continue } - creds := marshalCompact(AntigravityCredentials{RefreshToken: acc.RefreshToken, Email: acc.Email, ProjectID: acc.ProjectID}) + creds := engine.MarshalCompact(antigravity.Credentials{RefreshToken: acc.RefreshToken, Email: acc.Email, ProjectID: acc.ProjectID}) _, _ = m.st.CreateAccount("antigravity", seedLabel("antigravity", firstNonEmpty(acc.Name, acc.Email)), creds, true) } for _, acc := range m.cfg.Engines.Kiro.Accounts { if strings.TrimSpace(acc.RefreshToken) == "" { continue } - creds := marshalCompact(KiroCredentials{RefreshToken: acc.RefreshToken, Email: acc.Email}) + creds := engine.MarshalCompact(kiro.Credentials{RefreshToken: acc.RefreshToken, Email: acc.Email}) _, _ = m.st.CreateAccount("kiro", seedLabel("kiro", firstNonEmpty(acc.Name, acc.Email)), creds, true) } for _, acc := range m.cfg.Engines.WorkBuddy.Accounts { if strings.TrimSpace(acc.Token) == "" { continue } - creds := marshalCompact(WorkBuddyCredentials{Token: acc.Token, Email: acc.Email}) + creds := engine.MarshalCompact(workbuddy.Credentials{Token: acc.Token, Email: acc.Email}) _, _ = m.st.CreateAccount("workbuddy", seedLabel("workbuddy", firstNonEmpty(acc.Name, acc.Email)), creds, true) } for _, acc := range m.cfg.Engines.Custom.Accounts { if strings.TrimSpace(acc.BaseURL) == "" || strings.TrimSpace(acc.APIKey) == "" { continue } - creds := marshalCompact(CustomCredentials{BaseURL: acc.BaseURL, APIKey: acc.APIKey, Models: acc.Models}) + creds := engine.MarshalCompact(custom.Credentials{BaseURL: acc.BaseURL, APIKey: acc.APIKey, Models: acc.Models}) _, _ = m.st.CreateAccount("custom", seedLabel("custom", firstNonEmpty(acc.Name, acc.BaseURL)), creds, true) } accounts, err = m.st.ListAccounts() @@ -296,11 +285,11 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { for _, acc := range accounts { switch acc.Engine { - case "a": + case "a", "geminiweb": if m.engineA == nil { continue } - var creds GeminiCredentials + var creds geminiweb.Credentials if err := json.Unmarshal([]byte(acc.Credentials), &creds); err != nil { continue } @@ -308,12 +297,12 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { if !acc.Enabled { _ = m.engineA.SetEnabled(fmt.Sprintf("%d", acc.ID), false) } - case "b": - native, ok := m.engineB.(*NativeAIStudioEngine) + case "b", "aistudio": + native, ok := m.engineB.(*aistudio.NativeEngine) if !ok || native == nil { continue } - var creds AIStudioCredentials + var creds aistudio.Credentials if err := json.Unmarshal([]byte(acc.Credentials), &creds); err != nil { continue } @@ -332,12 +321,12 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { } // 反向导入:auth 目录已存在但存储没有的账号(已有的 auth 目录直接复用) - if native, ok := m.engineB.(*NativeAIStudioEngine); ok && native != nil { + if native, ok := m.engineB.(*aistudio.NativeEngine); ok && native != nil { for _, poolID := range native.PoolAccountIDs() { - if _, err := m.st.GetAccountByLabel("b", poolID); err == nil { + if _, err := m.st.GetAccountByLabel("aistudio", poolID); err == nil { continue } - if _, err := m.st.CreateAccount("b", poolID, "{}", true); err == nil { + if _, err := m.st.CreateAccount("aistudio", poolID, "{}", true); err == nil { native.RegisterAccountID(fmt.Sprintf("%d", lastAccountID(m.st)), poolID) } } @@ -348,7 +337,7 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { switch acc.Engine { case "codex": if eng := m.codexEngine(); eng != nil { - var creds CodexCredentials + var creds codex.Credentials if json.Unmarshal([]byte(acc.Credentials), &creds) == nil && creds.RefreshToken != "" { _ = eng.AddAccount(fmt.Sprintf("%d", acc.ID), acc.Label, creds) if !acc.Enabled { @@ -358,7 +347,7 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { } case "antigravity": if eng := m.antigravityEngine(); eng != nil { - var creds AntigravityCredentials + var creds antigravity.Credentials if json.Unmarshal([]byte(acc.Credentials), &creds) == nil && creds.RefreshToken != "" { _ = eng.AddAccount(fmt.Sprintf("%d", acc.ID), acc.Label, creds) if !acc.Enabled { @@ -368,7 +357,7 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { } case "kiro": if eng := m.kiroEngine(); eng != nil { - var creds KiroCredentials + var creds kiro.Credentials if json.Unmarshal([]byte(acc.Credentials), &creds) == nil && creds.RefreshToken != "" { _ = eng.AddAccount(fmt.Sprintf("%d", acc.ID), acc.Label, creds) if !acc.Enabled { @@ -378,7 +367,7 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { } case "workbuddy": if eng := m.mWorkBuddyEngine(); eng != nil { - var creds WorkBuddyCredentials + var creds workbuddy.Credentials if json.Unmarshal([]byte(acc.Credentials), &creds) == nil && creds.Token != "" { _ = eng.AddAccount(fmt.Sprintf("%d", acc.ID), acc.Label, creds) if !acc.Enabled { @@ -388,7 +377,7 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { } case "custom": if eng := m.customEngine(); eng != nil { - var creds CustomCredentials + var creds custom.Credentials if json.Unmarshal([]byte(acc.Credentials), &creds) == nil && creds.BaseURL != "" && creds.APIKey != "" { _ = eng.AddAccount(fmt.Sprintf("%d", acc.ID), acc.Label, creds) if !acc.Enabled { @@ -402,52 +391,52 @@ func (m *Manager) bootstrapAccounts(ctx context.Context) error { } // codexEngine / antigravityEngine / kiroEngine / mWorkBuddyEngine 返回订阅引擎实例。 -func (m *Manager) codexEngine() *CodexEngine { +func (m *Manager) codexEngine() *codex.Engine { if eng, ok := m.engines["codex"]; ok { - if c, ok := eng.(*CodexEngine); ok { + if c, ok := eng.(*codex.Engine); ok { return c } } return nil } -func (m *Manager) antigravityEngine() *AntigravityEngine { +func (m *Manager) antigravityEngine() *antigravity.Engine { if eng, ok := m.engines["antigravity"]; ok { - if a, ok := eng.(*AntigravityEngine); ok { + if a, ok := eng.(*antigravity.Engine); ok { return a } } return nil } -func (m *Manager) kiroEngine() *KiroEngine { +func (m *Manager) kiroEngine() *kiro.Engine { if eng, ok := m.engines["kiro"]; ok { - if k, ok := eng.(*KiroEngine); ok { + if k, ok := eng.(*kiro.Engine); ok { return k } } return nil } -func (m *Manager) mWorkBuddyEngine() *WorkBuddyEngine { +func (m *Manager) mWorkBuddyEngine() *workbuddy.Engine { if eng, ok := m.engines["workbuddy"]; ok { - if w, ok := eng.(*WorkBuddyEngine); ok { + if w, ok := eng.(*workbuddy.Engine); ok { return w } } return nil } -func (m *Manager) customEngine() *CustomEngine { +func (m *Manager) customEngine() *custom.Engine { if eng, ok := m.engines["custom"]; ok { - if c, ok := eng.(*CustomEngine); ok { + if c, ok := eng.(*custom.Engine); ok { return c } } return nil } -func poolHas(native *NativeAIStudioEngine, accountID string) bool { +func poolHas(native *aistudio.NativeEngine, accountID string) bool { for _, id := range native.PoolAccountIDs() { if strings.EqualFold(id, accountID) { return true @@ -482,14 +471,14 @@ func firstNonEmpty(vals ...string) string { } // EngineA / EngineB 返回引擎(未启用时返回 nil 接口)。 -func (m *Manager) EngineA() engine.Engine { +func (m *Manager) GeminiWeb() engine.Engine { if m.engineA == nil { return nil } return m.engineA } -func (m *Manager) EngineB() engine.Engine { +func (m *Manager) AIStudio() engine.Engine { if m.engineB == nil { return nil } @@ -527,10 +516,10 @@ func (m *Manager) EngineReadyMap() map[string]bool { // EngineProxy 返回引擎的全局代理配置(账号级代理优先级在引擎内部处理)。 func (m *Manager) EngineProxy(name string) string { switch name { - case "a": - return m.cfg.EngineA.Proxy - case "b": - return m.cfg.EngineB.Proxy + case "a", "geminiweb": + return m.cfg.GeminiWeb.Proxy + case "b", "aistudio": + return m.cfg.AIStudio.Proxy case "codex": return m.cfg.Engines.Codex.Proxy case "antigravity": @@ -610,16 +599,16 @@ func TakeOAuthSession(engine, sessionID string) (*OAuthPendingSession, error) { func (m *Manager) AddAccountFromStorageState(req AccountInput) (store.Account, error) { engineName := strings.ToLower(strings.TrimSpace(req.Engine)) if engineName == "" { - engineName = "b" + engineName = "aistudio" } switch engineName { - case "a", "gemini", "b", "aistudio": + case "a", "gemini", "geminiweb", "b", "aistudio": if strings.TrimSpace(req.StorageState) == "" { return store.Account{}, errors.New("storage_state 不能为空") } } switch engineName { - case "a", "gemini": + case "a", "gemini", "geminiweb": label := strings.TrimSpace(req.Label) if label == "" { label = strings.TrimSpace(req.Email) @@ -638,7 +627,7 @@ func (m *Manager) AddAccountFromStorageState(req AccountInput) (store.Account, e } return m.addAIStudioAccount(req.Email, req.StorageState, req.Locale, req.Timezone, req.Proxy) case "codex": - var creds CodexCredentials + var creds codex.Credentials if err := decodeEngineCredentials(req, &creds); err != nil { return store.Account{}, err } @@ -657,7 +646,7 @@ func (m *Manager) AddAccountFromStorageState(req AccountInput) (store.Account, e } return m.addCodexAccount(label, creds) case "antigravity": - var creds AntigravityCredentials + var creds antigravity.Credentials if err := decodeEngineCredentials(req, &creds); err != nil { return store.Account{}, err } @@ -676,7 +665,7 @@ func (m *Manager) AddAccountFromStorageState(req AccountInput) (store.Account, e } return m.addAntigravityAccount(label, creds) case "kiro": - var creds KiroCredentials + var creds kiro.Credentials if err := decodeEngineCredentials(req, &creds); err != nil { return store.Account{}, err } @@ -695,7 +684,7 @@ func (m *Manager) AddAccountFromStorageState(req AccountInput) (store.Account, e } return m.addKiroAccount(label, creds) case "workbuddy": - var creds WorkBuddyCredentials + var creds workbuddy.Credentials if err := decodeEngineCredentials(req, &creds); err != nil { return store.Account{}, err } @@ -714,7 +703,7 @@ func (m *Manager) AddAccountFromStorageState(req AccountInput) (store.Account, e } return m.addWorkBuddyAccount(label, creds) case "custom": - var creds CustomCredentials + var creds custom.Credentials if err := decodeEngineCredentials(req, &creds); err != nil { return store.Account{}, err } @@ -752,7 +741,7 @@ func decodeEngineCredentials(req AccountInput, out any) error { } // addCodexAccount 添加 codex 账号(存储 + 入池)。 -func (m *Manager) addCodexAccount(label string, creds CodexCredentials) (store.Account, error) { +func (m *Manager) addCodexAccount(label string, creds codex.Credentials) (store.Account, error) { eng := m.codexEngine() if eng == nil { return store.Account{}, errors.New("codex 引擎未启用") @@ -760,7 +749,7 @@ func (m *Manager) addCodexAccount(label string, creds CodexCredentials) (store.A if label == "" { return store.Account{}, errors.New("label 或 email 至少填写一个") } - acc, err := m.st.CreateAccount("codex", label, marshalCompact(creds), true) + acc, err := m.st.CreateAccount("codex", label, engine.MarshalCompact(creds), true) if err != nil { return store.Account{}, err } @@ -772,7 +761,7 @@ func (m *Manager) addCodexAccount(label string, creds CodexCredentials) (store.A } // addAntigravityAccount 添加 antigravity 账号(存储 + 入池)。 -func (m *Manager) addAntigravityAccount(label string, creds AntigravityCredentials) (store.Account, error) { +func (m *Manager) addAntigravityAccount(label string, creds antigravity.Credentials) (store.Account, error) { eng := m.antigravityEngine() if eng == nil { return store.Account{}, errors.New("antigravity 引擎未启用") @@ -780,7 +769,7 @@ func (m *Manager) addAntigravityAccount(label string, creds AntigravityCredentia if label == "" { return store.Account{}, errors.New("label 或 email 至少填写一个") } - acc, err := m.st.CreateAccount("antigravity", label, marshalCompact(creds), true) + acc, err := m.st.CreateAccount("antigravity", label, engine.MarshalCompact(creds), true) if err != nil { return store.Account{}, err } @@ -792,7 +781,7 @@ func (m *Manager) addAntigravityAccount(label string, creds AntigravityCredentia } // addKiroAccount 添加 kiro 账号(存储 + 入池)。 -func (m *Manager) addKiroAccount(label string, creds KiroCredentials) (store.Account, error) { +func (m *Manager) addKiroAccount(label string, creds kiro.Credentials) (store.Account, error) { eng := m.kiroEngine() if eng == nil { return store.Account{}, errors.New("kiro 引擎未启用") @@ -800,7 +789,7 @@ func (m *Manager) addKiroAccount(label string, creds KiroCredentials) (store.Acc if label == "" { return store.Account{}, errors.New("label 或 email 至少填写一个") } - acc, err := m.st.CreateAccount("kiro", label, marshalCompact(creds), true) + acc, err := m.st.CreateAccount("kiro", label, engine.MarshalCompact(creds), true) if err != nil { return store.Account{}, err } @@ -812,7 +801,7 @@ func (m *Manager) addKiroAccount(label string, creds KiroCredentials) (store.Acc } // addWorkBuddyAccount 添加 workbuddy 账号(存储 + 入池)。 -func (m *Manager) addWorkBuddyAccount(label string, creds WorkBuddyCredentials) (store.Account, error) { +func (m *Manager) addWorkBuddyAccount(label string, creds workbuddy.Credentials) (store.Account, error) { eng := m.mWorkBuddyEngine() if eng == nil { return store.Account{}, errors.New("workbuddy 引擎未启用") @@ -820,7 +809,7 @@ func (m *Manager) addWorkBuddyAccount(label string, creds WorkBuddyCredentials) if label == "" { return store.Account{}, errors.New("label 或 email 至少填写一个") } - acc, err := m.st.CreateAccount("workbuddy", label, marshalCompact(creds), true) + acc, err := m.st.CreateAccount("workbuddy", label, engine.MarshalCompact(creds), true) if err != nil { return store.Account{}, err } @@ -832,7 +821,7 @@ func (m *Manager) addWorkBuddyAccount(label string, creds WorkBuddyCredentials) } // addCustomAccount 添加第三方 Key 账号(存储 + 入池)。 -func (m *Manager) addCustomAccount(label string, creds CustomCredentials) (store.Account, error) { +func (m *Manager) addCustomAccount(label string, creds custom.Credentials) (store.Account, error) { eng := m.customEngine() if eng == nil { return store.Account{}, errors.New("custom 引擎未启用(engines.custom.enabled)") @@ -840,7 +829,7 @@ func (m *Manager) addCustomAccount(label string, creds CustomCredentials) (store if label == "" { return store.Account{}, errors.New("label 不能为空") } - acc, err := m.st.CreateAccount("custom", label, marshalCompact(creds), true) + acc, err := m.st.CreateAccount("custom", label, engine.MarshalCompact(creds), true) if err != nil { return store.Account{}, err } @@ -859,7 +848,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe } switch acc.Engine { case "codex": - var creds CodexCredentials + var creds codex.Credentials if err := json.Unmarshal(credentials, &creds); err != nil || creds.RefreshToken == "" { return errors.New("credentials.refresh_token 不能为空") } @@ -868,7 +857,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe } return m.codexEngine().ReplaceCredentials(fmt.Sprintf("%d", id), creds) case "antigravity": - var creds AntigravityCredentials + var creds antigravity.Credentials if err := json.Unmarshal(credentials, &creds); err != nil || creds.RefreshToken == "" { return errors.New("credentials.refresh_token 不能为空") } @@ -877,7 +866,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe } return m.antigravityEngine().ReplaceCredentials(fmt.Sprintf("%d", id), creds) case "kiro": - var creds KiroCredentials + var creds kiro.Credentials if err := json.Unmarshal(credentials, &creds); err != nil || creds.RefreshToken == "" { return errors.New("credentials.refresh_token 不能为空") } @@ -886,7 +875,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe } return m.kiroEngine().ReplaceCredentials(fmt.Sprintf("%d", id), creds) case "workbuddy": - var creds WorkBuddyCredentials + var creds workbuddy.Credentials if err := json.Unmarshal(credentials, &creds); err != nil || creds.Token == "" { return errors.New("credentials.token 不能为空") } @@ -895,7 +884,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe } return m.mWorkBuddyEngine().ReplaceCredentials(fmt.Sprintf("%d", id), creds) case "custom": - var creds CustomCredentials + var creds custom.Credentials if err := json.Unmarshal(credentials, &creds); err != nil { return errors.New("credentials 不是合法 JSON") } @@ -905,7 +894,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe if strings.TrimSpace(creds.APIKey) == "" { return errors.New("credentials.api_key 不能为空") } - if err := m.st.UpdateAccountCredentials(id, marshalCompact(creds)); err != nil { + if err := m.st.UpdateAccountCredentials(id, engine.MarshalCompact(creds)); err != nil { return err } return m.customEngine().ReplaceCredentials(fmt.Sprintf("%d", id), creds) @@ -915,7 +904,7 @@ func (m *Manager) UpdateSubscriptionCredentials(id int64, credentials json.RawMe // geminiCredentialsFromStorageState 从引擎B协议的标准 Cookie 数组中提取 // Gemini 网页协议需要的两个会话 Cookie。 -func geminiCredentialsFromStorageState(raw string) (GeminiCredentials, error) { +func geminiCredentialsFromStorageState(raw string) (geminiweb.Credentials, error) { var state struct { Cookies []struct { Name string `json:"name"` @@ -923,9 +912,9 @@ func geminiCredentialsFromStorageState(raw string) (GeminiCredentials, error) { } `json:"cookies"` } if err := json.Unmarshal([]byte(raw), &state); err != nil { - return GeminiCredentials{}, fmt.Errorf("storage-state 不是合法 JSON: %w", err) + return geminiweb.Credentials{}, fmt.Errorf("storage-state 不是合法 JSON: %w", err) } - var creds GeminiCredentials + var creds geminiweb.Credentials for _, cookie := range state.Cookies { switch strings.TrimSpace(cookie.Name) { case "__Secure-1PSID": @@ -935,7 +924,7 @@ func geminiCredentialsFromStorageState(raw string) (GeminiCredentials, error) { } } if creds.PSID == "" || creds.PSIDTS == "" { - return GeminiCredentials{}, errors.New("storage-state 缺少 __Secure-1PSID 或 __Secure-1PSIDTS Cookie") + return geminiweb.Credentials{}, errors.New("storage-state 缺少 __Secure-1PSID 或 __Secure-1PSIDTS Cookie") } return creds, nil } @@ -944,8 +933,8 @@ func (m *Manager) addGeminiCredentials(label, psid, psidts string) (store.Accoun if m.engineA == nil { return store.Account{}, errors.New("引擎A 未启用") } - creds, _ := json.Marshal(GeminiCredentials{PSID: psid, PSIDTS: psidts}) - acc, err := m.st.CreateAccount("a", label, string(creds), true) + creds, _ := json.Marshal(geminiweb.Credentials{PSID: psid, PSIDTS: psidts}) + acc, err := m.st.CreateAccount("geminiweb", label, string(creds), true) if err != nil { return store.Account{}, err } @@ -962,19 +951,19 @@ func (m *Manager) AddGeminiAccount(label, psid, psidts string) (store.Account, e {"name": "__Secure-1PSID", "value": psid}, {"name": "__Secure-1PSIDTS", "value": psidts}, }}) - return m.AddAccountFromStorageState(AccountInput{Engine: "a", Label: label, StorageState: string(state)}) + return m.AddAccountFromStorageState(AccountInput{Engine: "geminiweb", Label: label, StorageState: string(state)}) } // AddAIStudioAccount 添加引擎B 账号(AI Studio 号)。 func (m *Manager) AddAIStudioAccount(email, storageJSON, locale, timezone, proxy string) (store.Account, error) { return m.AddAccountFromStorageState(AccountInput{ - Engine: "b", Email: email, StorageState: storageJSON, + Engine: "aistudio", Email: email, StorageState: storageJSON, Locale: locale, Timezone: timezone, Proxy: proxy, }) } func (m *Manager) addAIStudioAccount(email, storageJSON, locale, timezone, proxy string) (store.Account, error) { - native, ok := m.engineB.(*NativeAIStudioEngine) + native, ok := m.engineB.(*aistudio.NativeEngine) if !ok || native == nil { return store.Account{}, errors.New("引擎B 原生模式未启用") } @@ -982,10 +971,10 @@ func (m *Manager) addAIStudioAccount(email, storageJSON, locale, timezone, proxy if err := json.Unmarshal([]byte(storageJSON), &state); err != nil { return store.Account{}, fmt.Errorf("storage-state 不是合法 JSON: %w", err) } - creds, _ := json.Marshal(AIStudioCredentials{ + creds, _ := json.Marshal(aistudio.Credentials{ StorageState: state, Locale: locale, Timezone: timezone, Proxy: proxy, }) - acc, err := m.st.CreateAccount("b", email, string(creds), true) + acc, err := m.st.CreateAccount("aistudio", email, string(creds), true) if err != nil { return store.Account{}, err } @@ -1001,7 +990,7 @@ func (m *Manager) UpdateGeminiCredentials(id int64, psid, psidts string) error { if m.engineA == nil { return errors.New("引擎A 未启用") } - creds, _ := json.Marshal(GeminiCredentials{PSID: psid, PSIDTS: psidts}) + creds, _ := json.Marshal(geminiweb.Credentials{PSID: psid, PSIDTS: psidts}) if err := m.st.UpdateAccountCredentials(id, string(creds)); err != nil { return err } @@ -1052,11 +1041,11 @@ func (m *Manager) CheckAccount(ctx context.Context, id int64) error { } engineID := fmt.Sprintf("%d", id) switch acc.Engine { - case "a": + case "a", "geminiweb": if m.engineA == nil { return errors.New("引擎A 未启用") } - var creds GeminiCredentials + var creds geminiweb.Credentials if err := json.Unmarshal([]byte(acc.Credentials), &creds); err != nil { return err } @@ -1065,8 +1054,8 @@ func (m *Manager) CheckAccount(ctx context.Context, id int64) error { } _ = m.st.UpdateAccountStatus(id, "initializing", "重新初始化中") return nil - case "b": - native, ok := m.engineB.(*NativeAIStudioEngine) + case "b", "aistudio": + native, ok := m.engineB.(*aistudio.NativeEngine) if !ok || native == nil { return errors.New("引擎B 原生模式未启用") } @@ -1093,9 +1082,9 @@ func (m *Manager) AccountOverview() []AccountOverview { return nil } // 收集每个引擎的实时账号状态 - live := map[string]map[string]AccountState{} + live := map[string]map[string]engine.AccountState{} for name, pool := range m.pools { - states := map[string]AccountState{} + states := map[string]engine.AccountState{} for _, st := range pool.AccountStates() { states[st.ID] = st } @@ -1109,11 +1098,11 @@ func (m *Manager) AccountOverview() []AccountOverview { if st, ok := states[engineID]; ok { ov.Live = st } else { - ov.Live = AccountState{ID: engineID, Label: acc.Label, Enabled: acc.Enabled, Status: "unloaded"} + ov.Live = engine.AccountState{ID: engineID, Label: acc.Label, Enabled: acc.Enabled, Status: "unloaded"} } } else { // 引擎未启用(无池) - ov.Live = AccountState{ID: engineID, Label: acc.Label, Enabled: acc.Enabled, Status: "unloaded", Detail: "引擎未启用"} + ov.Live = engine.AccountState{ID: engineID, Label: acc.Label, Enabled: acc.Enabled, Status: "unloaded", Detail: "引擎未启用"} } out = append(out, ov) } @@ -1131,13 +1120,13 @@ func (m *Manager) pickEngine(modelName string) (engine.Engine, bool) { // 1) 显式模型路由:engine_a/b_models + routing.engine_models. // 配置命中即为「专属」:写了就是点名要这个引擎,它挂了就报它的真实错误, // 不再被 alternate 带去别的引擎(换过去只会拿到假 404 或错模型)。 - if len(m.engineAModels) > 0 && matchAny(name, m.engineAModels) { - if eng, ok := m.engines["a"]; ok { + if len(m.geminiWebModels) > 0 && matchAny(name, m.geminiWebModels) { + if eng, ok := m.engines["geminiweb"]; ok { return eng, true } } - if len(m.engineBModels) > 0 && matchAny(name, m.engineBModels) { - if eng, ok := m.engines["b"]; ok { + if len(m.aiStudioModels) > 0 && matchAny(name, m.aiStudioModels) { + if eng, ok := m.engines["aistudio"]; ok { return eng, true } } @@ -1162,7 +1151,7 @@ func (m *Manager) pickEngine(modelName string) (engine.Engine, bool) { // 交给引擎B 并标为专属,引擎B 不可用时直接报引擎B 的真实错误, // 绝不再走 default/auto 兜底改投第三方 Key(lyria 打到 MiniMax 的根因)。 if isMultimodalModel(name) { - if eng, ok := m.engines["b"]; ok { + if eng, ok := m.engines["aistudio"]; ok { return eng, true } // 引擎B 未启用:这类模型没有任何承接方,交给上层返回 model_not_found。 @@ -1180,10 +1169,10 @@ func (m *Manager) pickEngine(modelName string) (engine.Engine, bool) { } // 6) auto 回退:引擎B协议是主协议,其次引擎A,最后任意 Ready 引擎 - if eng, ok := m.engines["b"]; ok { + if eng, ok := m.engines["aistudio"]; ok { return eng, false } - if eng, ok := m.engines["a"]; ok { + if eng, ok := m.engines["geminiweb"]; ok { return eng, false } for _, engName := range m.engineOrder { @@ -1213,7 +1202,7 @@ func (m *Manager) matchConfiguredModels(name string) (engine.Engine, bool) { // matchSubscriptionCatalog 按模型 id 精确命中订阅/custom 引擎目录。 func (m *Manager) matchSubscriptionCatalog(modelName string) (engine.Engine, bool) { for _, engName := range m.engineOrder { - if engName == "a" || engName == "b" { + if engName == "geminiweb" || engName == "aistudio" { continue } eng, ok := m.engines[engName] @@ -1238,7 +1227,7 @@ func (m *Manager) matchSubscriptionCatalog(modelName string) (engine.Engine, boo // 具体错误,而不是切换引擎后产生误导性错误。 func (m *Manager) matchCoreCatalog(modelName string) (engine.Engine, bool) { for _, engName := range m.engineOrder { - if engName != "a" && engName != "b" { + if engName != "geminiweb" && engName != "aistudio" { continue } eng, ok := m.engines[engName] @@ -1367,12 +1356,10 @@ func (m *Manager) probeChatModel(ctx context.Context, modelID string) (string, e func (m *Manager) probeImageModel(ctx context.Context, modelID string) (string, error) { eng, _ := m.pickEngine(modelID) switch e := eng.(type) { - case *CustomEngine: + case *custom.Engine: + return e.ProbeImage(ctx, modelID) + case *aistudio.Engine: return e.ProbeImage(ctx, modelID) - case *AIStudioEngine: - if e != nil && e.up != nil { - return e.up.ProbeImage(ctx, modelID) - } } // 原生 AI Studio 等:图片走 chat/completions。 return m.probeChatModel(ctx, modelID) @@ -1381,12 +1368,10 @@ func (m *Manager) probeImageModel(ctx context.Context, modelID string) (string, func (m *Manager) probeEmbeddingModel(ctx context.Context, modelID string) (string, error) { eng, _ := m.pickEngine(modelID) switch e := eng.(type) { - case *CustomEngine: + case *custom.Engine: + return e.ProbeEmbedding(ctx, modelID) + case *aistudio.Engine: return e.ProbeEmbedding(ctx, modelID) - case *AIStudioEngine: - if e != nil && e.up != nil { - return e.up.ProbeEmbedding(ctx, modelID) - } } return "", fmt.Errorf("当前引擎不支持向量探测,请手动验证") } @@ -1397,10 +1382,10 @@ func (m *Manager) probeEmbeddingModel(ctx context.Context, modelID string) (stri func (m *Manager) probeCatalogModel(modelID, kind string) (string, error) { eng, _ := m.pickEngine(modelID) if eng == nil { - return "", newModelNotFound(modelID, m.ownershipHint(modelID)) + return "", engine.NewModelNotFound(modelID, m.ownershipHint(modelID)) } if !engineHasModel(eng, modelID) { - return "", newModelNotFound(modelID, m.ownershipHint(modelID)) + return "", engine.NewModelNotFound(modelID, m.ownershipHint(modelID)) } // 目录里有、但没有任何可用账号声明它(引擎自报 available=false)同样不算可用。 want := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(modelID), "models/")) @@ -1415,26 +1400,9 @@ func (m *Manager) probeCatalogModel(modelID, kind string) (string, error) { break } if !eng.Ready() { - return "", engineUnavailableError(eng) - } - return fmt.Sprintf("引擎 %s 已声明该模型且引擎可接单(%s 模型未做生成探测)", eng.Name(), kindLabel(kind)), nil -} - -func kindLabel(kind string) string { - switch kind { - case model.KindVideo: - return "视频" - case model.KindMusic: - return "音乐" - case model.KindSpeech: - return "语音" - case model.KindImage: - return "生图" - case model.KindEmbedding: - return "向量" - default: - return "对话" + return "", engine.UnavailableError(eng) } + return fmt.Sprintf("引擎 %s 已声明该模型且引擎可接单(%s 模型未做生成探测)", eng.Name(), engine.KindLabel(kind)), nil } // Chat 非流式对话。 @@ -1443,7 +1411,12 @@ func (m *Manager) Chat(ctx context.Context, req model.ChatRequest) (*model.ChatR if err != nil { return nil, err } - return eng.Chat(ctx, req) + res, err := eng.Chat(ctx, req) + if err != nil { + // 失败也带回引擎名,用量明细才不会显示「—」。 + return &model.ChatResult{Engine: eng.Name(), Model: req.Model}, err + } + return res, nil } // ChatStream 流式对话。sink 同时承接文本与工具调用增量:引擎实现 @@ -1456,10 +1429,18 @@ func (m *Manager) ChatStream(ctx context.Context, req model.ChatRequest, sink mo if err != nil { return nil, err } + var ( + res *model.ChatResult + ) if toolEng, ok := eng.(engine.ToolEngine); ok { - return toolEng.ChatStreamSink(ctx, req, sink) + res, err = toolEng.ChatStreamSink(ctx, req, sink) + } else { + res, err = eng.ChatStream(ctx, req, sink.Text) } - return eng.ChatStream(ctx, req, sink.Text) + if err != nil { + return &model.ChatResult{Engine: eng.Name(), Model: req.Model}, err + } + return res, nil } // resolveChatEngine 选定引擎。目录命中的模型归属其引擎:引擎不可用时直接返回 @@ -1474,29 +1455,29 @@ func (m *Manager) resolveChatEngine(req model.ChatRequest) (engine.Engine, model if model.ChatIncompatibleKind(kind) { if mediaEng, ok := m.chatMediaEngine(); ok { if !mediaEng.Ready() { - return nil, req, engineUnavailableError(mediaEng) + return nil, req, engine.UnavailableError(mediaEng) } return mediaEng, req, nil } - return nil, req, newUnsupportedEndpoint(req.Model, kind) + return nil, req, engine.NewUnsupportedEndpoint(req.Model, kind) } eng, owned := m.pickEngine(req.Model) if eng == nil { - return nil, req, newModelNotFound(req.Model, m.ownershipHint(req.Model)) + return nil, req, engine.NewModelNotFound(req.Model, m.ownershipHint(req.Model)) } if !eng.Ready() { // 多模态(lyria / veo / image / tts 等)与目录已标明归属的模型禁止换引擎: // 换到 custom 只会把请求打到不相干的第三方供应商,最终以假 404 收场。 // 目标引擎不可用时返回它自己的真实错误(如 engine b: worker_ready=0)。 if owned || isMultimodalModel(strings.ToLower(strings.TrimSpace(req.Model))) { - return nil, req, engineUnavailableError(eng) + return nil, req, engine.UnavailableError(eng) } alt := m.alternate(eng, req.Model) if alt != nil { eng = alt } else { - return nil, req, engineUnavailableError(eng) + return nil, req, engine.UnavailableError(eng) } } return eng, req, nil @@ -1559,128 +1540,40 @@ func (m *Manager) ownershipHint(modelName string) string { // GenerateVideo 通过引擎B的 AI Studio GenerateVideo 协议创建 Veo 长任务。 func (m *Manager) GenerateVideo(ctx context.Context, request aistudio.VideoRequest) (aistudio.VideoOperation, error) { - native, ok := m.engineB.(*NativeAIStudioEngine) + native, ok := m.engineB.(*aistudio.NativeEngine) if !ok || native == nil { return aistudio.VideoOperation{}, errors.New("当前未启用引擎B原生 Veo 协议") } if !native.Ready() { - return aistudio.VideoOperation{}, engineUnavailableError(native) + return aistudio.VideoOperation{}, engine.UnavailableError(native) } return native.GenerateVideo(ctx, request) } // GetGenerateVideoOperation 查询 Veo 长任务状态。 func (m *Manager) GetGenerateVideoOperation(ctx context.Context, operationID string) (aistudio.VideoOperation, error) { - native, ok := m.engineB.(*NativeAIStudioEngine) + native, ok := m.engineB.(*aistudio.NativeEngine) if !ok || native == nil { return aistudio.VideoOperation{}, errors.New("当前未启用引擎B原生 Veo 协议") } if !native.Ready() { - return aistudio.VideoOperation{}, engineUnavailableError(native) + return aistudio.VideoOperation{}, engine.UnavailableError(native) } return native.GetGenerateVideoOperation(ctx, operationID) } // DownloadVideoFile 下载已完成 Veo 任务的输出文件。 func (m *Manager) DownloadVideoFile(ctx context.Context, fileID string) (aistudio.MediaStream, error) { - native, ok := m.engineB.(*NativeAIStudioEngine) + native, ok := m.engineB.(*aistudio.NativeEngine) if !ok || native == nil { return aistudio.MediaStream{}, errors.New("当前未启用引擎B原生 Veo 协议") } if !native.Ready() { - return aistudio.MediaStream{}, engineUnavailableError(native) + return aistudio.MediaStream{}, engine.UnavailableError(native) } return native.DownloadVideoFile(ctx, fileID) } -// engineUnavailableError 保留引擎最近一次初始化/健康检查错误,避免线上只看到 -// “引擎不可用”而无法判断是账号、代理、网络还是上游协议问题。 -func engineUnavailableError(eng engine.Engine) error { - msg := fmt.Sprintf("引擎 %s 不可用", eng.Name()) - if status := eng.Status(); status != nil { - switch { - case statusString(status["error"]) != "": - msg += ": " + statusString(status["error"]) - case statusString(status["worker_error"]) != "": - // 账号 ready 但 WAA Worker 起不来:这层信息最容易被漏掉,单独透出。 - msg += ": WAA Worker 未就绪(worker_ready=" + statusInt(status["worker_ready"]) + "): " + statusString(status["worker_error"]) - case status["worker_ready"] != nil && statusInt(status["worker_ready"]) == "0": - msg += ": WAA Worker 未就绪(worker_ready=0),账号在池但没有任何可用的工人" - case emptyStatusList(status["accounts"]): - msg += ": 没有可用账号(请在管理台添加账号,或检查 auth_states 配置)" - case firstAccountError(status["accounts"]) != "": - // 账号级错误(凭据失效 / 冷却中等) - msg += ": " + firstAccountError(status["accounts"]) - } - } - return errors.New(msg) -} - -// statusString 安全读取状态里的字符串字段。 -func statusString(v any) string { - s, _ := v.(string) - return strings.TrimSpace(s) -} - -// statusInt 安全读取状态里的整数字段(数字或字符串形式)。 -func statusInt(v any) string { - switch n := v.(type) { - case int: - return strconv.Itoa(n) - case int64: - return strconv.FormatInt(n, 10) - case float64: - return strconv.FormatInt(int64(n), 10) - case string: - return strings.TrimSpace(n) - default: - return "0" - } -} - -// firstAccountError 从账号状态列表中提取第一条错误描述。 -func firstAccountError(v any) string { - switch list := v.(type) { - case []AccountState: - for _, st := range list { - if st.Status == "error" && strings.TrimSpace(st.Detail) != "" { - return st.Label + ": " + strings.TrimSpace(st.Detail) - } - } - case []any: - for _, item := range list { - if m, ok := item.(map[string]any); ok { - status, _ := m["status"].(string) - detail, _ := m["detail"].(string) - label, _ := m["label"].(string) - if status == "error" && strings.TrimSpace(detail) != "" { - if label != "" { - return label + ": " + strings.TrimSpace(detail) - } - return strings.TrimSpace(detail) - } - } - } - } - return "" -} - -func emptyStatusList(v any) bool { - switch list := v.(type) { - case []map[string]any: - return len(list) == 0 - case []any: - return len(list) == 0 - default: - // 订阅引擎的 []AccountState 等具体切片类型 - rv := reflect.ValueOf(v) - if rv.Kind() == reflect.Slice { - return rv.Len() == 0 - } - return false - } -} - // alternate 返回另一个引擎(按注册顺序优先就绪的)。 // 声明式号池(第三方 Key)只认自己的模型目录:目标模型不在其目录里就不换过去, // 否则换过去只会拿到 model_not_found,把真正的故障源(如引擎B 挂了)盖掉。 @@ -1693,7 +1586,7 @@ func (m *Manager) alternate(cur engine.Engine, modelName string) engine.Engine { if !eng.Ready() { continue } - if _, strict := eng.(*CustomEngine); strict && !engineHasModel(eng, modelName) { + if _, strict := eng.(*custom.Engine); strict && !engineHasModel(eng, modelName) { continue } return eng @@ -1729,7 +1622,7 @@ func (m *Manager) listModels(publicOnly bool) []model.ModelInfo { } // 公开目录(/v1/models、Codex 目录)不展示没有可用账号的订阅/custom 引擎 // 的静态模型,免得客户端选到一个必然失败的模型;管理台仍可看到全部。 - if publicOnly && name != "a" && name != "b" && !eng.Ready() { + if publicOnly && name != "geminiweb" && name != "aistudio" && !eng.Ready() { continue } for _, mi := range eng.ListModels() { @@ -1773,7 +1666,7 @@ func (m *Manager) Status() map[string]any { out := map[string]any{} for name, eng := range m.engines { key := "engine_" + name - if name == "a" || name == "b" { + if name == "geminiweb" || name == "aistudio" { key = "engine_" + name } out[key] = eng.Status() @@ -1784,8 +1677,8 @@ func (m *Manager) Status() map[string]any { // EngineMetaList 管理台引擎元数据列表。 func (m *Manager) EngineMetaList() []EngineMeta { metas := []EngineMeta{ - {Name: "a", Describe: "Gemini Web(gemini.google.com 网页号,Cookie 会话)", Enabled: m.engines["a"] != nil, AccountPool: true, Credential: "psid/psidts Cookie"}, - {Name: "b", Describe: "AI Studio(原生逆向 / OpenAI 兼容上游)", Enabled: m.engines["b"] != nil, AccountPool: true, Credential: "storage-state.json"}, + {Name: "geminiweb", Describe: "Gemini Web(gemini.google.com 网页号,Cookie 会话)", Enabled: m.engines["geminiweb"] != nil, AccountPool: true, Credential: "psid/psidts Cookie"}, + {Name: "aistudio", Describe: "AI Studio(原生逆向 / OpenAI 兼容上游)", Enabled: m.engines["aistudio"] != nil, AccountPool: true, Credential: "storage-state.json"}, {Name: "codex", Describe: "GPT(ChatGPT 订阅号)", Enabled: m.engines["codex"] != nil, AccountPool: true, OAuth: "authorize", Credential: "refresh_token(OAuth 授权或 Codex CLI auth.json 导入)"}, {Name: "antigravity", Describe: "Antigravity(Google Cloud AI Companion)", Enabled: m.engines["antigravity"] != nil, AccountPool: true, OAuth: "authorize", Credential: "refresh_token(Google OAuth 授权)"}, {Name: "kiro", Describe: "AWS Kiro(CodeWhisperer,Claude 模型)", Enabled: m.engines["kiro"] != nil, AccountPool: true, Credential: "refresh_token(Kiro IDE ~/.aws/sso/cache/kiro-auth-token.json)"}, @@ -1824,7 +1717,7 @@ func (m *Manager) ServeModelPassthrough(ctx context.Context, modelID, method, pa if !ok { return false, nil } - custom, isCustom := eng.(*CustomEngine) + custom, isCustom := eng.(*custom.Engine) if !isCustom { // 订阅引擎不是通用 OpenAI 兼容上游,维持引擎B回退。 return false, nil diff --git a/internal/provider/routing_ready_test.go b/internal/provider/routing_ready_test.go index a21da98..b9ae57f 100644 --- a/internal/provider/routing_ready_test.go +++ b/internal/provider/routing_ready_test.go @@ -10,9 +10,11 @@ import ( ) type fakeEngine struct { - name string - ready bool - models []string + name string + ready bool + models []string + chatErr error + streamErr error } func (f *fakeEngine) Name() string { return f.name } @@ -26,9 +28,15 @@ func (f *fakeEngine) ListModels() []model.ModelInfo { return out } func (f *fakeEngine) Chat(context.Context, model.ChatRequest) (*model.ChatResult, error) { + if f.chatErr != nil { + return nil, f.chatErr + } return nil, nil } func (f *fakeEngine) ChatStream(context.Context, model.ChatRequest, model.ChatStreamFunc) (*model.ChatResult, error) { + if f.streamErr != nil { + return nil, f.streamErr + } return nil, nil } func (f *fakeEngine) Passthrough() bool { return false } diff --git a/internal/store/cookies.go b/internal/store/cookies.go index 003610a..3dad883 100644 --- a/internal/store/cookies.go +++ b/internal/store/cookies.go @@ -36,7 +36,7 @@ func (s *Store) ImportLegacyCookies(directory string) (int, error) { } count := 0 for _, account := range accounts { - if account.Engine != "a" { + if account.Engine != "a" && account.Engine != "geminiweb" { // 存量行记 a,新行记 geminiweb continue } var credentials struct { diff --git a/internal/store/queries.go b/internal/store/queries.go index 326cf3c..ee9583e 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -124,6 +124,7 @@ func scanAccount(row interface{ Scan(...any) error }) (Account, error) { var acc Account var enabled int err := row.Scan(&acc.ID, &acc.Engine, &acc.Label, &acc.Credentials, &enabled, &acc.Status, &acc.Detail, &acc.CreatedAt, &acc.UpdatedAt) + acc.Engine = legacyEngineName(acc.Engine) if errors.Is(err, sql.ErrNoRows) { return Account{}, ErrNotFound } @@ -297,6 +298,7 @@ func (s *Store) UsageSummary(sinceUnix int64) ([]UsageRow, error) { if err := rows.Scan(&r.APIKey, &r.Engine, &r.Model, &r.Requests, &r.SuccessRequests, &r.PromptTokens, &r.CompletionTokens); err != nil { return nil, err } + r.Engine = legacyEngineName(r.Engine) out = append(out, r) } return out, rows.Err() @@ -308,3 +310,16 @@ func boolToInt(b bool) int { } return 0 } + +// legacyEngineName 把存量库里的历史引擎名归一为当前标识: +// a→geminiweb、b→aistudio(v0.3.9 起引擎改名,旧记录读出时统一显示新名)。 +func legacyEngineName(engine string) string { + switch engine { + case "a": + return "geminiweb" + case "b": + return "aistudio" + default: + return engine + } +} diff --git a/internal/store/quota.go b/internal/store/quota.go index c2c4aeb..b1a3301 100644 --- a/internal/store/quota.go +++ b/internal/store/quota.go @@ -527,6 +527,7 @@ func (s *Store) ListUsageRecords(filter UsageFilter) ([]UsageRecord, int64, erro &r.ChargedTokens, &r.Error, &r.LatencyMs, &r.UserID, &r.UserEmail, &r.UserMultiplier, &r.Refunded); err != nil { return nil, 0, err } + r.Engine = legacyEngineName(r.Engine) r.Stream, r.Success, r.Estimated = stream != 0, success != 0, estimated != 0 r.KeyMasked = MaskKey(r.APIKey) out = append(out, r)