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 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
8 changes: 4 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
5 changes: 1 addition & 4 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 @@ -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
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
39 changes: 38 additions & 1 deletion internal/aistudio/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -1168,12 +1168,24 @@ func (c *Client) sendPlayground(ctx context.Context, request GenerateRequest, en
if err != nil {
return nil, nil, err
}
request.continuationProof = response.Proof
return response, func(source io.Reader, emit func(Event) error) error {
return c.decodePlaygroundRounds(ctx, request, entry.defaults, runtime, source, restoreFunctionNames(request.Tools.Functions, emit))
}, nil
}

// decodePlaygroundRounds 解码 GenerateContent 流,上游要求续写时携带续写 token 发送下一轮,各轮输出依次转发
// continuationAccountsKey 是上下文中续写轮换账户函数的键
type continuationAccountsKey struct{}

// ContinuationAccounts 在续写轮以可换账户的错误失败后返回改由其他账户发送该轮的上下文
type ContinuationAccounts func(context.Context, error) (context.Context, error)

// ContextWithContinuationAccounts 返回续写轮可经 accounts 换账户发送的上下文
func ContextWithContinuationAccounts(ctx context.Context, accounts ContinuationAccounts) context.Context {
return context.WithValue(ctx, continuationAccountsKey{}, accounts)
}

// decodePlaygroundRounds 解码 GenerateContent 流,上游要求续写时携带续写 token 发送下一轮,续写轮以可换账户的错误失败时经上下文中的 ContinuationAccounts 换账户重发,各轮输出依次转发
func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateRequest, defaults GenerationDefaults, runtime RequestContext, source io.Reader, emit func(Event) error) error {
total := int64(0)
if defaults.ContinuedOutputTokens > 0 {
Expand Down Expand Up @@ -1233,9 +1245,34 @@ func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateReq
}
closeRound()
response, err := c.doProtected(ctx, request, body)
for err != nil && retryableAccountError(err) {
accounts, ok := ctx.Value(continuationAccountsKey{}).(ContinuationAccounts)
if !ok {
break
}
next, switchErr := accounts(ctx, err)
if switchErr != nil {
err = errors.Join(err, switchErr)
break
}
ctx = next
lease, _ := AccountLeaseFromContext(ctx)
request.AccountID, request.continuationProof = lease.Account().ID, ""
if c.contextProvider != nil {
if runtime, err = c.contextProvider.RequestContext(ctx, request.AccountID); err != nil {
return fmt.Errorf("读取 AI Studio 请求上下文: %w", err)
}
}
if body, err = EncodeGenerateContentRequest(request, defaults, runtime); err != nil {
return err
}
reportUpstreamMode(ctx, "GenerateContent", fmt.Sprintf("stream | 续写=%d", request.continuationRound), "")
response, err = c.doProtected(ctx, request, body)
}
if err != nil {
return err
}
request.continuationProof = response.Proof
stopClose := context.AfterFunc(ctx, func() {
_ = response.Body.Close()
})
Expand Down
2 changes: 1 addition & 1 deletion internal/aistudio/imagen.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (c *Client) GenerateImage(ctx context.Context, request ImageRequest) ([]Gen

// DoProtectedImage 写入 GenerateImage fresh proof 后经 Worker 页面发送请求,binding 与页面输入框均为提示词
func (t *WorkerProtectedTransport) DoProtectedImage(ctx context.Context, request ImageRequest, rpc RPCRequest) (*RPCResponse, error) {
return t.doPrepared(ctx, request.Prompt, request.Prompt, 3, AccountSelection{
return t.doPrepared(ctx, request.Prompt, request.Prompt, 3, "", AccountSelection{
ModelID: request.Model, Method: "predict", AccountID: request.AccountID,
}, rpc)
}
Expand Down
21 changes: 14 additions & 7 deletions internal/aistudio/runtime_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,17 @@ type pageGenerator interface {
Generate(context.Context, camoufoxnative.GenerateTurn) (*camoufoxnative.ProtectedResponse, error)
}

// generator 返回在页面界面运行该请求的 runtime:Camoufox 后端、GenerateContent 且最后一个轮次是带文本的 user 轮次
// generator 返回在页面界面运行该请求的 runtime:Camoufox 后端、未带 proof 的 GenerateContent 且最后一个轮次是带文本的 user 轮次
func (worker *NativeWorker) generator(request ProtectedRequest) (pageGenerator, bool) {
generator, ok := worker.current().(pageGenerator)
if !ok || !strings.HasSuffix(request.URL, "/GenerateContent") {
if !ok || request.Proof != "" || !strings.HasSuffix(request.URL, "/GenerateContent") {
return nil, false
}
_, typed := camoufoxnative.GeneratePrompt(request.Body)
return generator, typed
}

// Prepare 生成 fresh proof 并写入请求指定的 WAA field;在页面界面运行的 GenerateContent 由页面应用取 proof,只返回公共协议头
// Prepare 生成 fresh proof 并写入请求指定的 WAA field,请求带 Proof 时写入该 proof;在页面界面运行的 GenerateContent 由页面应用取 proof,只返回公共协议头
func (worker *NativeWorker) Prepare(ctx context.Context, request ProtectedRequest) (PreparedProtectedRequest, error) {
start, end, err := arrayElementSpan(request.Body, request.ProofField-1)
if err != nil {
Expand All @@ -119,14 +119,20 @@ func (worker *NativeWorker) Prepare(ctx context.Context, request ProtectedReques
}
return PreparedProtectedRequest{Body: request.Body, Headers: headers}, nil
}
proof, headers, err := worker.proofAndHeaders(ctx, request.Prompt, request.PagePrompt, strings.HasSuffix(request.URL, "/GenerateContent") && request.PagePrompt != "")
proof := request.Proof
var headers http.Header
if proof == "" {
proof, headers, err = worker.proofAndHeaders(ctx, request.Prompt, request.PagePrompt, strings.HasSuffix(request.URL, "/GenerateContent") && request.PagePrompt != "")
} else {
headers, err = worker.current().ProtocolHeaders(ctx)
}
if err != nil {
return PreparedProtectedRequest{}, err
}
encoded, _ := json.Marshal(proof)
body := make([]byte, 0, len(request.Body)-(end-start)+len(encoded))
body = append(append(append(body, request.Body[:start]...), encoded...), request.Body[end:]...)
return PreparedProtectedRequest{Body: body, Headers: headers}, nil
return PreparedProtectedRequest{Body: body, Headers: headers, Proof: proof}, nil
}

// pageRunner 是写入提示词并点击 Run 后取 GenerateContent proof 的 workerRuntime
Expand Down Expand Up @@ -216,7 +222,7 @@ func arrayElementSpan(body []byte, index int) (int, int, error) {
// pageCountTokensDelay 是官网页面在 GenerateContent 发出后统计用户轮次 token 的时延
const pageCountTokensDelay = 2200 * time.Millisecond

// SendProtected 经账户 WAA runtime 流式发送已准备的请求:Camoufox 后端的 GenerateContent 在页面界面运行,请求头、proof 与随后的 CountTokens 都由页面应用产生;其余 GenerateContent 带官网点击运行时的 Priority: u=0,发出 pageCountTokensDelay 后由 runtime 以相同请求头统计页面提示词的 token
// SendProtected 经账户 WAA runtime 流式发送已准备的请求,响应带实际写入的 proof:Camoufox 后端未带 proof 的 GenerateContent 在页面界面运行,请求头、proof 与随后的 CountTokens 都由页面应用产生;其余 GenerateContent 带官网点击运行时的 Priority: u=0,发出 pageCountTokensDelay 后由 runtime 以相同请求头统计页面提示词的 token
func (worker *NativeWorker) SendProtected(ctx context.Context, request ProtectedRequest) (*RPCResponse, error) {
if generator, ok := worker.generator(request); ok {
return worker.generate(ctx, generator, request)
Expand Down Expand Up @@ -244,6 +250,7 @@ func (worker *NativeWorker) SendProtected(ctx context.Context, request Protected
StatusCode: response.StatusCode,
Header: response.Header,
Body: response.Body,
Proof: request.Proof,
}, nil
}

Expand All @@ -265,7 +272,7 @@ func (worker *NativeWorker) generate(ctx context.Context, generator pageGenerato
return nil, err
}
worker.setPhase(WorkerReady)
return &RPCResponse{StatusCode: response.StatusCode, Header: response.Header, Body: response.Body}, nil
return &RPCResponse{StatusCode: response.StatusCode, Header: response.Header, Body: response.Body, Proof: response.Proof}, nil
}

// countTokensBody 按官网页面对用户轮次的统计,以 GenerateContent 的模型构造页面提示词的 CountTokens 正文;不是带页面提示词的 GenerateContent 时返回 false
Expand Down
Loading