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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ curl http://127.0.0.1:2048/v1/chat/completions \
| Anthropic Messages | `http://127.0.0.1:2048` | `.env` 中的 `PROXY_API_KEY` |
| Gemini | `http://127.0.0.1:2048` | `.env` 中的 `PROXY_API_KEY` |

模型名称从 `GET /v1/models` 或 `GET /v1beta/models` 读取。`PROXY_API_KEY` 为空时,浏览器中只有本机页面可以直接调用接口;网页版客户端和部分桌面客户端需要设置 `PROXY_API_KEY`。多个 key 用逗号分隔,任一 key 都可调用;设置页可为每个 key 勾选处理其请求的账户。
模型名称从 `GET /v1/models` 或 `GET /v1beta/models` 读取。`PROXY_API_KEY` 为空时,浏览器中只有本机页面可以直接调用接口;网页版客户端和部分桌面客户端需要设置 `PROXY_API_KEY`。多个 key 用逗号分隔,任一 key 都可调用;设置页可为每个 key 勾选处理其请求的账户。请求带 `X-AIStudio-Account: <账户>` 请求头时只由该账户处理,账户填账户页显示的邮箱;该账户出错时直接返回它的错误,不换到其他账户。

以 Cherry Studio 为例:

Expand Down
2 changes: 1 addition & 1 deletion README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ curl http://127.0.0.1:2048/v1/chat/completions \
| Anthropic Messages | `http://127.0.0.1:2048` | `PROXY_API_KEY` from `.env` |
| Gemini | `http://127.0.0.1:2048` | `PROXY_API_KEY` from `.env` |

Read model names from `GET /v1/models` or `GET /v1beta/models`. When `PROXY_API_KEY` is empty, only pages on this machine can call the API from a browser; web clients and some desktop clients need `PROXY_API_KEY` set. Separate several keys with commas; any of them is accepted. On the Settings page you can tick which accounts handle each key's requests.
Read model names from `GET /v1/models` or `GET /v1beta/models`. When `PROXY_API_KEY` is empty, only pages on this machine can call the API from a browser; web clients and some desktop clients need `PROXY_API_KEY` set. Separate several keys with commas; any of them is accepted. On the Settings page you can tick which accounts handle each key's requests. A request with the header `X-AIStudio-Account: <account>` is handled only by that account, written as the email shown on the Accounts page; if that account fails, its error is returned directly instead of switching to another account.

For Cherry Studio:

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ POST /api/control/start

`runtime-state.json` 的 `model_access` value 为 `{state,checked_at,reason?}`,成功状态为 `verified`;`cooldowns` value 为 `{until,reason?}`;`resources` value 保存 kind、name、mime、size、purpose、created_at 与可选 video 元数据。Drive file、视频任务、视频产物和 Bidi 恢复 token 均保持创建账户粘性。视频任务额外保存公开 video object 的 model、seconds、size 与 UTC 创建时间,生成服务或进程重启后的轮询继续投影相同字段。

活动请求锁保护跨进程账户租约,短事务锁保护 `runtime-state.json` 合并写回。短事务锁位于 `auth/.leases/<账户>.runtime.lock`,以 25ms 间隔等待,最多 2 秒;取得锁后重读磁盘状态,只修改目标字段,原子替换文件,再同步内存与资源索引。取租约时同样在锁内读取所选账户的文件,内容与上次同步或写入时相同则沿用内存状态。接收 context 的资源事务会在取消或 deadline 到达时提前返回。
活动请求锁保护跨进程账户租约,短事务锁保护 `runtime-state.json` 合并写回。短事务锁位于 `auth/.leases/<账户>.runtime.lock`,以 25ms 间隔等到锁释放;取得锁后重读磁盘状态,只修改目标字段,原子替换文件,再同步内存与资源索引。取租约时同样在锁内读取所选账户的文件,内容与上次同步或写入时相同则沿用内存状态。接收 context 的资源事务会在取消或 deadline 到达时提前返回。

账户更新以 `account.json` 原子写入为持久提交点。`internal/app` 先准备 `pending`(尚未提交)的固定出口,关闭旧 Worker并锁定该账户的 Worker 配置,再调用 `AccountLease.SaveConfig`;保存成功后依次提交 Worker 配置与固定出口。准备、关闭或保存失败时丢弃 pending 更新;保存后的租约释放错误原样返回,已发布配置继续生效。

Expand Down
2 changes: 1 addition & 1 deletion docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ ERROR account@example.com WAA Worker 停止失败 | PID=18240 | 耗时=10.002s
| `上游首事件到达` | 首事件等待后解出第一个语义事件 | `等待`、`事件`、`模型` |
| `事件流停顿` | 已有语义事件,连续 15 秒没有下一事件 | `最近事件`、`推理`、`正文`、`网络字节`、`最近网络` |
| `事件流恢复` | 停顿后解出下一语义事件 | `停顿`、`当前事件` |
| `账号切换` | 当前账户在首个上游语义事件前失败,或非流式请求的上游流中途中断 | 当前账户来源、模型和下一行原始原因 |
| `账号切换` | 当前账户在首个上游语义事件前失败,或非流式请求的上游流中途中断;`账号切换 \| 续写轮` 为自动续写轮在当前账户上失败后换账户重发 | 当前账户来源、模型和下一行原始原因 |
| `备用请求发起` | 原请求等待首个语义事件达到 `FIRST_EVENT_HEDGE` 的阈值,空闲账户开始同一请求 | `账户`、`原请求已等待`、`阈值`、`模型` |
| `备用请求胜出` | 备用请求先解出首个语义事件,原请求取消 | `账户`、`原请求已等待`、`模型` |
| `原请求胜出` | 原请求先解出首个语义事件,备用请求取消 | `备用账户`、`模型` |
Expand Down
10 changes: 6 additions & 4 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ Google 邮箱的小写形式同时作为账户目录、管理页面标识和日

生成服务启动时与之后每 10 分钟删除各账户到期的资源:上传文件在 `expires_at` 之后,内联附件闲置 1 小时且没有进行中的请求引用之后,生成视频与其任务自完成起 48 小时(没有完成的任务自创建起 48 小时),Live 续接凭证自最近一次发放起 24 小时,旧版本留下的请求临时文件(以 `drive-file` 记录、名为 `inline-media-<序号>` 的内联附件,Omni 输入图片,以及没有摘要、其他账户有更早上传的同名同大小文件的跨账户副本)自上传起 48 小时。Drive 中的文件先删除再解除绑定,Drive 返回 404 时按已删除处理,其他删除失败保留绑定、下一轮重试;没有空闲并发槽位的账户在下一轮回收;其余没有 `expires_at` 的上传文件不按时间删除。

每次运行状态事务先取得 `auth/.leases/<账户>.runtime.lock`,每 25ms 尝试一次,等待上限为 2 秒;携带调用方 context(Go 取消上下文)的事务在调用方取消或 deadline 更早到达时立即结束。取得锁后重新读取当前磁盘值,只合并目标字段,以临时文件原子替换 `runtime-state.json`,再同步内存状态与资源归属。锁等待失败、读取失败、写回失败和 unlock 失败均返回原始错误链。
每次运行状态事务先取得 `auth/.leases/<账户>.runtime.lock`,每 25ms 尝试一次,直到锁释放;携带调用方 context(Go 取消上下文)的事务在调用方取消或 deadline 到达时结束等待。取得锁后重新读取当前磁盘值,只合并目标字段,以临时文件原子替换 `runtime-state.json`,再同步内存状态与资源归属。锁等待失败、读取失败、写回失败和 unlock 失败均返回原始错误链。

调度器按实时模型方法、capability、AccessModes 和账户权益选择候选,优先使用已经成功调用目标 scope(模型资格与冷却的状态记录范围)的预热账户,再按目标模型最近一次真实首事件耗时排序。常驻 Worker 优先覆盖可调用模型更多的账户。预热数量低于上限时提升待机账户,预热账户均忙时等待并发槽位。同账号 WAA proof 串行生成,已准备的 MakerSuite HTTP 请求并发执行;首个活动请求获取 `.leases/<邮箱>.lock`,最后一个释放。Drive file、视频任务与结果 file 始终使用创建账户。

Expand Down Expand Up @@ -246,7 +246,7 @@ Waa/Create
-> send protected MakerSuite RPC
```

`WAA_BACKEND=camoufox` 时官方 VM 运行在账户固定指纹的 Camoufox 页面,受保护请求由页面 `XMLHttpRequest` 发送;最后一轮是带文本的 user 轮次的 `GenerateContent` 由页面应用在输入框写入该轮文本并点击 Run 时发出(与页面上一轮的对话相同时点击 Rerun),proof 由页面应用以服务给出的 binding 摘要生成,发送时正文换成服务编码的请求正文;`WAA_BACKEND=spidermonkey` 时 VM 运行在服务进程内的 SpiderMonkey 与 Firefox 形状宿主中,受保护请求由 Go HTTP 以 Firefox 152 网络形状发送。
`WAA_BACKEND=camoufox` 时官方 VM 运行在账户固定指纹的 Camoufox 页面,受保护请求由页面 `XMLHttpRequest` 发送;最后一轮是带文本的 user 轮次的 `GenerateContent` 首轮由页面应用在输入框写入该轮文本并点击 Run 时发出(与页面上一轮的对话相同时点击 Rerun),proof 由页面应用以服务给出的 binding 摘要生成,发送时正文换成服务编码的请求正文,自动续写的后续轮次沿用页面写入首轮的 proof 直接发送;`WAA_BACKEND=spidermonkey` 时 VM 运行在服务进程内的 SpiderMonkey 与 Firefox 形状宿主中,受保护请求由 Go HTTP 以 Firefox 152 网络形状发送。

`Waa/Create` 请求是 JSON+protobuf 数组。Worker 启动时只带 request key,VM 刷新时追加当前 interpreter hash 与上一 VM 的无绑定 snapshot:

Expand Down Expand Up @@ -751,10 +751,10 @@ ListModels 能力码 91(目录能力 `output_continuation`)的模型由官

1. 请求的 generation config field 46 为 `1`,根 field 11 省略;max output 大于 32768 时首轮发送 32768
2. 候选 field 9(JSON 索引 8)给出续写 token。完成帧以 `max_tokens` 结束且同一帧带 token、本轮上限小于请求的输出总量,或以终止原因 22 结束时,本轮不形成终态
3. 下一轮复制原请求,根消息 field 17 写入最近的 token,max output 为 `min(输出总量, (轮次 + 1) × 32768)`,轮次从 0 计
3. 下一轮复制原请求,沿用首轮写入的 field 5 proof,根消息 field 17 写入最近的 token,max output 为 `min(输出总量, (轮次 + 1) × 32768)`,轮次从 0 计
4. 终止原因 22 而响应没有 token 时请求失败

各轮正文、思考与工具事件按顺序输出为一个响应,终态取最后一轮。usage 以最后一轮为基础,visible output、thought 与 tool tokens 逐轮累加,total 为最后一轮 total 加之前各轮的这三项。未指定输出上限时输出总量为模型的 `inputTokenLimit`。输出总量超过 32768 的自动续写请求只经 Playground 调度,未指定输出上限且含 File Search、`serviceTier`、`store:false` 等经 Build 通道发送的设置时仍可经 Build,按 Build 的单轮上限发送;请求期限为 `REQUEST_TIMEOUT`,每个续写轮开始时重新计时。每个续写轮在日志中记一条“上游调用”,模式后附 `续写=<轮次>`。
各轮正文、思考与工具事件按顺序输出为一个响应,终态取最后一轮。usage 以最后一轮为基础,visible output、thought 与 tool tokens 逐轮累加,total 为最后一轮 total 加之前各轮的这三项。未指定输出上限时输出总量为模型的 `inputTokenLimit`。输出总量超过 32768 的自动续写请求只经 Playground 调度,未指定输出上限且含 File Search、`serviceTier`、`store:false` 等经 Build 通道发送的设置时仍可经 Build,按 Build 的单轮上限发送;请求期限为 `REQUEST_TIMEOUT`,每个续写轮开始时重新计时。每个续写轮在日志中记一条“上游调用”,模式后附 `续写=<轮次>`。续写 token 可由其他账户接续:续写轮以 401、403、429 或 5xx 失败时,未指定账户、不含文件与内联媒体的请求换到本请求续写轮尚未失败的其他账户,以该账户的新 proof 重发同一续写轮,之后各轮沿用这个 proof;额度失败的账户按额度冷却规则冷却,用量明细记为最后生成的账户。

### Finish 与错误

Expand Down Expand Up @@ -1244,6 +1244,8 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete

公开 `/v1` 与 `/v1beta` 接受 `Authorization: Bearer`、`X-API-Key`、`X-Goog-API-Key` 或 `?key=`,任一来源与配置的 key 相同即通过;配置为空时关闭本地 API key 校验,此时 `Origin` 为 `null` 或非 localhost、非回环地址的 http/https 页面请求按 API key 错误拒绝(OpenAI 与 Anthropic 路由 401,Gemini 路由与官方相同:没有 key 为 403,带 key 为 400),不带 `Origin` 的客户端与其他 scheme 不受限制。`/v1*`、`/upload/` 与 `/health` 响应允许任意 origin,并以 `Access-Control-Expose-Headers: *` 暴露响应头;OPTIONS 预检不经过 API key 校验,允许 `GET/POST/PUT/DELETE/OPTIONS`,`Access-Control-Allow-Headers` 回显请求的 `Access-Control-Request-Headers`。`/v1*` 请求体上限约为 684 MiB,可容纳 Base64 编码的 512 MiB 文件。`/upload/v1beta/files` 使用相同的认证与请求体上限;可续传上传的分块请求发往 start 响应 `X-Goog-Upload-URL` 给出的地址,凭其中的 `upload_id` 鉴权,不需要 API key。

公开 `/v1`、`/v1beta` 与 `/upload/` 的请求带 `X-AIStudio-Account: <账户 ID>` 时,账户池只在该账户上选号,视频后台生成与可续传上传同样只用该账户;上游出错时返回该错误,不换到其他账户,账户冷却中的处理与只有这一个可用账户时相同;该账户仍受所用 key 的 `API_KEY_ACCOUNTS` 绑定限制,不受 `MODEL_ACCOUNTS` 限制。账户不存在或不在本次请求可用的账户中时返回 400 `account_required`,错误写明该账户。

`/api` 控制面在 `ADMIN_AUTH_ENABLED=false` 时要求来源地址为 loopback 且 `Host` 为 localhost 或回环地址;开启登录后,通过管理员账号和密码签发的 Cookie 会话访问。管理请求执行 same-origin 校验并携带 `Cache-Control: no-store`。全部响应携带 `X-Frame-Options: DENY`、`Content-Security-Policy: frame-ancestors 'none'`、`X-Content-Type-Options: nosniff` 与 `Referrer-Policy: no-referrer`。`GET /health` 返回 `{"status":"ok"}`。

`POST /api/auth/login` 接受 `{"username":"<ADMIN_USERNAME>","password":"<ADMIN_PASSWORD>","remember":false}`,成功返回 `{"enabled":true,"authenticated":true,"username":"<ADMIN_USERNAME>"}`:
Expand Down
9 changes: 3 additions & 6 deletions internal/aistudio/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ const (
globalCooldownKey = "*"
externalLeasePoll = 100 * time.Millisecond
runtimeLockPoll = 25 * time.Millisecond
runtimeLockLimit = 2 * time.Second
)

// AccountState 表示账户当前是否可调度
Expand Down Expand Up @@ -2851,13 +2850,13 @@ func (p *AccountPool) selectionIndicesLocked(selection AccountSelection) ([]int,
if account.ID == accountID {
if allowed != nil {
if _, exists := allowed[accountID]; !exists {
return nil, ErrNoEligibleAccount
return nil, fmt.Errorf("%w:账户 %s 不在本次请求可用的账户中", ErrNoEligibleAccount, accountID)
}
}
return []int{index}, nil
}
}
return nil, ErrNoEligibleAccount
return nil, fmt.Errorf("%w:账户 %s 不存在", ErrNoEligibleAccount, accountID)
}
indices := make([]int, 0, len(p.accounts))
for index, account := range p.accounts {
Expand Down Expand Up @@ -3367,9 +3366,7 @@ func lockRuntimeState(ctx context.Context, account *Account) (*flock.Flock, erro
return nil, fmt.Errorf("创建账户状态锁目录: %w", err)
}
lock := flock.New(filepath.Join(leaseDirectory, filepath.Base(account.Directory)+".runtime.lock"))
lockCtx, cancel := context.WithTimeout(ctx, runtimeLockLimit)
defer cancel()
_, err := lock.TryLockContext(lockCtx, runtimeLockPoll)
_, err := lock.TryLockContext(ctx, runtimeLockPoll)
if err != nil {
return nil, fmt.Errorf("锁定账户运行状态: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/aistudio/build_embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (t *WorkerProtectedTransport) DoProtectedBuild(ctx context.Context, selecti
if err != nil {
return nil, err
}
return t.doPrepared(ctx, prompt, page, buildProofField, selection, rpc)
return t.doPrepared(ctx, prompt, page, buildProofField, "", selection, rpc)
}

// buildEmbedBatchLimit 是上游 batchEmbedContents 单次接受的最大请求数
Expand Down
30 changes: 29 additions & 1 deletion internal/aistudio/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ func ContextWithAPIKey(ctx context.Context, key string) context.Context {
return context.WithValue(ctx, apiKeyContextKey{}, key)
}

// accountContextKey 是请求上下文中指定账户的键
type accountContextKey struct{}

// ContextWithAccount 返回带有请求指定账户的上下文,账户池只用该账户
func ContextWithAccount(ctx context.Context, accountID string) context.Context {
return context.WithValue(ctx, accountContextKey{}, accountID)
}

// contextAccount 返回请求上下文中指定的账户 ID,没有指定时为空
func contextAccount(ctx context.Context) string {
accountID, _ := ctx.Value(accountContextKey{}).(string)
return accountID
}

// ContextWithSelectionScope 返回带有 source 中所用 API key 与指定账户的 ctx,供跨请求的操作沿用发起请求的选号范围
func ContextWithSelectionScope(ctx, source context.Context) context.Context {
if key, ok := source.Value(apiKeyContextKey{}).(string); ok {
ctx = ContextWithAPIKey(ctx, key)
}
if accountID := contextAccount(source); accountID != "" {
ctx = ContextWithAccount(ctx, accountID)
}
return ctx
}

// SetAPIKeyAccounts 设置各 API key 可用的账户 ID,未列出的 key 可用全部账户
func (p *AccountPool) SetAPIKeyAccounts(accounts map[string][]string) {
p.mu.Lock()
Expand All @@ -88,8 +113,11 @@ func (p *AccountPool) SetAPIKeyAccounts(accounts map[string][]string) {
p.notifyLocked()
}

// scopedSelection 把请求所用 API key 与模型绑定的账户并入选择的允许账户,指定账户与绑定资源的选择不受模型绑定限制
// scopedSelection 把请求指定的账户、所用 API key 与模型绑定的账户并入选择,指定账户与绑定资源的选择不受模型绑定限制
func (p *AccountPool) scopedSelection(ctx context.Context, selection AccountSelection) AccountSelection {
if selection.AccountID == "" {
selection.AccountID = contextAccount(ctx)
}
key, _ := ctx.Value(apiKeyContextKey{}).(string)
p.mu.Lock()
keyScope, keyBound := p.apiKeyAccounts[key]
Expand Down
3 changes: 2 additions & 1 deletion internal/aistudio/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ type RPCRequest struct {
Streaming bool
}

// RPCResponse 描述认证传输层返回的响应头和实时正文
// RPCResponse 描述认证传输层返回的响应头和实时正文,Proof 为受保护请求写入的 WAA proof
type RPCResponse struct {
StatusCode int
Header http.Header
Body io.ReadCloser
Proof string
}

// RPCTransport 负责认证、账户租约、Cookie 写回和真实网络发送
Expand Down
Loading