From 58964b8794ed69ccc762dca7a939d242eabfbb28 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Wed, 11 Nov 2026 05:06:35 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E5=85=A8=E9=83=A8=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E5=86=B7=E5=8D=B4=E6=97=B6=E9=80=90=E4=B8=AA?= =?UTF-8?q?=E5=B0=9D=E8=AF=95=E5=86=B7=E5=8D=B4=E4=B8=AD=E7=9A=84=E8=B4=A6?= =?UTF-8?q?=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全部账户处在套餐限额或每日限额冷却时,同一请求依次发往这些账户、每个账户一次 - 全部启用账户都已尝试后结束重试,返回最后一次的错误 - 协议与开发文档说明这一顺序 --- docs/development.md | 2 +- docs/protocol.md | 2 +- internal/app/runtime.go | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/development.md b/docs/development.md index 46875707..66a78909 100644 --- a/docs/development.md +++ b/docs/development.md @@ -325,7 +325,7 @@ Worker 容量由热池目标、活动上限和单账户并发共同约束。活 故障重置先等待同账户的活动请求释放租约;等待期间该账户暂停接收新请求。客户端取消只结束自身请求。启动预热在官网 Run 按钮启用后提交,请求在发送前再次检查账户冷却状态。 -额度冷却按 `quota_unit`、`quota_limit`、`quota_metric`、错误文案依次判定周期。明确的每日限额在美国太平洋时间次日零点恢复;分钟限额按有效的 `window_start_time` 恢复;通用 429 冷却一分钟。写明恢复时间的套餐限额(`You've reached your usage limit for this model (resets in 10 hours, 2 minutes)`)冷却到该时间;没有重试时间的套餐限额(`You've reached your usage limit` 或 Build 写代码助手步骤带每用户额度标记)按同一账户同一 scope 的连续次数冷却 1、5、15、30、60 分钟,冷却未结束时的并发失败沿用当前期限,该 scope 成功一次后从 1 分钟重新开始;订阅模型遇到额度错误时,后台立即刷新该账户的权益与目录,同一账户每分钟最多一次。`RetryInfo` 与 `Retry-After` 提供的重试时间优先。错误列出的额度项上限全部为 0(`limit: 0`)时冷却类型为无配额,期限同样按上述规则。全局范围由额度元数据确认,其余错误只冷却失败模型或能力 scope;正文后的额度错误与 Live 会话错误使用同一规则。全部候选账户都在冷却且最早恢复晚于 1 分钟时,套餐限额与每日限额冷却中的账户仍接收请求:上游按每个请求判定是否放行,用尽后较短的请求仍可能完成;同一请求再收到这两种限额拒绝时按上游原因冷却并返回该 429,不再换账户。 +额度冷却按 `quota_unit`、`quota_limit`、`quota_metric`、错误文案依次判定周期。明确的每日限额在美国太平洋时间次日零点恢复;分钟限额按有效的 `window_start_time` 恢复;通用 429 冷却一分钟。写明恢复时间的套餐限额(`You've reached your usage limit for this model (resets in 10 hours, 2 minutes)`)冷却到该时间;没有重试时间的套餐限额(`You've reached your usage limit` 或 Build 写代码助手步骤带每用户额度标记)按同一账户同一 scope 的连续次数冷却 1、5、15、30、60 分钟,冷却未结束时的并发失败沿用当前期限,该 scope 成功一次后从 1 分钟重新开始;订阅模型遇到额度错误时,后台立即刷新该账户的权益与目录,同一账户每分钟最多一次。`RetryInfo` 与 `Retry-After` 提供的重试时间优先。错误列出的额度项上限全部为 0(`limit: 0`)时冷却类型为无配额,期限同样按上述规则。全局范围由额度元数据确认,其余错误只冷却失败模型或能力 scope;正文后的额度错误与 Live 会话错误使用同一规则。全部候选账户都在冷却且最早恢复晚于 1 分钟时,套餐限额与每日限额冷却中的账户仍接收请求:上游按每个请求判定是否放行,用尽后较短的请求仍可能完成;同一请求依次发往这些账户、每个账户一次,上游放行即完成,被拒的账户按上游原因更新冷却,全部拒绝时返回最后一次的 429。 账户状态: diff --git a/docs/protocol.md b/docs/protocol.md index 2da33de1..f9627e6d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2743,7 +2743,7 @@ Gemini 列中注明状态码的行以该状态码返回;Interactions 路由与 | 视频仍在生成 | 409 | `video_not_ready` | `api_error` | `INTERNAL` | | 文件超过 512 MiB | 413 | `file_too_large` | `request_too_large` | `INTERNAL` | | 上游配额或限流(上游给出重试时长时带 `Retry-After`,Gemini 错误体带 `RetryInfo`) | 429 | `rate_limit_exceeded` | `rate_limit_error` | `RESOURCE_EXHAUSTED` | -| 候选账户均在冷却且 1 分钟内不恢复(套餐限额与每日限额冷却中的账户先发送一次,上游仍拒绝时返回该拒绝;带 `Retry-After`,Gemini 错误体带 `RetryInfo`) | 429 | `rate_limit_exceeded` | `rate_limit_error` | `RESOURCE_EXHAUSTED` | +| 候选账户均在冷却且 1 分钟内不恢复(套餐限额与每日限额冷却中的账户逐个发送一次,全部拒绝时返回最后一次拒绝;带 `Retry-After`,Gemini 错误体带 `RetryInfo`) | 429 | `rate_limit_exceeded` | `rate_limit_error` | `RESOURCE_EXHAUSTED` | | 上游过载 | 529 | `upstream_error` | `overloaded_error` | `INTERNAL` | | 当前客户端请求被管理端取消 | 503 | `request_canceled` | `api_error` | `UNAVAILABLE` | | 生成服务已停止 | 503 | `service_stopped` | `api_error` | `UNAVAILABLE` | diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 586fbb49..d4217cd8 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -1755,8 +1755,6 @@ type generateRetry struct { attemptFailure error // missingInlineMedia 表示已因复用的内联附件文件不存在而在同一账户重试过 missingInlineMedia bool - // quotaRejected 表示已有尝试收到套餐限额或每日限额拒绝,之后全部账户冷却时不再改用这两种冷却中的账户 - quotaRejected bool // quotaUntil 是本次请求中因额度失败而冷却的账户最早的恢复时刻 quotaUntil time.Time } @@ -1850,6 +1848,7 @@ func (service *trackedService) generateWithRetry( } if (unbound || fileBound) && len(retry.attempted) > 0 { enabled, _ := service.pool.EnabledAccounts() + selection.AllowedAccountIDs = []string{} for _, accountID := range enabled { if _, exists := retry.attempted[accountID]; !exists { selection.AllowedAccountIDs = append(selection.AllowedAccountIDs, accountID) @@ -1862,7 +1861,7 @@ func (service *trackedService) generateWithRetry( "排队等待 | 已等待=%s | 模型=%s", waited, modelID, )) }) - quotaFallback := !retry.quotaRejected && selection.ResourceID == "" + quotaFallback := selection.ResourceID == "" lease, acquireErr := service.acquireWarmLease(requestCtx, selection, quotaFallback) var buildCooling *aistudio.AllCoolingError if acquireErr != nil && selection.Channel == aistudio.ChannelBuild && requestCtx.Err() == nil && !aistudio.RequestBuildOnly(request) && @@ -2231,7 +2230,6 @@ func (service *trackedService) settleAttempt( } cooldown, quotaFailure := aistudio.QuotaCooldownForError(err, time.Now()) if quotaFailure { - retry.quotaRejected = retry.quotaRejected || aistudio.RequestQuotaCooldown(cooldown.Reason) modelAccessScope := lease.CooldownScope(modelID) scopeLabel := modelAccessScope if cooldown.Global { From d3c480185cefe674b56b8d2c5cb71d99e3360d93 Mon Sep 17 00:00:00 2001 From: BLACK-BIRTHDAY <319472776+BLACK-BIRTHDAY@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:49:37 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(aistudio):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E7=BA=AF=E6=96=87=E6=9C=AC=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E5=BC=82=E5=B8=B8=E7=BB=AD?= =?UTF-8?q?=E5=86=99=E4=B8=8E=E5=86=99=E4=BB=A3=E7=A0=81=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=9D=97=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/build.md | 14 +- docs/protocol.md | 2 +- internal/aistudio/assistant.go | 344 ++++++++++++++++++------ internal/aistudio/compatibility_test.go | 281 +++++++++++++++++++ internal/aistudio/generate.go | 267 +++++++++++++++++- 5 files changed, 818 insertions(+), 90 deletions(-) diff --git a/docs/build.md b/docs/build.md index a3cbc4a1..9ebe2779 100644 --- a/docs/build.md +++ b/docs/build.md @@ -569,7 +569,7 @@ CreateApplet(对话没有可继续的应用时) → 应用 ID CodeAssistantOffline(WAA proof) → 任务 ID StreamCodeAssistantOfflineGeneration → 步骤快照流,读到本轮回复结束或上游关闭 StreamCodeAssistantOfflineGeneration (消息列表在回复完成前结束时再请求一次) -CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文、问题与函数调用,或上游步骤在已输出正文后失败时追加用户轮,最多 3 次) +CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文、问题与函数调用,调用代码块缺失或无法解码,调用了被禁止的工作区工具,或上游步骤在已输出正文后失败时追加用户轮,最多 3 次) 流关闭后在后台: CancelCodeAssistantOfflineGeneration (已取得任务 ID、流没有正常解码完成且任务没有以失败步骤结束时) 巡检、删除账户、新建应用后写入记录失败: @@ -587,7 +587,7 @@ CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文 服务按请求所用 API key 与对话历史把请求对应到应用: - 对话轮按连续的 model 轮与其余轮次分组,逐组累积 SHA-256 摘要。每组计入非思考正文 Part 直接连接后按空白归一的结果、函数调用的名称与键排序的参数、函数结果键排序的内容,其余 Part 去掉思考签名计入,内联数据以其 SHA-256 计入;调用 ID 不计入,客户端以不同格式回传同一回复时摘要相同 -- 本轮回复完整结束、没有以 `malformed_function_call` 结束、没有在函数调用之后继续输出正文、也没有在上游步骤失败后接着写时,本进程把“请求的对话 + 本轮回复的正文与函数调用”的摘要记为该应用可以继续的位置 +- 本轮回复完整结束、没有以 `malformed_function_call` 结束、没有调用被禁止的工作区内置工具、没有在函数调用之后继续输出正文、也没有在上游步骤失败后接着写时,本进程把“请求的对话 + 本轮回复的正文与函数调用”的摘要记为该应用可以继续的位置 - 新请求最后一组之前最近的 model 轮组摘要与记录相同时,在该应用中继续:field 1 只提交该组之后的轮次,field 6 为 `2`、field 14 为 `[2]`、field 21 为应用 ID;记录的位置被取走,回复完整结束后移到新回复之后,同时到达的另一请求找不到记录 - 找不到时新建应用,最后一组之前的对话轮转录在 field 9 中,见“请求映射”;包括第一轮、重新生成、编辑或压缩过的历史、回到较早的回复另起分支、另一个 API key、上一轮回复没有完整结束或在步骤失败后接着写过、最后一组过长或是 model 轮与服务重启之后;对话所在账户没有空闲槽位或不可用、请求被调度到其他账户时,放弃原应用并在新账户新建 - 新建的应用 ID 以资源类型 `assistant-applet` 记入账户运行状态。生成服务启动时与之后每 10 分钟,巡检删除闲置超过 1 小时、或本进程没有可继续对话的应用并删除记录,`DeleteApplet` 返回 404 时同样删除记录;进行中的请求所用应用保留,没有空闲槽位的账户跳过本轮;删除失败时以 WARN 记录 “闲置写代码助手应用删除失败: <错误>”,记录保留到下一轮 @@ -720,13 +720,13 @@ Part 的 field 2 为文本,field 13 为 `1` 或 `true` 时是思考。只修 3. 跳过 role 不是 `model` 的对话轮。模型轮的 Part 以(步骤序号,Part 序号)定位,同一步骤内多个模型轮的 Part 序号连续编号 4. Part 文本是该位置的完整快照:以上一次快照开头时输出新增部分,与上一次相同时不输出;不以上一次快照开头时返回协议错误 “步骤正文不是上一次快照的延续” 5. 思考 Part 的增量输出为 reasoning,其余为 text;text 来自与上一段 text 不同的步骤、且是该 Part 的第一段增量时,前面加 `\n\n` -6. 请求声明了函数时,语言标记为 `function_calls`、`function_results`、`function_call`、`tool_calls`、`tool_call` 与 `tool_code` 的代码块暂缓到以换行加 ```` ``` ```` 结束(流结束时补上结尾)再判定,围栏可以出现在行中;其他代码块按原文输出为 text,可能是围栏开头的结尾反引号暂缓输出。判定取代码块中的第一个 JSON 值,解码失败时把字符串中不合法转义(如 `\x27`,以及 `\u` 之后不是 4 位十六进制的 Windows 路径 `C:\users`)的反斜杠按字面字符、原始控制字符(如多行代码中的换行)按转义再解码一次;值为调用数组或单个调用对象时,每个元素输出为一个函数调用,调用 ID 为新的 UUID,`arguments` 为 `null` 或缺失时为 `{}`。`function_calls` 块接受任意函数名,其余标记的块只在函数均已声明时解码为调用,否则按原文输出。`function_results` 块不输出;输出函数调用之后的正文不再输出;无法解码的 `function_calls` 块不输出,回复以 `malformed_function_call` 结束 -7. 状态为 `1` 的步骤的 field 10 带 `search_web` 时,查询记入 `webSearchQueries`,每个 `[标题, 地址]` 记为一个网页来源块;同一步骤只记录一次。带 `ask_question` 且本轮还没有输出函数调用时,各问题输出为正文,选项各占一行 `- <选项>`;问题为说明中交回函数调用的那句时不输出 -8. 状态为 `1` 的步骤含模型轮时,回复记为已完成;消息中最后一个步骤状态为 `1`、含非思考正文且 field 10 为空时,本轮回复结束;输出函数调用之后模型继续输出非空白正文时,本轮回复在调用处结束,任务流关闭后取消生成任务;输出函数调用之后的步骤失败时,本轮回复同样在调用处结束,任务已经结束,不再取消。这些情况都不再读取后续消息与流尾状态 +6. 请求声明了函数时,语言标记为 `function_calls`、`function_results`、`function_call`、`tool_calls`、`tool_call` 与 `tool_code` 的代码块暂缓到以换行加 ```` ``` ```` 结束(流结束时补上结尾)再判定,围栏可以出现在行中;其他代码块按原文输出为 text,可能是围栏开头的结尾反引号暂缓输出。判定取代码块中的第一个 JSON 值,解码失败时把字符串中不合法转义(如 `\x27`,以及 `\u` 之后不是 4 位十六进制的 Windows 路径 `C:\users`)的反斜杠按字面字符、原始控制字符(如多行代码中的换行)按转义并去掉 `}` 或 `]` 前的尾随逗号再解码一次;值为调用数组或单个调用对象时,每个元素输出为一个函数调用,调用 ID 为新的 UUID,`arguments` 为 `null` 或缺失时为 `{}`。`function_calls` 块接受任意函数名,其余标记的块只在函数均已声明时解码为调用,否则按原文输出。`function_results` 块不输出;输出函数调用之后的正文不再输出;无法解码的 `function_calls` 块不输出,回复记为 `malformed_function_call` +7. 状态为 `1` 的步骤的 field 10 带 `search_web` 时,查询记入 `webSearchQueries`,每个 `[标题, 地址]` 记为一个网页来源块;同一步骤只记录一次。带 `ask_question` 且本轮还没有输出函数调用时,各问题输出为正文,选项各占一行 `- <选项>`;问题为说明中交回函数调用的那句时不输出,此时本轮还没有输出函数调用且没有待闭合的调用代码块则记为 `malformed_function_call`。请求声明了函数且本轮还没有输出函数调用时,步骤执行了 `search_web` 与 `ask_question` 以外的工作区内置工具则停止读取该任务后续步骤 +8. 状态为 `1` 的步骤含模型轮时,回复记为已完成;消息中最后一个步骤状态为 `1`、含非思考正文且 field 10 为空时,本轮回复结束;输出函数调用之后模型继续输出非空白正文、或调用了被禁止的工作区内置工具、或发出交回函数调用的 `ask_question` 而没有函数调用时,本轮任务在该步骤处结束,任务流关闭后取消生成任务;输出函数调用之后的步骤失败时,本轮回复同样在调用处结束,任务已经结束,不再取消。这些情况都不再读取后续消息与流尾状态 9. 消息列表结束后读取流尾状态,code 0 为成功,其他 code 按“错误、额度与能力边界”的表换算 HTTP 状态。上游有时在模型步骤仍为状态 `2` 时正常结束消息列表;此时以同一任务 ID 与应用再请求一次 `StreamCodeAssistantOfflineGeneration`,已完成的任务立即返回状态为 `1` 的最终快照与用量,按同一组规则继续解码,已输出的正文与函数调用不重复输出;重新打开的任务流仍在模型步骤完成前结束时,已解出函数调用或写出正文则以已有内容结束本轮,任务流关闭后取消生成任务,下一轮新建应用并带全部历史 -10. 本轮回复结束或流关闭时回复已完成:依次输出暂缓的文本、记录的搜索 grounding、用量之和,再以 `stop` 结束(无法解码的 `function_calls` 块时为 `malformed_function_call`);回复没有完成、也没有函数调用与正文时返回协议错误 “流结束前没有完成的模型回复” +10. 本轮回复结束或流关闭时回复已完成:依次输出暂缓的文本、记录的搜索 grounding、用量之和,再以 `stop` 结束(无法解码的 `function_calls` 块或调用了被禁止的工作区内置工具且没有解出函数调用时为 `malformed_function_call`);回复没有完成、也没有函数调用与正文时返回协议错误 “流结束前没有完成的模型回复” -一次任务的回复完成时没有输出正文、问题与函数调用(例如只有思考)且没有以 `malformed_function_call` 结束时,在同一应用中继续:field 1 只含用户轮 `Your last turn ended without any visible reply. Continue now and give your reply.`,再读取新任务的任务流,按同一组规则解码,最多 3 次;用量的输入与缓存取最后一次,输出为各次之和。 +一次任务的回复完成时没有输出正文、问题与函数调用(例如只有思考),或 `function_calls` 代码块缺失、无法解码,或调用了被禁止的工作区内置工具时,在同一应用中继续:没有正文、问题与函数调用时 field 1 为用户轮 `Your last turn ended without any visible reply. Continue now and give your reply.`;代码块缺失、无法解码或调用了被禁止的工作区内置工具时提交要求只输出 `function_calls` 代码块并调用 `ask_question` 的用户轮,已输出正文时附上 ``(最后至多 20000 个字符),后续任务开头重复已输出正文的前缀自动去掉;再读取新任务的任务流,按同一组规则解码,最多 3 次;用量的输入与缓存取最后一次,输出为各次之和。 本轮已输出正文、还没有输出函数调用时出现失败步骤且不带额度标记的,同样在同一应用中继续:field 1 只含下面的用户轮,`` 中为本轮已输出正文的最后至多 20000 个字符;新任务的输出接在已输出的正文之后,失败任务中已完成步骤的搜索来源与用量照常输出,用量按上一段的规则合并,搜索记录一并保存,与上一段的追加合计最多 3 次,仍失败时以该失败步骤的错误结束。失败之前只有思考时返回错误,由生成层重新生成: diff --git a/docs/protocol.md b/docs/protocol.md index 2da33de1..a484d798 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -772,7 +772,7 @@ ListModels 能力码 91(目录能力 `output_continuation`)的模型由官 | 18 | missing_thought_signature | 19 | malformed_response | | 22 | continuation | 其他整数 | `provider_` | -Build 通道的 Gemini API 终止原因去掉 `FINISH_REASON_` 前缀后转为上表的同名小写原因,上表没有的名称为 `provider_<小写名称>`。上游以 `malformed_function_call`、`unexpected_tool_call` 或 `malformed_response` 结束,或流在完成帧之前中断、以 5xx 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时(不输出给客户端的搜索、网址上下文、地图与文件检索步骤不计入,MCP 调用计入),服务在同一账户上以同一请求重新生成,最多再生成 3 次,之后按最后一次的终止原因或中断错误结束;被重新生成取代的那一次的网页来源与引用不输出,每次重新生成从头匹配停止序列,上一次暂存的停止序列前缀不输出,重新生成的请求发送失败时本次生成以该错误结束。两个通道第一次带终止原因的帧中的内容事件标记为结束帧,自动续写中转入下一轮的帧不标记;Gemini 流式输出把结束帧的内容放进最后一块。 +Build 通道的 Gemini API 终止原因去掉 `FINISH_REASON_` 前缀后转为上表的同名小写原因,上表没有的名称为 `provider_<小写名称>`。上游以 `malformed_function_call`、`unexpected_tool_call` 或 `malformed_response` 结束,或流在完成帧之前中断、以 5xx 流尾状态结束,且这次生成没有输出正文、函数调用与媒体时(不输出给客户端的搜索、网址上下文、地图与文件检索步骤不计入,MCP 调用计入),服务在同一账户上以同一请求重新生成;已经输出纯文本且没有输出函数调用、媒体、可执行代码与服务端工具步骤时,Playground 与 Build 通道在同一账户上把已输出纯文本作为 model 轮并附上带 ``(最后至多 20000 个字符)的用户提示继续生成,续写轮开头重复已输出正文的前缀自动去掉并保留思考签名,用量按续写合并。两种情况合计最多 3 次,之后按最后一次的终止原因或中断错误结束;被重新生成取代的那一次的网页来源与引用不输出,每次重新生成从头匹配停止序列,上一次暂存的停止序列前缀不输出,重新生成的请求发送失败时本次生成以该错误结束。两个通道第一次带终止原因的帧中的内容事件标记为结束帧,自动续写中转入下一轮的帧不标记;Gemini 流式输出把结束帧的内容放进最后一块。 候选 field 5(`$[0][frame][0][0][4]`)为安全评级 `[[null, null, 类别, 概率, blocked?], ...]`,类别编号与安全设置相同,概率 `NEGLIGIBLE`=1、`LOW`=2、`MEDIUM`=3、`HIGH`=4;输入拒绝帧 `[null,[block_reason,ratings?,message]]` 的 ratings 为同形数组。Build 通道读取候选与 `promptFeedback` 的 `safetyRatings`。 diff --git a/internal/aistudio/assistant.go b/internal/aistudio/assistant.go index 617a0464..23858afa 100644 --- a/internal/aistudio/assistant.go +++ b/internal/aistudio/assistant.go @@ -70,9 +70,15 @@ const ( assistantLatestPrompt = "Continue the conversation from the last turn of the conversation history at the end of your instructions." // assistantEmptyReplyPrompt 是模型一轮结束时没有正文与函数调用后在同一应用中追加的用户轮 assistantEmptyReplyPrompt = "Your last turn ended without any visible reply. Continue now and give your reply." + // assistantMalformedCallPrompt 是模型未写出 function_calls 代码块或代码块 JSON 无效时在同一应用中追加的用户轮 + assistantMalformedCallPrompt = "Your last function_calls block was missing or contained invalid JSON. Output only a valid " + assistantCallFence + " fenced JSON block:\n" + assistantCallFence + "\n[{\"name\": \"\", \"arguments\": {}}]\n```\nRight after the block, call your " + assistantQuestionTool + " tool once with the question \"" + assistantCallQuestion + "\" and write nothing more. Do not repeat any earlier text." + // assistantInvalidToolPrompt 是模型调用了被禁止的远程工作区内置工具而非客户端函数时在同一应用中追加的用户轮 + assistantInvalidToolPrompt = "You called a forbidden remote workspace tool instead of a client function. Never call remote workspace tools. Output only a valid " + assistantCallFence + " fenced JSON block for the client function call(s):\n" + assistantCallFence + "\n[{\"name\": \"\", \"arguments\": {}}]\n```\nRight after the block, call your " + assistantQuestionTool + " tool once with the question \"" + assistantCallQuestion + "\" and write nothing more. Do not repeat any earlier text." // assistantInterruptedPrompt 是上游步骤在已输出正文后失败时在同一应用中追加的用户轮,%s 为已输出正文的末尾 assistantInterruptedPrompt = "Your last turn was cut off by an internal error. The user already received your reply up to the end of this text:\n\n%s\n\nContinue the reply right after it without repeating it." - // assistantPartialReplyRunes 是 assistantInterruptedPrompt 带上的已输出正文末尾的字符数上限 + // assistantPartialReplyPrompt 是模型已输出部分正文后需要续出或修正工具调用时附带的已输出正文末尾,%s 为已输出正文的末尾 + assistantPartialReplyPrompt = "\n\nThe user already received your reply up to the end of this text:\n\n%s\n\nDo not repeat it." + // assistantPartialReplyRunes 是恢复提示带上的已输出正文末尾的字符数上限 assistantPartialReplyRunes = 20000 ) @@ -421,12 +427,113 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent var answer strings.Builder var reply []Part var usage *Usage + var finish *Event + stripPrefix := "" + trimmedPrefix := "" + tailPrefix := "" + trimmedTail := "" + var pendingPrefix strings.Builder + emitText := func(text string) error { + if text == "" { + return nil + } + answer.WriteString(text) + reply = append(reply, Part{Text: text}) + return emit(Event{Kind: EventText, Text: text}) + } + flushPrefix := func(dropExact bool) error { + if pendingPrefix.Len() == 0 { + stripPrefix = "" + trimmedPrefix = "" + tailPrefix = "" + trimmedTail = "" + return nil + } + buffered := pendingPrefix.String() + pendingPrefix.Reset() + target := stripPrefix + trimmed := trimmedPrefix + tail := tailPrefix + trimmedT := trimmedTail + stripPrefix = "" + trimmedPrefix = "" + tailPrefix = "" + trimmedTail = "" + if dropExact && (strings.HasPrefix(target, buffered) || + (trimmed != "" && strings.TrimRight(buffered, " \t\r\n") == trimmed) || + (tail != "" && strings.HasPrefix(tail, buffered)) || + (trimmedT != "" && strings.TrimRight(buffered, " \t\r\n") == trimmedT)) { + return nil + } + return emitText(buffered) + } collect := func(event Event) error { switch event.Kind { case EventText: - answer.WriteString(event.Text) - reply = append(reply, Part{Text: event.Text}) + if stripPrefix == "" && tailPrefix == "" { + return emitText(event.Text) + } + pendingPrefix.WriteString(event.Text) + combined := pendingPrefix.String() + if stripPrefix != "" && strings.HasPrefix(combined, stripPrefix) { + rest := combined[len(stripPrefix):] + pendingPrefix.Reset() + stripPrefix = "" + trimmedPrefix = "" + tailPrefix = "" + trimmedTail = "" + return emitText(rest) + } + if stripPrefix != "" && strings.HasPrefix(stripPrefix, combined) { + return nil + } + if trimmedPrefix != "" && stripPrefix != trimmedPrefix && strings.HasPrefix(combined, trimmedPrefix) { + rest := combined[len(trimmedPrefix):] + if strings.TrimLeft(rest, " \t\r\n") == "" { + return nil + } + if rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\r' || rest[0] == '\n' { + pendingPrefix.Reset() + stripPrefix = "" + trimmedPrefix = "" + tailPrefix = "" + trimmedTail = "" + return emitText(strings.TrimLeft(rest, " \t\r\n")) + } + } + if tailPrefix != "" { + if strings.HasPrefix(combined, tailPrefix) { + rest := combined[len(tailPrefix):] + pendingPrefix.Reset() + stripPrefix = "" + trimmedPrefix = "" + tailPrefix = "" + trimmedTail = "" + return emitText(rest) + } + if strings.HasPrefix(tailPrefix, combined) { + return nil + } + if trimmedTail != "" && tailPrefix != trimmedTail && strings.HasPrefix(combined, trimmedTail) { + rest := combined[len(trimmedTail):] + if strings.TrimLeft(rest, " \t\r\n") == "" { + return nil + } + if rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\r' || rest[0] == '\n' { + pendingPrefix.Reset() + stripPrefix = "" + trimmedPrefix = "" + tailPrefix = "" + trimmedTail = "" + return emitText(strings.TrimLeft(rest, " \t\r\n")) + } + } + } + return flushPrefix(false) case EventToolCall: + if err := flushPrefix(true); err != nil { + return err + } reply = append(reply, Part{FunctionCall: event.FunctionCall}) case EventUsage: if usage != nil { @@ -434,6 +541,19 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent event.Usage = &merged } usage = event.Usage + case EventFinish: + if err := flushPrefix(true); err != nil { + return err + } + value := event + finish = &value + return nil + default: + if event.Kind != EventReasoning && event.Kind != EventGrounding { + if err := flushPrefix(false); err != nil { + return err + } + } } return emit(event) } @@ -460,11 +580,28 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent if err != nil { break } - partial := []rune(answer.String()) - prompt = fmt.Sprintf(assistantInterruptedPrompt, string(partial[max(0, len(partial)-assistantPartialReplyRunes):])) - } else if len(reply) > replied || decoder.overran || decoder.malformed { + prompt = fmt.Sprintf(assistantInterruptedPrompt, assistantPartialTail(answer.String())) + } else if !decoder.called && (decoder.malformed || decoder.invalidTool) { + if decoder.malformed { + prompt = assistantMalformedCallPrompt + } else { + prompt = assistantInvalidToolPrompt + } + if strings.TrimSpace(answer.String()) != "" { + prompt += fmt.Sprintf(assistantPartialReplyPrompt, assistantPartialTail(answer.String())) + } + } else if len(reply) > replied || decoder.overran { break } + pendingPrefix.Reset() + stripPrefix = answer.String() + trimmedPrefix = strings.TrimRight(stripPrefix, " \t\r\n") + tailPrefix = "" + trimmedTail = "" + if tail := assistantPartialTail(stripPrefix); tail != "" && tail != stripPrefix { + tailPrefix = tail + trimmedTail = strings.TrimRight(tail, " \t\r\n") + } replied = len(reply) searches = append(searches, decoder.searches...) followUp := request @@ -478,19 +615,36 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent err = errors.Join(DecodeAssistantStream(follow.Body, decoder, collect), follow.Body.Close()) } cleanup.completed.Store(decoder.failed || err == nil && decoder.finished && !decoder.overran) - if err == nil && interruption != nil && len(reply) == replied { + if err == nil && interruption != nil && len(reply) == replied && !decoder.malformed && !decoder.invalidTool { err = interruption } if err == nil { assistantSearches.store(apiKey, historyDigest, answer.String(), append(searches, decoder.searches...)) - if decoder.finished && !decoder.overran && !decoder.malformed && interruption == nil { + if decoder.finished && !decoder.overran && !decoder.malformed && !decoder.invalidTool && interruption == nil { cleanup.tip.Store(&assistantSessionKey{apiKey: apiKey, digest: assistantLinkDigest(links[len(links)-1].digest, true, reply)}) } + if !decoder.called && (decoder.malformed || decoder.invalidTool) { + if finish == nil { + finish = &Event{Kind: EventFinish} + } + finish.FinishReason = decodeFinishReason(10) + } + if finish != nil { + if emitErr := emit(*finish); emitErr != nil { + return emitErr + } + } } return err }, nil } +// assistantPartialTail 返回用于恢复提示词的已输出正文末尾,最多保留 assistantPartialReplyRunes 个字符 +func assistantPartialTail(text string) string { + partial := []rune(text) + return string(partial[max(0, len(partial)-assistantPartialReplyRunes):]) +} + // openAssistantGeneration 在应用中提交请求的对话轮并打开任务流,返回任务 ID、任务流请求正文与任务流;continued 为 true 时在应用已有对话之后继续 func (c *Client) openAssistantGeneration(ctx context.Context, request GenerateRequest, entry modelEntry, applet string, continued bool) (string, []byte, *RPCResponse, error) { body, err := EncodeAssistantRequest(request, entry.defaults, applet, continued, strings.ToUpper(uuid.NewString())) @@ -741,12 +895,13 @@ type AssistantStreamDecoder struct { // block 表示 held 以调用候选代码块开头,other 表示正处在其他代码块内 block bool other bool - // called 表示已输出函数调用,malformed 表示 function_calls 代码块无法解码 - called bool - malformed bool - lastStep int64 - wroteText bool - finished bool + // called 表示已输出函数调用,malformed 表示 function_calls 代码块无法解码,invalidTool 表示声明了客户端函数时模型调用了被禁止的工作区内置工具 + called bool + malformed bool + invalidTool bool + lastStep int64 + wroteText bool + finished bool // answered 表示最近解码的步骤是已完成、含正文且没有工具动作的模型步骤,即本轮回复结束 answered bool // overran 表示本轮回复在函数调用处结束:调用之后模型继续输出正文或上游后续步骤失败 @@ -856,6 +1011,9 @@ func (d *AssistantStreamDecoder) Decode(raw json.RawMessage, path string) ([]Eve return nil, err } events = append(events, stepEvents...) + if d.overran { + break + } } return events, nil } @@ -897,54 +1055,66 @@ func (d *AssistantStreamDecoder) decodeStep(raw json.RawMessage, path string, ev d.usage[number] = usage } var events []Event + invalidAction := false + missingCallQuestion := false if actionRaw := rawAt(step, 9); !isJSONNull(actionRaw) && status == 1 && !d.searched[number] { - if err := d.decodeSearch(actionRaw, path+"[9]", evidence); err != nil { + var action []json.RawMessage + if action, err = d.decodeSearch(actionRaw, path+"[9]", evidence); err != nil { return nil, err } - if events, err = d.decodeQuestion(actionRaw, path+"[9]", evidence); err != nil { + var isQuestion bool + if events, isQuestion, err = d.decodeQuestion(actionRaw, path+"[9]", evidence); err != nil { return nil, err } + invalidAction = !isQuestion && isJSONNull(rawAt(action, 18)) && len(events) == 0 + missingCallQuestion = isQuestion && len(events) == 0 d.searched[number] = true } contentsRaw := rawAt(step, 0) - if isJSONNull(contentsRaw) { - return events, nil - } - contents, err := rawArray(contentsRaw, path+"[0]", evidence) - if err != nil { - return nil, withMethod(err, assistantStreamMethod) - } - partOffset := 0 - for contentIndex, contentRaw := range contents { - contentPath := fmt.Sprintf("%s[0][%d]", path, contentIndex) - content, err := rawArray(contentRaw, contentPath, evidence) - if err != nil { - return nil, withMethod(err, assistantStreamMethod) - } - role, err := rawString(rawAt(content, 1), contentPath+"[1]", evidence) + if !isJSONNull(contentsRaw) { + contents, err := rawArray(contentsRaw, path+"[0]", evidence) if err != nil { return nil, withMethod(err, assistantStreamMethod) } - if role != "model" { - continue - } - parts, err := rawArray(rawAt(content, 0), contentPath+"[0]", evidence) - if err != nil { - return nil, withMethod(err, assistantStreamMethod) - } - for partIndex, partRaw := range parts { - partEvents, err := d.decodePart(number, partOffset+partIndex, partRaw, fmt.Sprintf("%s[0][%d]", contentPath, partIndex), evidence) + partOffset := 0 + for contentIndex, contentRaw := range contents { + contentPath := fmt.Sprintf("%s[0][%d]", path, contentIndex) + content, err := rawArray(contentRaw, contentPath, evidence) if err != nil { - return nil, err + return nil, withMethod(err, assistantStreamMethod) + } + role, err := rawString(rawAt(content, 1), contentPath+"[1]", evidence) + if err != nil { + return nil, withMethod(err, assistantStreamMethod) + } + if role != "model" { + continue + } + parts, err := rawArray(rawAt(content, 0), contentPath+"[0]", evidence) + if err != nil { + return nil, withMethod(err, assistantStreamMethod) + } + for partIndex, partRaw := range parts { + partEvents, err := d.decodePart(number, partOffset+partIndex, partRaw, fmt.Sprintf("%s[0][%d]", contentPath, partIndex), evidence) + if err != nil { + return nil, err + } + events = append(events, partEvents...) + } + partOffset += len(parts) + if status == 1 { + d.finished = true } - events = append(events, partEvents...) - } - partOffset += len(parts) - if status == 1 { - d.finished = true } + d.answered = status == 1 && isJSONNull(rawAt(step, 9)) && d.wroteText && d.lastStep == number + } + if invalidAction && len(d.Functions) > 0 && !d.called { + d.invalidTool = true + d.overran = true + } else if missingCallQuestion && len(d.Functions) > 0 && !d.called && !d.block { + d.malformed = true + d.overran = true } - d.answered = status == 1 && isJSONNull(rawAt(step, 9)) && d.wroteText && d.lastStep == number return events, nil } @@ -1086,11 +1256,18 @@ func (d *AssistantStreamDecoder) text(delta string) []Event { start := strings.Index(d.held, assistantFence) if start < 0 { keep := len(d.held) - len(strings.TrimRight(d.held, "`")) - emit(d.held[:len(d.held)-keep]) + prefix := d.held[:len(d.held)-keep] + if d.malformed && strings.TrimSpace(prefix) == "" { + return events + } + emit(prefix) d.held = d.held[len(d.held)-keep:] return events } - emit(d.held[:start]) + prefix := d.held[:start] + if !d.malformed || strings.TrimSpace(prefix) != "" { + emit(prefix) + } d.held = d.held[start:] newline := strings.IndexByte(d.held, '\n') if newline < 0 { @@ -1160,7 +1337,7 @@ func assistantCalls(body string, anyName bool, functions []string) ([]Event, boo return events, true } -// repairJSONStrings 修正 JSON 字符串中模型写出的内容:不合法的转义(如按 Python 写出的 \x27、Windows 路径中 \u 之后不是十六进制的部分)改为字面反斜杠,原始控制字符(如多行代码中的换行)改为转义 +// repairJSONStrings 修正 JSON 字符串中模型写出的内容并去掉 } 或 ] 之前的尾随逗号:不合法的转义(如按 Python 写出的 \x27、Windows 路径中 \u 之后不是十六进制的部分)写为字面反斜杠,原始控制字符(如多行代码中的换行)写为转义 func repairJSONStrings(body string) string { var repaired strings.Builder quoted := false @@ -1174,6 +1351,8 @@ func repairJSONStrings(body string) string { repaired.WriteString(`\\`) case quoted && char < 0x20: fmt.Fprintf(&repaired, `\u%04x`, char) + case !quoted && char == ',' && nextNonSpaceCloses(body[index+1:]): + continue default: if char == '"' { quoted = !quoted @@ -1184,6 +1363,21 @@ func repairJSONStrings(body string) string { return repaired.String() } +// nextNonSpaceCloses 表示 rest 在跳过空白后紧跟 } 或 ] +func nextNonSpaceCloses(rest string) bool { + for index := 0; index < len(rest); index++ { + switch rest[index] { + case ' ', '\t', '\n', '\r': + continue + case '}', ']': + return true + default: + return false + } + } + return false +} + // validJSONEscape 表示反斜杠之后的 rest 以 JSON 合法转义开头,\u 之后须为 4 位十六进制 func validJSONEscape(rest string) bool { if strings.HasPrefix(rest, "u") { @@ -1193,47 +1387,47 @@ func validJSONEscape(rest string) bool { } // decodeQuestion 解码 ask_question 工具动作 field 22 [标题, null, 说明, [[参数名, 值]...]]:没有输出函数调用时把各问题与选项输出为正文,交回函数调用的提问不输出 -func (d *AssistantStreamDecoder) decodeQuestion(raw json.RawMessage, path string, evidence json.RawMessage) ([]Event, error) { +func (d *AssistantStreamDecoder) decodeQuestion(raw json.RawMessage, path string, evidence json.RawMessage) ([]Event, bool, error) { action, err := rawArray(raw, path, evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return nil, false, withMethod(err, assistantStreamMethod) } toolRaw := rawAt(action, 21) if isJSONNull(toolRaw) || d.called { - return nil, nil + return nil, false, nil } tool, err := rawArray(toolRaw, path+"[21]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return nil, false, withMethod(err, assistantStreamMethod) } pairs, err := rawArray(rawAt(tool, 3), path+"[21][3]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return nil, false, withMethod(err, assistantStreamMethod) } arguments := map[string]string{} for index, pairRaw := range pairs { pairPath := fmt.Sprintf("%s[21][3][%d]", path, index) pair, err := rawArray(pairRaw, pairPath, evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return nil, false, withMethod(err, assistantStreamMethod) } name, err := rawString(rawAt(pair, 0), pairPath+"[0]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return nil, false, withMethod(err, assistantStreamMethod) } if arguments[name], err = rawString(rawAt(pair, 1), pairPath+"[1]", evidence); err != nil { - return nil, withMethod(err, assistantStreamMethod) + return nil, false, withMethod(err, assistantStreamMethod) } } if arguments["tag"] != assistantQuestionTool { - return nil, nil + return nil, false, nil } var questions []struct { Question string `json:"question"` Options []string `json:"options"` } if err := json.Unmarshal([]byte(arguments["questions"]), &questions); err != nil { - return nil, &ProtocolEvidenceError{Method: assistantStreamMethod, Path: path + "[21][3]", Detail: "ask_question 的 questions 不是问题列表: " + err.Error(), Raw: cloneRaw(evidence)} + return nil, true, &ProtocolEvidenceError{Method: assistantStreamMethod, Path: path + "[21][3]", Detail: "ask_question 的 questions 不是问题列表: " + err.Error(), Raw: cloneRaw(evidence)} } var text strings.Builder for _, question := range questions { @@ -1249,29 +1443,29 @@ func (d *AssistantStreamDecoder) decodeQuestion(raw json.RawMessage, path string } } if text.Len() == 0 { - return nil, nil + return nil, true, nil } d.wroteText = true - return []Event{{Kind: EventText, Text: text.String()}}, nil + return []Event{{Kind: EventText, Text: text.String()}}, true, nil } // decodeSearch 把完成的 search_web 工具动作(field 19:查询、域名、摘要、[标题, 链接] 列表)并入搜索来源,查询与摘要记入 searches -func (d *AssistantStreamDecoder) decodeSearch(raw json.RawMessage, path string, evidence json.RawMessage) error { +func (d *AssistantStreamDecoder) decodeSearch(raw json.RawMessage, path string, evidence json.RawMessage) ([]json.RawMessage, error) { action, err := rawArray(raw, path, evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } searchRaw := rawAt(action, 18) if isJSONNull(searchRaw) { - return nil + return action, nil } search, err := rawArray(searchRaw, path+"[18]", evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } query, err := rawString(rawAt(search, 0), path+"[18][0]", evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } if d.grounding == nil { d.grounding = &GroundingMetadata{} @@ -1280,33 +1474,33 @@ func (d *AssistantStreamDecoder) decodeSearch(raw json.RawMessage, path string, if summaryRaw := rawAt(search, 2); !isJSONNull(summaryRaw) { summary, err := rawString(summaryRaw, path+"[18][2]", evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } d.searches = append(d.searches, fmt.Sprintf(assistantSearchTranscript, query, summary)) } if resultsRaw := rawAt(search, 3); !isJSONNull(resultsRaw) { results, err := rawArray(resultsRaw, path+"[18][3]", evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } for index, resultRaw := range results { resultPath := fmt.Sprintf("%s[18][3][%d]", path, index) result, err := rawArray(resultRaw, resultPath, evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } title, err := rawString(rawAt(result, 0), resultPath+"[0]", evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } uri, err := rawString(rawAt(result, 1), resultPath+"[1]", evidence) if err != nil { - return withMethod(err, assistantStreamMethod) + return nil, withMethod(err, assistantStreamMethod) } d.grounding.Chunks = append(d.grounding.Chunks, GroundingChunk{Source: "web", URI: uri, Title: title}) } } - return nil + return action, nil } // recorded 返回记录的搜索来源与各步骤用量之和 @@ -1337,12 +1531,12 @@ func (d *AssistantStreamDecoder) End() ([]Event, error) { switch { case d.block: events = d.callBlock(d.held + "\n" + assistantFence) - case !d.called && d.held != "": + case !d.called && d.held != "" && (!(d.malformed || d.invalidTool) || strings.TrimSpace(d.held) != ""): events = []Event{{Kind: EventText, Text: d.held}} } events = append(events, d.recorded()...) finish := decodeFinishReason(1) - if d.malformed { + if !d.called && (d.malformed || d.invalidTool) { finish = decodeFinishReason(10) } return append(events, Event{Kind: EventFinish, FinishReason: finish}), nil diff --git a/internal/aistudio/compatibility_test.go b/internal/aistudio/compatibility_test.go index 220184ff..7ae516c1 100644 --- a/internal/aistudio/compatibility_test.go +++ b/internal/aistudio/compatibility_test.go @@ -1,8 +1,13 @@ package aistudio import ( + "bytes" + "context" "encoding/json" + "fmt" + "io" "net/http" + "reflect" "strings" "testing" "time" @@ -296,3 +301,279 @@ func TestAssistantFailureQuotaCooldown(t *testing.T) { } }) } + +// fakeSequenceTransport 按顺序返回预设的受保护与普通 RPC 响应 +type fakeSequenceTransport struct { + responses []string +} + +func (t *fakeSequenceTransport) next() (*RPCResponse, error) { + body := "" + if len(t.responses) > 0 { + body = t.responses[0] + t.responses = t.responses[1:] + } + return &RPCResponse{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil +} + +func (t *fakeSequenceTransport) Do(_ context.Context, rpc RPCRequest) (*RPCResponse, error) { + switch rpc.Method { + case "CreateApplet": + return &RPCResponse{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, + Body: io.NopCloser(strings.NewReader(`["applet-1"]`)), + }, nil + case assistantStreamMethod: + return t.next() + default: + return &RPCResponse{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, + Body: io.NopCloser(strings.NewReader(`[]`)), + }, nil + } +} + +func (t *fakeSequenceTransport) DoProtected(_ context.Context, _ GenerateRequest, rpc RPCRequest) (*RPCResponse, error) { + if rpc.Method == assistantOfflineMethod { + return &RPCResponse{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, + Body: io.NopCloser(strings.NewReader(`["gen-1","applet-1"]`)), + }, nil + } + return t.next() +} + +// TestGeneratePartialTextMalformedFunctionCallContinuation 验证已输出纯文本后遇到 malformed_function_call 时续写补出函数调用、去重重复前缀并合并用量 +func TestGeneratePartialTextMalformedFunctionCallContinuation(t *testing.T) { + malformedCode := int64(10) + stopCode := int64(1) + frame := func(parts [][]any, code *int64, usage *Usage) []any { + candidate := []any{[]any{parts, "model"}} + if code != nil { + candidate = append(candidate, *code) + } + item := make([]any, 3) + item[0] = []any{candidate} + if usage != nil { + item[2] = []any{usage.InputTokens, usage.OutputTokens, usage.TotalTokens} + } + return item + } + streamBody := func(frames ...[]any) string { + raw, err := json.Marshal([]any{frames, []any{int64(0)}}) + if err != nil { + t.Fatal(err) + } + return string(raw) + } + callPart := make([]any, 11) + callPart[10] = []any{"get_weather", encodeWireStruct(map[string]any{"city": "Tokyo"}), "call_1"} + sigPart := make([]any, 15) + sigPart[1] = "Let me check the weather. " + sigPart[14] = "sig-kept" + + round1 := streamBody( + frame([][]any{{nil, "Let me check the weather. "}}, nil, nil), + frame([][]any{{nil, "Hold on"}}, &malformedCode, &Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}), + ) + round2 := streamBody( + frame([][]any{sigPart}, nil, nil), + frame([][]any{{nil, "Hold on, calling tool now."}}, nil, nil), + frame([][]any{callPart}, &stopCode, &Usage{InputTokens: 25, OutputTokens: 8, TotalTokens: 33}), + ) + transport := &fakeSequenceTransport{responses: []string{round1, round2}} + client := NewClient(ClientOptions{Protected: transport}) + model := Model{ + ID: "gemini-test", Name: "Gemini Test", Methods: []string{"generateContent"}, OutputTokenLimit: 8192, + Capabilities: map[string]bool{"chat_model": true, "function_declarations": true, "thinking": true}, + } + client.setModelCatalog("acc@example.com", modelCatalog{ + models: []Model{model}, + entries: map[string]modelEntry{model.ID: {model: model, defaults: GenerationDefaults{MaxOutputTokens: 8192, Thinking: true}}}, + }) + stream, err := client.Generate(context.Background(), GenerateRequest{ + ID: "req-1", AccountID: "acc@example.com", Model: model.ID, + Contents: []Content{{Role: RoleUser, Parts: []Part{{Text: "Weather in Tokyo?"}}}}, + Tools: Tools{ + Functions: []FunctionDeclaration{{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`)}}, + ToolConfig: ToolConfig{Mode: "required"}, + }, + }) + if err != nil { + t.Fatal(err) + } + var text strings.Builder + var signatures []string + var calls []FunctionCall + var usage *Usage + var finish string + for event := range stream { + switch event.Kind { + case EventText: + text.WriteString(event.Text) + if event.ThoughtSignature != "" { + signatures = append(signatures, event.ThoughtSignature) + } + case EventThoughtSignature: + signatures = append(signatures, event.ThoughtSignature) + case EventToolCall: + calls = append(calls, *event.FunctionCall) + case EventUsage: + value := *event.Usage + usage = &value + case EventFinish: + finish = event.FinishReason + case EventError: + t.Fatal(event.Err) + } + } + if text.String() != "Let me check the weather. Hold on, calling tool now." { + t.Fatalf("text=%q", text.String()) + } + if !reflect.DeepEqual(signatures, []string{"sig-kept"}) { + t.Fatalf("signatures=%v", signatures) + } + if len(calls) != 1 || calls[0].Name != "get_weather" { + t.Fatalf("calls=%+v", calls) + } + if usage == nil || usage.InputTokens != 24 || usage.OutputTokens != 13 || usage.TotalTokens != 37 { + t.Fatalf("usage=%+v", usage) + } + if finish != "stop" { + t.Fatalf("finish=%q", finish) + } +} + +// TestAssistantFunctionCallRepairAndContinuation 验证写代码助手修复 JSON 尾随逗号,并在调用块损坏或调用被禁止的工作区工具时在同一应用内续出客户端函数调用 +func TestAssistantFunctionCallRepairAndContinuation(t *testing.T) { + step := func(number, status int64, text string, action []any) []any { + item := make([]any, 16) + if text != "" { + item[0] = []any{[]any{[]any{[]any{nil, text}}, "model"}} + } + item[9] = action + item[11] = number + item[12] = status + return item + } + actionTool := func(tag, questions string) []any { + action := make([]any, 22) + action[21] = []any{"Tool", nil, nil, [][]string{{"tag", tag}, {"questions", questions}}} + return action + } + streamPayload := func(steps ...[]any) string { + raw, err := json.Marshal([]any{[]any{[]any{nil, nil, steps, "applet-1"}}, []any{int64(0)}}) + if err != nil { + t.Fatal(err) + } + return string(raw) + } + callQuestion, _ := json.Marshal([]map[string]any{{"question": assistantCallQuestion}}) + + for _, test := range []struct { + name string + responses []string + }{ + { + name: "trailing comma repair", + responses: []string{ + streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\",},},]\n```\n", nil)), + }, + }, + { + name: "malformed function_calls block", + responses: []string{ + streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{bad}]\n```\n", nil)), + streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\"}}]\n```\n", nil)), + }, + }, + { + name: "missing function_calls before ask_question", + responses: []string{ + streamPayload(step(1, 1, "Checking file.\n", actionTool(assistantQuestionTool, string(callQuestion)))), + streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\"}}]\n```\n", nil)), + }, + }, + { + name: "forbidden workspace tool", + responses: []string{ + streamPayload( + step(1, 1, "Checking file.\n", nil), + step(2, 1, "", actionTool("run_command", "ls")), + step(3, 1, "BOGUS WORKSPACE OUTPUT", nil), + ), + streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\"}}]\n```\n", nil)), + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + transport := &fakeSequenceTransport{responses: test.responses} + baseModel := Model{ + ID: "gemini-3.8-pro", Name: "Gemini 3.8 Pro", Methods: []string{"generateContent"}, + Capabilities: map[string]bool{"chat_model": true, "code_assistant": true}, + } + entry := assistantModelEntry(modelEntry{ + model: baseModel, + defaults: GenerationDefaults{MaxOutputTokens: 65536, Thinking: true, ThinkingLevel: true, ThinkingLevels: []int64{1, 2, 3, 4}}, + }) + account := &Account{ + ID: "acc@example.com", Config: AccountConfig{Enabled: true}, State: AccountReady, + Models: []Model{baseModel, entry.model}, modelAccessGeneration: 1, + } + pool := NewAccountPool([]*Account{account}, 1) + lease, _, err := pool.TryAcquireFor(context.Background(), AccountSelection{ModelID: entry.model.ID, Method: "generateContent"}) + if err != nil || lease == nil { + t.Fatalf("lease=%v err=%v", lease, err) + } + defer func() { _ = lease.Release() }() + client := NewClient(ClientOptions{Transport: transport, Protected: transport}) + client.setModelCatalog(account.ID, modelCatalog{ + models: []Model{baseModel, entry.model}, + entries: map[string]modelEntry{entry.model.ID: entry}, + }) + stream, err := client.Generate(ContextWithAccountLease(context.Background(), lease), GenerateRequest{ + ID: fmt.Sprintf("req-%s", test.name), AccountID: account.ID, Model: entry.model.ID, + Contents: []Content{{Role: RoleUser, Parts: []Part{{Text: "Read config.yaml"}}}}, + Tools: Tools{Functions: []FunctionDeclaration{{Name: "read_file"}}}, + }) + if err != nil { + t.Fatal(err) + } + var text strings.Builder + var calls []*FunctionCall + var finish string + for event := range stream { + switch event.Kind { + case EventText: + text.WriteString(event.Text) + case EventToolCall: + calls = append(calls, event.FunctionCall) + case EventFinish: + finish = event.FinishReason + case EventError: + t.Fatal(event.Err) + } + } + if text.String() != "Checking file.\n" { + t.Fatalf("text=%q", text.String()) + } + if len(calls) != 1 || calls[0].Name != "read_file" { + t.Fatalf("calls=%+v", calls) + } + var gotArgs bytes.Buffer + if err := json.Compact(&gotArgs, calls[0].Arguments); err != nil || gotArgs.String() != `{"path":"config.yaml"}` { + t.Fatalf("args=%s err=%v", string(calls[0].Arguments), err) + } + if finish != "stop" { + t.Fatalf("finish=%q", finish) + } + }) + } +} diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index 294d851f..d6282026 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -867,6 +867,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan } } var usage *Usage + var previousUsage *Usage var finish *Event var output Output // turn 是按上游顺序未合并的本轮 Part 与轮次令牌,生成完整结束后保存供下一轮补回 @@ -887,9 +888,16 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan matchedStopSequence := "" emitEvent := func(event Event) error { turn.turnToken = cmp.Or(event.TurnToken, turn.turnToken) + if previousUsage != nil && event.FrameUsage != nil { + merged := mergeContinuedUsage(*previousUsage, *event.FrameUsage) + event.FrameUsage = &merged + } switch event.Kind { case EventUsage: value := *event.Usage + if previousUsage != nil { + value = mergeContinuedUsage(*previousUsage, value) + } usage = &value return nil case EventFinish: @@ -929,11 +937,56 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return send(event) } } - emit := stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) - err := decodeStream(observeStreamActivity(ctx, response.Body), emit) - for regenerated := 0; regenerated < regenerations && regenerable(ctx, err, finish, committed); regenerated++ { + var stripper *continuationPrefixStripper + roundEmit := func() func(Event) error { + emit := stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) + if stripper == nil { + return emit + } + return func(event Event) error { + turn.turnToken = cmp.Or(event.TurnToken, turn.turnToken) + return stripper.filter(event, emit) + } + } + err := decodeStream(observeStreamActivity(ctx, response.Body), roundEmit()) + for regenerated := 0; regenerated < regenerations; regenerated++ { + emittedText := "" + recoverCommitted := false + if (!entry.model.Capabilities["assistant_route"] || err != nil) && regenerable(ctx, err, finish, committed) { + } else if committed && !entry.model.Capabilities["assistant_route"] && regenerable(ctx, err, finish, false) { + var ok bool + if emittedText, ok = committedTextOnly(output); !ok { + break + } + recoverCommitted = true + } else { + break + } stopClose() _ = response.Body.Close() + if recoverCommitted { + trimTrailingSignatureParts(&output.Parts) + trimTrailingSignatureParts(&turn.parts) + turn.turnToken = "" + if usage != nil { + previousUsage = usage + } + wireRequest = request + wireRequest.Config.StopSequences = nil + wireRequest.turnToken = "" + contents := make([]Content, 0, len(request.Contents)+2) + contents = append(contents, request.Contents...) + contents = append(contents, + Content{Role: RoleAssistant, Parts: []Part{{Text: emittedText}}}, + Content{Role: RoleUser, Parts: []Part{{Text: partialContinuationPrompt(emittedText, finish)}}}, + ) + wireRequest.Contents = contents + usage, finish = nil, nil + stripper = newContinuationPrefixStripper(emittedText) + } else { + usage, finish, sources, turn = nil, nil, nil, modelTurn{} + stripper = nil + } if response, decodeStream, err = sendRequest(); err != nil { if ctx.Err() == nil { _ = send(Event{Kind: EventError, Err: err}) @@ -941,10 +994,8 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return } stopClose = closeOnCancel(response) - usage, finish, sources, turn = nil, nil, nil, modelTurn{} matcher = newStopSequenceMatcher(request.Config.StopSequences) - emit = stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) - err = decodeStream(observeStreamActivity(ctx, response.Body), emit) + err = decodeStream(observeStreamActivity(ctx, response.Body), roundEmit()) } if releaseSources() != nil { return @@ -971,6 +1022,9 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return } if finish != nil && contentPolicyFinish(finish.FinishReason) { + if usage == nil { + usage = previousUsage + } if usage != nil && send(Event{Kind: EventUsage, Usage: usage}) != nil { return } @@ -979,6 +1033,9 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan } return } + if usage == nil { + usage = previousUsage + } if usage == nil { usage = localCompleteUsage(request, output) } else if usage.OutputTokensMissing { @@ -1011,6 +1068,202 @@ func contentPolicyFinish(reason string) bool { return false } +// abnormalGenerationFinish 表示结束原因属于异常工具调用或格式错误响应:格式错误的函数调用、意外的工具调用与格式错误的响应 +func abnormalGenerationFinish(reason string) bool { + switch reason { + case "malformed_function_call", "unexpected_tool_call", "malformed_response": + return true + } + return false +} + +// committedTextOnly 返回已向客户端提交的纯文本输出,没有可见文本或含函数调用、媒体、代码与服务端工具步骤时返回 false +func committedTextOnly(output Output) (string, bool) { + visible := output.Visible() + if len(visible) == 0 { + return "", false + } + var text strings.Builder + for _, part := range visible { + if part.SignatureOnly() { + continue + } + if !part.textOnly() { + return "", false + } + text.WriteString(part.Text) + } + if text.Len() == 0 { + return "", false + } + return text.String(), true +} + +// trimTrailingSignatureParts 去掉失败尝试末尾只有思考签名的 Part +func trimTrailingSignatureParts(parts *[]Part) { + items := *parts + for len(items) > 0 && items[len(items)-1].SignatureOnly() { + items = items[:len(items)-1] + } + *parts = items +} + +// partialReplyTail 返回已输出正文末尾至多 assistantPartialReplyRunes 个 Unicode 字符 +func partialReplyTail(text string) string { + runes := []rune(text) + if len(runes) > assistantPartialReplyRunes { + return string(runes[len(runes)-assistantPartialReplyRunes:]) + } + return text +} + +// partialContinuationPrompt 构造在已输出部分正文后继续生成或补发有效工具调用的用户提示 +func partialContinuationPrompt(emittedText string, finish *Event) string { + reason := "Your last turn was cut off by an internal error." + if finish != nil { + switch finish.FinishReason { + case "malformed_function_call": + reason = "Your last turn was cut off because the function call was malformed." + case "unexpected_tool_call": + reason = "Your last turn was cut off because of an unexpected tool call." + case "malformed_response": + reason = "Your last turn was cut off because the response was malformed." + } + } + return fmt.Sprintf( + "%s The user already received your reply up to the end of this text:\n\n%s\n\nContinue immediately right after without repeating it, and emit the valid tool call or remaining response.", + reason, partialReplyTail(emittedText), + ) +} + +// continuationPrefixStripper 在续写恢复轮次去掉开头重复已输出正文的前缀,尚未确定是否完全匹配时暂存前缀 +type continuationPrefixStripper struct { + targets []string + pending string + pendingSig string + active bool +} + +// newContinuationPrefixStripper 返回按已输出正文全文、去尾空白与末尾片段去重的前缀过滤器 +func newContinuationPrefixStripper(emittedText string) *continuationPrefixStripper { + if emittedText == "" { + return nil + } + tail := partialReplyTail(emittedText) + candidates := []string{ + emittedText, + strings.TrimRight(emittedText, " \t\r\n"), + tail, + strings.TrimRight(tail, " \t\r\n"), + } + targets := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if candidate == "" || slices.Contains(targets, candidate) { + continue + } + targets = append(targets, candidate) + } + if len(targets) == 0 { + return nil + } + slices.SortStableFunc(targets, func(left string, right string) int { + return cmp.Compare(len(right), len(left)) + }) + return &continuationPrefixStripper{targets: targets, active: true} +} + +// longerPrefixPending 表示 combined 是 targets[:index] 中某个更长候选的前缀 +func (s *continuationPrefixStripper) longerPrefixPending(index int, combined string) bool { + for _, longer := range s.targets[:index] { + if len(combined) < len(longer) && strings.HasPrefix(longer, combined) { + return true + } + } + return false +} + +// filter 过滤续写轮开头重复的已输出正文前缀并保留思考签名 +func (s *continuationPrefixStripper) filter(event Event, emit func(Event) error) error { + if s == nil || !s.active { + return emit(event) + } + if event.Kind != EventText { + switch event.Kind { + case EventUsage, EventReasoning, EventGrounding, EventCitation: + return emit(event) + case EventThoughtSignature: + if s.pending == "" { + return emit(event) + } + } + sig := s.pendingSig + s.pending = "" + s.pendingSig = "" + s.active = false + if sig != "" { + if err := emit(Event{Kind: EventThoughtSignature, Part: Part{ThoughtSignature: sig}}); err != nil { + return err + } + } + return emit(event) + } + if event.Transcript != nil { + s.active = false + if s.pending != "" { + if err := emit(Event{Kind: EventText, Text: s.pending, ThoughtSignature: s.pendingSig}); err != nil { + return err + } + s.pending = "" + s.pendingSig = "" + } + return emit(event) + } + combined := s.pending + event.Text + sig := cmp.Or(event.ThoughtSignature, s.pendingSig) + for index, target := range s.targets { + if strings.HasPrefix(combined, target) { + if index > 0 && s.longerPrefixPending(index, combined) { + break + } + rest := strings.TrimPrefix(combined, target) + s.pending = "" + s.pendingSig = "" + s.active = false + if rest == "" { + if sig != "" { + return emit(Event{ + Kind: EventThoughtSignature, + Part: Part{ThoughtSignature: sig}, + ProviderModel: event.ProviderModel, + TurnToken: event.TurnToken, + DebugInfo: event.DebugInfo, + ResponseID: event.ResponseID, + FrameUsage: event.FrameUsage, + FinishFrame: event.FinishFrame, + }) + } + return nil + } + event.Text = rest + event.ThoughtSignature = sig + return emit(event) + } + } + for _, target := range s.targets { + if strings.HasPrefix(target, combined) { + s.pending = combined + s.pendingSig = sig + return nil + } + } + s.pending = "" + s.pendingSig = "" + s.active = false + event.Text = combined + event.ThoughtSignature = sig + return emit(event) +} + // regenerable 表示上游以函数调用格式错误、意外的工具调用或响应格式异常结束、流在完成帧之前中断或流尾为 5xx 错误,且本次生成还没有不能丢弃的输出 func regenerable(ctx context.Context, err error, finish *Event, committed bool) bool { if err != nil { @@ -1019,7 +1272,7 @@ func regenerable(ctx context.Context, err error, finish *Event, committed bool) if ctx.Err() != nil || !errors.As(err, &interrupted) && !(errors.As(err, &rpcError) && rpcError.StatusCode >= http.StatusInternalServerError) { return false } - } else if finish == nil || finish.FinishReason != "malformed_function_call" && finish.FinishReason != "unexpected_tool_call" && finish.FinishReason != "malformed_response" { + } else if finish == nil || !abnormalGenerationFinish(finish.FinishReason) { return false } return !committed From 549b5398a0abeedd27f652471a6f8d6ae6dceb5a Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Wed, 11 Nov 2026 04:48:54 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20Carbon=20?= =?UTF-8?q?=E9=95=BF=E5=9B=9E=E7=AD=94=E7=9A=84=E8=87=AA=E5=8A=A8=E7=BB=AD?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 续写轮沿用首轮写入的 proof,Camoufox 后端沿用页面为首轮写入的 proof 直接发送 - 续写轮遇到可换账户的错误时换到其他账户,以同一续写 token 重发该轮,额度失败的账户进入冷却 - 换账户后转发、认证检查与用量明细跟随当前账户,被换下的账户立即释放 - 账户运行状态锁在请求期限内等到释放 - 协议、开发与日志文档说明续写轮的 proof、换账户与锁等待 --- docs/development.md | 2 +- docs/logging.md | 2 +- docs/protocol.md | 8 +- internal/aistudio/accounts.go | 5 +- internal/aistudio/build_embed.go | 2 +- internal/aistudio/client.go | 3 +- internal/aistudio/generate.go | 39 +++++++- internal/aistudio/imagen.go | 2 +- internal/aistudio/runtime_native.go | 21 +++-- internal/aistudio/service.go | 28 +++--- internal/aistudio/types.go | 2 + internal/aistudio/waa.go | 6 +- internal/app/runtime.go | 131 +++++++++++++++++++-------- internal/camoufoxnative/generate.go | 33 +++---- internal/camoufoxnative/protected.go | 6 +- 15 files changed, 198 insertions(+), 92 deletions(-) diff --git a/docs/development.md b/docs/development.md index 66a78909..c021070c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 更新;保存后的租约释放错误原样返回,已发布配置继续生效。 diff --git a/docs/logging.md b/docs/logging.md index 14ebdce3..69483d98 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -200,7 +200,7 @@ ERROR account@example.com WAA Worker 停止失败 | PID=18240 | 耗时=10.002s | `上游首事件到达` | 首事件等待后解出第一个语义事件 | `等待`、`事件`、`模型` | | `事件流停顿` | 已有语义事件,连续 15 秒没有下一事件 | `最近事件`、`推理`、`正文`、`网络字节`、`最近网络` | | `事件流恢复` | 停顿后解出下一语义事件 | `停顿`、`当前事件` | -| `账号切换` | 当前账户在首个上游语义事件前失败,或非流式请求的上游流中途中断 | 当前账户来源、模型和下一行原始原因 | +| `账号切换` | 当前账户在首个上游语义事件前失败,或非流式请求的上游流中途中断;`账号切换 \| 续写轮` 为自动续写轮在当前账户上失败后换账户重发 | 当前账户来源、模型和下一行原始原因 | | `备用请求发起` | 原请求等待首个语义事件达到 `FIRST_EVENT_HEDGE` 的阈值,空闲账户开始同一请求 | `账户`、`原请求已等待`、`阈值`、`模型` | | `备用请求胜出` | 备用请求先解出首个语义事件,原请求取消 | `账户`、`原请求已等待`、`模型` | | `原请求胜出` | 原请求先解出首个语义事件,备用请求取消 | `备用账户`、`模型` | diff --git a/docs/protocol.md b/docs/protocol.md index f9627e6d..ea6012cc 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 始终使用创建账户。 @@ -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: @@ -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 与错误 diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index 0b647e8b..9b6aea88 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -40,7 +40,6 @@ const ( globalCooldownKey = "*" externalLeasePoll = 100 * time.Millisecond runtimeLockPoll = 25 * time.Millisecond - runtimeLockLimit = 2 * time.Second ) // AccountState 表示账户当前是否可调度 @@ -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) } diff --git a/internal/aistudio/build_embed.go b/internal/aistudio/build_embed.go index ceaeeb2f..a44a43ad 100644 --- a/internal/aistudio/build_embed.go +++ b/internal/aistudio/build_embed.go @@ -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 单次接受的最大请求数 diff --git a/internal/aistudio/client.go b/internal/aistudio/client.go index 75d04ee0..029610e7 100644 --- a/internal/aistudio/client.go +++ b/internal/aistudio/client.go @@ -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 写回和真实网络发送 diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index 294d851f..4954ddf1 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -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 { @@ -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() }) diff --git a/internal/aistudio/imagen.go b/internal/aistudio/imagen.go index ec435eea..f33987f7 100644 --- a/internal/aistudio/imagen.go +++ b/internal/aistudio/imagen.go @@ -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) } diff --git a/internal/aistudio/runtime_native.go b/internal/aistudio/runtime_native.go index 9240bc4e..d02d151c 100644 --- a/internal/aistudio/runtime_native.go +++ b/internal/aistudio/runtime_native.go @@ -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 { @@ -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 @@ -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) @@ -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 } @@ -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 diff --git a/internal/aistudio/service.go b/internal/aistudio/service.go index 084004ed..df789bef 100644 --- a/internal/aistudio/service.go +++ b/internal/aistudio/service.go @@ -123,21 +123,22 @@ func (t *WorkerProtectedTransport) DoProtected(ctx context.Context, request Gene page = "" } modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") - return t.doPrepared(ctx, prompt, page, proofField, AccountSelection{ + return t.doPrepared(ctx, prompt, page, proofField, request.continuationProof, AccountSelection{ ModelID: modelID, Method: "generateContent", AccountID: strings.TrimSpace(request.AccountID), }, rpc) } -// doPrepared 为请求写入 proof 后经账户的 WAA 运行时发送,并写回运行时导出的 Cookie +// doPrepared 为请求写入 proof 后经账户的 WAA 运行时发送,并写回运行时导出的 Cookie;proof 非空时写入该 proof func (t *WorkerProtectedTransport) doPrepared( ctx context.Context, prompt string, page string, proofField int, + proof string, selection AccountSelection, rpc RPCRequest, ) (*RPCResponse, error) { - lease, worker, rpc, err := t.prepareProtectedRequest(ctx, prompt, page, proofField, selection, rpc) + lease, worker, rpc, proof, err := t.prepareProtectedRequest(ctx, prompt, page, proofField, proof, selection, rpc) if err != nil { return nil, err } @@ -156,7 +157,7 @@ func (t *WorkerProtectedTransport) doPrepared( } reportRequestPhase(ctx, RequestPhaseSendingUpstream) response, err := worker.SendProtected(ctx, ProtectedRequest{ - URL: rpc.URL, Headers: headers, Body: rpc.Body, Prompt: prompt, PagePrompt: page, ProofField: proofField, + URL: rpc.URL, Headers: headers, Body: rpc.Body, Prompt: prompt, PagePrompt: page, ProofField: proofField, Proof: proof, }) if err != nil { return nil, err @@ -197,30 +198,31 @@ func (t *WorkerProtectedTransport) prepareProtectedRequest( prompt string, page string, proofField int, + proof string, selection AccountSelection, rpc RPCRequest, -) (*AccountLease, ProtectedPreparer, RPCRequest, error) { +) (*AccountLease, ProtectedPreparer, RPCRequest, string, error) { lease, ok := AccountLeaseFromContext(ctx) if !ok { - return nil, nil, RPCRequest{}, fmt.Errorf("受保护请求缺少账户租约") + return nil, nil, RPCRequest{}, "", fmt.Errorf("受保护请求缺少账户租约") } if err := validateLeaseSelection(lease, selection); err != nil { - return nil, nil, RPCRequest{}, err + return nil, nil, RPCRequest{}, "", err } worker, err := t.workers.Worker(ctx, lease.Account().ID, selection.ModelID) if err != nil { - return nil, nil, RPCRequest{}, fmt.Errorf("获取账户 WAA preparer: %w", err) + return nil, nil, RPCRequest{}, "", fmt.Errorf("获取账户 WAA preparer: %w", err) } reportRequestPhase(ctx, RequestPhasePreparingWAA) prepared, err := worker.Prepare(ctx, ProtectedRequest{ URL: rpc.URL, Headers: rpc.Header.Clone(), Body: rpc.Body, - Prompt: prompt, PagePrompt: page, ProofField: proofField, + Prompt: prompt, PagePrompt: page, ProofField: proofField, Proof: proof, }) if err != nil { - return nil, nil, RPCRequest{}, fmt.Errorf("准备 fresh WAA proof: %w", err) + return nil, nil, RPCRequest{}, "", fmt.Errorf("准备 fresh WAA proof: %w", err) } if prepared.Headers == nil || len(prepared.Body) == 0 { - return nil, nil, RPCRequest{}, fmt.Errorf("WAA preparer 返回空请求") + return nil, nil, RPCRequest{}, "", fmt.Errorf("WAA preparer 返回空请求") } requestHeaders := rpc.Header rpc.AccountID = lease.Account().ID @@ -233,12 +235,12 @@ func (t *WorkerProtectedTransport) prepareProtectedRequest( } } rpc.Header.Set("Content-Type", JSONProtobufContentType) - return lease, worker, rpc, nil + return lease, worker, rpc, prepared.Proof, nil } // DoProtectedTitle 写入 GenerateTitle fresh proof 后经 Worker 页面发送请求,binding 为提示词与回答以空格连接,页面输入框为空 func (t *WorkerProtectedTransport) DoProtectedTitle(ctx context.Context, request TitleRequest, rpc RPCRequest) (*RPCResponse, error) { - return t.doPrepared(ctx, request.Prompt+" "+request.Answer, "", 3, AccountSelection{ModelID: request.Model}, rpc) + return t.doPrepared(ctx, request.Prompt+" "+request.Answer, "", 3, "", AccountSelection{ModelID: request.Model}, rpc) } func bindingPrompt(request GenerateRequest) (string, error) { diff --git a/internal/aistudio/types.go b/internal/aistudio/types.go index 68bc070c..641cff76 100644 --- a/internal/aistudio/types.go +++ b/internal/aistudio/types.go @@ -452,6 +452,8 @@ type GenerateRequest struct { continuationRound int // continuationToken 为上一轮响应给出的续写 token continuationToken string + // continuationProof 为首轮请求写入的 WAA proof,各续写轮沿用 + continuationProof string // restoreModelTurns 表示 Playground 请求按本进程保存的生成结果补回历史模型轮 restoreModelTurns bool // turnToken 为历史中最后一个模型轮的轮次令牌 diff --git a/internal/aistudio/waa.go b/internal/aistudio/waa.go index 7c739f82..f73272d6 100644 --- a/internal/aistudio/waa.go +++ b/internal/aistudio/waa.go @@ -6,7 +6,7 @@ import ( "time" ) -// ProtectedRequest 表示需要 WAA 保护的 AI Studio 请求 +// ProtectedRequest 表示需要 WAA 保护的 AI Studio 请求,Proof 非空时写入该 proof 并直接发送 type ProtectedRequest struct { URL string Headers http.Header @@ -14,6 +14,7 @@ type ProtectedRequest struct { Prompt string PagePrompt string ProofField int + Proof string } // followUp 是受保护请求发出 Delay 后以相同请求头发出的伴随请求,响应被丢弃 @@ -37,10 +38,11 @@ func followUpFrom(ctx context.Context) (followUp, bool) { return item, ok } -// PreparedProtectedRequest 表示已写入 fresh proof 的请求 +// PreparedProtectedRequest 表示已写入 proof 的请求与写入的 proof,由页面界面运行的请求 proof 为空 type PreparedProtectedRequest struct { Body []byte Headers http.Header + Proof string } // WorkerPhase 表示账户 runtime 当前阶段 diff --git a/internal/app/runtime.go b/internal/app/runtime.go index d4217cd8..ccd700c7 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -1728,6 +1728,7 @@ type generateAttempt struct { lease *aistudio.AccountLease selection aistudio.AccountSelection hedge bool + unbound bool accountLabel string proxy string workerGeneration uint64 @@ -1740,6 +1741,8 @@ type generateAttempt struct { err error cancel context.CancelFunc done chan struct{} + // serving 是正在生成回答的账户租约,续写轮换账户后为新账户的租约 + serving atomic.Pointer[aistudio.AccountLease] } // generateRetry 是生成请求跨尝试的换号与恢复状态 @@ -2009,7 +2012,7 @@ func (service *trackedService) generateWithRetry( } service.forwardEvents( clientCtx, requestCtx, func() { winner.cancel(); cancel() }, request.ID, - winner.first, winner.source, destination, winner.lease, winner.copies, winner.activity, modelID, + winner.first, winner.source, destination, &winner.serving, winner.copies, winner.activity, modelID, ) } @@ -2043,12 +2046,14 @@ func (service *trackedService) startAttempt( selection aistudio.AccountSelection, hedge bool, ) *generateAttempt { + unbound := strings.TrimSpace(request.AccountID) == "" && resourceID == "" request.AccountID = lease.Account().ID attempt := &generateAttempt{ - lease: lease, selection: selection, hedge: hedge, accountLabel: lease.Account().Config.Label, + lease: lease, selection: selection, hedge: hedge, unbound: unbound, accountLabel: lease.Account().Config.Label, proxy: service.proxies.Binding(request.AccountID), workerGeneration: service.workers.WorkerGeneration(request.AccountID), startedAt: time.Now(), activity: &upstreamActivity{}, done: make(chan struct{}), } + attempt.serving.Store(lease) attemptCtx, cancel := context.WithCancel(requestCtx) attempt.cancel = cancel go func() { @@ -2081,18 +2086,7 @@ func (service *trackedService) runAttempt( } originalContents := request.Contents ctx = aistudio.ContextWithAccountLease(ctx, lease) - ctx = aistudio.ContextWithUpstreamModeObserver(ctx, func(method, mode, reason string) { - level := "INFO" - message := fmt.Sprintf("上游调用 | 通道=%s | 模式=%s | RPC=%s", lease.Channel(), mode, method) - if reason != "" && service.pool.BuildEnabled() { - level = "WARN" - if fallbackReason != "" { - reason = fallbackReason + "; " + reason - } - message += " | 回退流式 | 原因=" + reason - } - service.requests.logRequestProgress(request.ID, accountLabel, level, message) - }) + ctx = aistudio.ContextWithUpstreamModeObserver(ctx, service.upstreamModeObserver(request.ID, lease, fallbackReason)) var err error copiedFileCount := 0 if resourceID != "" { @@ -2104,6 +2098,12 @@ func (service *trackedService) runAttempt( } } mediaCount, mediaBytes := inlineMediaInput(request.Contents) + if attempt.unbound && mediaCount == 0 { + failed := make(map[string]struct{}) + ctx = aistudio.ContextWithContinuationAccounts(ctx, func(roundCtx context.Context, err error) (context.Context, error) { + return service.continuationAccount(roundCtx, request.ID, modelID, attempt, failed, err) + }) + } if err == nil && mediaCount > 0 { uploadStartedAt := time.Now() request.Contents, attempt.copies, err = service.service.(interface { @@ -2169,6 +2169,73 @@ func (service *trackedService) runAttempt( attempt.err = err } +// upstreamModeObserver 返回把租约账户的上游调用方式记入请求日志的观察函数,回退流式时附上 fallbackReason +func (service *trackedService) upstreamModeObserver(requestID string, lease *aistudio.AccountLease, fallbackReason string) func(string, string, string) { + accountLabel := lease.Account().Config.Label + return func(method, mode, reason string) { + level := "INFO" + message := fmt.Sprintf("上游调用 | 通道=%s | 模式=%s | RPC=%s", lease.Channel(), mode, method) + if reason != "" && service.pool.BuildEnabled() { + level = "WARN" + if fallbackReason != "" { + reason = fallbackReason + "; " + reason + } + message += " | 回退流式 | 原因=" + reason + } + service.requests.logRequestProgress(requestID, accountLabel, level, message) + } +} + +// continuationAccount 把续写轮在当前账户上的失败记为一次尝试,额度失败时冷却该账户,释放其租约后从未在续写轮失败过的其余账户取得租约,返回改由该账户发送续写轮的上下文 +func (service *trackedService) continuationAccount(ctx context.Context, requestID string, modelID string, attempt *generateAttempt, failed map[string]struct{}, err error) (context.Context, error) { + current := attempt.serving.Load() + label := current.Account().Config.Label + api.AddAccessLogAttempt(ctx, api.RequestAttempt{Account: label, Channel: string(current.Channel()), Error: err.Error()}) + failed[current.Account().ID] = struct{}{} + if cooldown, quotaFailure := aistudio.QuotaCooldownForError(err, time.Now()); quotaFailure { + if _, stateErr := service.markQuotaCooldown(requestID, modelID, current, cooldown); stateErr != nil { + return nil, stateErr + } + } + if releaseErr := current.Release(); releaseErr != nil { + return nil, releaseErr + } + selection := aistudio.AccountSelection{ModelID: modelID, Method: "generateContent", PlaygroundOnly: true, AllowedAccountIDs: []string{}} + enabled, _ := service.pool.EnabledAccounts() + for _, accountID := range enabled { + if _, exists := failed[accountID]; !exists { + selection.AllowedAccountIDs = append(selection.AllowedAccountIDs, accountID) + } + } + lease, acquireErr := service.acquireWarmLease(ctx, selection, false) + if acquireErr != nil { + return nil, acquireErr + } + attempt.serving.Store(lease) + service.requests.logRequestProgress(requestID, label, "WARN", fmt.Sprintf( + "账号切换 | 续写轮 | 模型=%s\n原因: %s", modelID, strings.TrimSpace(err.Error()), + )) + service.markAttemptAccount(ctx, requestID, modelID, lease) + next := aistudio.ContextWithAccountLease(ctx, lease) + return aistudio.ContextWithUpstreamModeObserver(next, service.upstreamModeObserver(requestID, lease, "")), nil +} + +// markQuotaCooldown 按额度失败冷却租约账户在该模型或全局范围上的调度并记入请求日志,返回实际生效的恢复时刻 +func (service *trackedService) markQuotaCooldown(requestID, modelID string, lease *aistudio.AccountLease, cooldown aistudio.QuotaCooldown) (time.Time, error) { + scope := lease.CooldownScope(modelID) + scopeLabel := scope + if cooldown.Global { + scope, scopeLabel = "", "全局" + } + until, err := service.pool.MarkQuotaCooldownIfGeneration(lease.Account().ID, scope, lease.ModelAccessGeneration(), lease.CheckedAt(), cooldown) + if err == nil && !until.IsZero() { + service.requests.logRequestProgress(requestID, lease.Account().Config.Label, "WARN", fmt.Sprintf( + "账号冷却 | 类型=%s | 范围=%s | 恢复=%s", cooldown.Kind, scopeLabel, until.Local().Format(time.RFC3339), + )) + } + return until, err +} + // quotaCooldownError 是带恢复时刻的额度失败,恢复时刻取本次请求中额度失败账户最早的冷却恢复时刻 type quotaCooldownError struct { err error @@ -2230,15 +2297,7 @@ func (service *trackedService) settleAttempt( } cooldown, quotaFailure := aistudio.QuotaCooldownForError(err, time.Now()) if quotaFailure { - modelAccessScope := lease.CooldownScope(modelID) - scopeLabel := modelAccessScope - if cooldown.Global { - modelAccessScope = "" - scopeLabel = "全局" - } - until, stateErr := service.pool.MarkQuotaCooldownIfGeneration( - accountID, modelAccessScope, lease.ModelAccessGeneration(), lease.CheckedAt(), cooldown, - ) + until, stateErr := service.markQuotaCooldown(requestID, modelID, lease, cooldown) if stateErr != nil { err = errors.Join(err, stateErr) retryable = false @@ -2248,10 +2307,6 @@ func (service *trackedService) settleAttempt( retry.quotaUntil = until } err = "aCooldownError{err: err, until: retry.quotaUntil} - service.requests.logRequestProgress(requestID, accountLabel, "WARN", fmt.Sprintf( - "账号冷却 | 类型=%s | 范围=%s | 恢复=%s", - cooldown.Kind, scopeLabel, until.Local().Format(time.RFC3339), - )) } if !cooldown.Global && service.pool.AccountChannelAvailable(accountID, attempt.selection) { delete(retry.attempted, accountID) @@ -2286,7 +2341,7 @@ func (service *trackedService) settleAttempt( if !quotaFailure && !buildRejected { retry.attemptFailure = err } - if releaseErr := lease.Release(); releaseErr != nil { + if releaseErr := attempt.serving.Load().Release(); releaseErr != nil { return attemptOutcome{err: errors.Join(err, releaseErr), accountID: accountID, accountLabel: accountLabel} } recoverWorker := false @@ -2526,7 +2581,7 @@ func (service *trackedService) forwardEvents( first aistudio.Event, source <-chan aistudio.Event, destination chan<- aistudio.Event, - lease *aistudio.AccountLease, + serving *atomic.Pointer[aistudio.AccountLease], temporaryCopies *aistudio.TemporaryFileCopies, activity *upstreamActivity, requestedModelID string, @@ -2534,8 +2589,7 @@ func (service *trackedService) forwardEvents( state := "completed" var requestErr error terminal := false - accountLabel := lease.Account().Config.Label - accessGeneration := lease.ModelAccessGeneration() + accountLabel := func() string { return serving.Load().Account().Config.Label } modelID := strings.TrimPrefix(strings.TrimSpace(first.ProviderModel), "models/") var lastEventAt time.Time lastEventKind := "-" @@ -2565,10 +2619,10 @@ func (service *trackedService) forwardEvents( api.SetAccessLogGenerationResult(requestCtx, usage, toolCalls) if temporaryCopies != nil { if err := temporaryCopies.Cleanup(); err != nil { - service.requests.log(accountLabel, "WARN", "临时文件清理失败 | 错误="+err.Error()) + service.requests.log(accountLabel(), "WARN", "临时文件清理失败 | 错误="+err.Error()) } } - if err := lease.Release(); err != nil { + if err := serving.Load().Release(); err != nil { state = "failed" requestErr = errors.Join(requestErr, err) if clientCtx.Err() == nil { @@ -2597,7 +2651,7 @@ func (service *trackedService) forwardEvents( case <-stall: stalled = true stallTimer.Reset(waitReportInterval) - service.requests.logRequestProgress(requestID, accountLabel, "WARN", fmt.Sprintf( + service.requests.logRequestProgress(requestID, accountLabel(), "WARN", fmt.Sprintf( "事件流停顿 | 模型=%s | 已等待=%s | 最近事件=%s | 推理=%d | 正文=%d | %s", modelID, time.Since(lastEventAt).Round(time.Second), lastEventKind, reasoningEvents, contentEvents, activity.logFields(time.Now()), @@ -2623,7 +2677,7 @@ func (service *trackedService) forwardEvents( } now := time.Now() if stalled { - service.requests.logRequestProgress(requestID, accountLabel, "INFO", fmt.Sprintf( + service.requests.logRequestProgress(requestID, accountLabel(), "INFO", fmt.Sprintf( "事件流恢复 | 模型=%s | 停顿=%s | 当前事件=%s", modelID, now.Sub(lastEventAt).Round(time.Millisecond), event.Kind, )) @@ -2641,14 +2695,14 @@ func (service *trackedService) forwardEvents( case aistudio.EventToolCall: toolCalls++ } - api.SetAccessLogTarget(requestCtx, event.ProviderModel, lease.Account().Config.Label) + api.SetAccessLogTarget(requestCtx, event.ProviderModel, accountLabel()) if terminal { continue } if event.Kind == aistudio.EventError { requestErr = event.Err if aistudio.DefinitiveAuthenticationFailure(event.Err) { - if stateErr := lease.ConfirmAuthenticationFailure(requestCtx, event.Err); stateErr != nil { + if stateErr := serving.Load().ConfirmAuthenticationFailure(requestCtx, event.Err); stateErr != nil { requestErr = errors.Join(requestErr, stateErr) event.Err = requestErr } @@ -2675,8 +2729,9 @@ func (service *trackedService) forwardEvents( } if !verified && event.Kind == aistudio.EventFinish { verified = true + lease := serving.Load() service.markModelAccessVerifiedAsync( - lease.Account().ID, accountLabel, lease.CooldownScope(requestedModelID), accessGeneration, lease.CheckedAt(), + lease.Account().ID, lease.Account().Config.Label, lease.CooldownScope(requestedModelID), lease.ModelAccessGeneration(), lease.CheckedAt(), ) } } diff --git a/internal/camoufoxnative/generate.go b/internal/camoufoxnative/generate.go index 61ca726c..73ebdb9a 100644 --- a/internal/camoufoxnative/generate.go +++ b/internal/camoufoxnative/generate.go @@ -122,7 +122,7 @@ func nextChatAction(previous, contents []json.RawMessage) chatAction { return chatNew } -// generateArmFunction 是在页面上为下一次 Run 安装一次性钩子的函数声明:官网高层 proof 函数下一次被调用时记下请求已开始并以本请求的 digest 取 proof,proof 返回后下一个 GenerateContent XHR 发送时把正文换成 head + proof + tail,并在该 XHR 上监听响应,经通道先发出 {sent: true},再依次发出响应头、正文块与结束标记;参数依次为页面对象、请求 ID、digest、head、tail 与通道 +// generateArmFunction 是在页面上为下一次 Run 安装一次性钩子的函数声明:官网高层 proof 函数下一次被调用时记下请求已开始并以本请求的 digest 取 proof,proof 返回后下一个 GenerateContent XHR 发送时把正文换成 head + proof + tail,并在该 XHR 上监听响应,经通道先发出带 proof 的 {sent: true, proof},再依次发出响应头、正文块与结束标记;参数依次为页面对象、请求 ID、digest、head、tail 与通道 const generateArmFunction = `(page, id, digest, head, tail, emit) => { const makerSuite = window.default_MakerSuite; const open = XMLHttpRequest.prototype.open; @@ -161,7 +161,7 @@ const generateArmFunction = `(page, id, digest, head, tail, emit) => { changed(); request.resume?.(); }); - emit(JSON.stringify({sent: true})); + emit(JSON.stringify({sent: true, proof})); return send.call(xhr, head + JSON.stringify(proof) + tail); }; } @@ -217,7 +217,7 @@ func (worker *Worker) generate(ctx context.Context, tab *chatTab, turn GenerateT unboost() return nil, worker.abandonProtectedRequest(ctx, tab.contextID, tab.page, requestID, err) } - err = worker.waitSent(ctx, tab, requestID, channel) + proof, err := worker.waitSent(ctx, tab, requestID, channel) unboost() if err != nil { return nil, worker.abandonProtectedRequest(ctx, tab.contextID, tab.page, requestID, fmt.Errorf("等待页面应用发出 GenerateContent: %w", err)) @@ -239,6 +239,7 @@ func (worker *Worker) generate(ctx context.Context, tab *chatTab, turn GenerateT return &ProtectedResponse{ StatusCode: status, Header: header, + Proof: proof, Body: &protectedResponseBody{ ctx: bodyCtx, cancel: cancelBody, worker: worker, contextID: tab.contextID, page: tab.page, requestID: requestID, channel: channel, stop: func() error { return worker.stopTurn(tab) }, release: func(done bool) { worker.releaseChat(tab, done && status == http.StatusOK) }, @@ -357,44 +358,44 @@ func (worker *Worker) waitRunnable(ctx context.Context, tab *chatTab) error { } } -// waitSent 等页面应用发出本轮 GenerateContent;页面忙于处理或已开始运行时随之等待,点击后页面空闲时持续没有开始运行或弹出对话框时以页面给出的原因失败 -func (worker *Worker) waitSent(ctx context.Context, tab *chatTab, requestID string, channel *bidiChannel) error { +// waitSent 等页面应用发出本轮 GenerateContent 并返回页面写入的 proof;页面忙于处理或已开始运行时随之等待,点击后页面空闲时持续没有开始运行或弹出对话框时以页面给出的原因失败 +func (worker *Worker) waitSent(ctx context.Context, tab *chatTab, requestID string, channel *bidiChannel) (string, error) { for checks := 1; ; checks++ { pollCtx, cancel := context.WithTimeout(ctx, pagePollInterval) message, err := readProtectedMessage(pollCtx, channel) cancel() if err != nil && (ctx.Err() != nil || !errors.Is(err, context.DeadlineExceeded)) { - return err + return "", err } if err == nil { - return sentError(message) + return sentProof(message) } state, err := readRunState(ctx, worker.client, tab.contextID, tab.page, requestID) if err != nil { - return err + return "", err } if state.Started || state.Running { message, err := readProtectedMessage(ctx, channel) if err != nil { - return err + return "", err } - return sentError(message) + return sentProof(message) } if len(state.Dialogs) > 0 || checks == runSettleChecks { - return fmt.Errorf("点击 Run 后页面没有开始运行: %s", state.reason()) + return "", fmt.Errorf("点击 Run 后页面没有开始运行: %s", state.reason()) } } } -// sentError 返回通道上第一条消息表示的发送结果,页面已发出 GenerateContent 时为 nil -func sentError(message protectedMessage) error { +// sentProof 返回通道上第一条消息表示的发送结果,页面已发出 GenerateContent 时返回页面写入的 proof +func sentProof(message protectedMessage) (string, error) { if message.Error != "" { - return errors.New(message.Error) + return "", errors.New(message.Error) } if !message.Sent { - return errors.New("页面应用没有发出 GenerateContent") + return "", errors.New("页面应用没有发出 GenerateContent") } - return nil + return message.Proof, nil } // newChat 点击顶栏 New chat,在确认弹窗中丢弃当前对话,等页面清空并显示临时对话标识,临时对话中打开的新对话仍是临时对话 diff --git a/internal/camoufoxnative/protected.go b/internal/camoufoxnative/protected.go index 1308ec6a..f8f786a7 100644 --- a/internal/camoufoxnative/protected.go +++ b/internal/camoufoxnative/protected.go @@ -16,16 +16,18 @@ import ( "time" ) -// ProtectedResponse 表示由固定指纹浏览器流式返回的 MakerSuite 响应 +// ProtectedResponse 表示由固定指纹浏览器流式返回的 MakerSuite 响应,Proof 为界面运行时页面应用写入请求的 proof type ProtectedResponse struct { StatusCode int Header http.Header Body io.ReadCloser + Proof string } -// protectedMessage 是受保护请求通道上以 { 开头的控制消息:界面运行时先是发出标记,之后是状态码与响应头,最后是结束标记或错误;其余消息为 Base64 正文块 +// protectedMessage 是受保护请求通道上以 { 开头的控制消息:界面运行时先是带 proof 的发出标记,之后是状态码与响应头,最后是结束标记或错误;其余消息为 Base64 正文块 type protectedMessage struct { Sent bool `json:"sent"` + Proof string `json:"proof"` Status int `json:"status"` Headers [][2]string `json:"headers"` Done bool `json:"done"` From 7df460a8eeca6beb12e4153605609c225f3081e9 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Wed, 11 Nov 2026 02:04:44 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=A5?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B4=E6=8C=87=E5=AE=9A=E5=A4=84=E7=90=86?= =?UTF-8?q?=E8=B4=A6=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公开 API 读取 X-AIStudio-Account 请求头,账户池只在该账户上选号并与 API key 绑定取交集 - 视频后台生成与 Gemini 可续传上传沿用请求指定的账户,可续传上传完成时沿用开始请求的 key 绑定 - 指定账户不存在或不在本次请求可用的账户中时返回写明该账户的错误,补齐六种语言译文 - README 与协议文档说明请求头的用法 --- README.md | 2 +- README_en.md | 2 +- docs/protocol.md | 2 ++ internal/aistudio/accounts.go | 4 ++-- internal/aistudio/channel.go | 30 +++++++++++++++++++++++++++++- internal/aistudio/video_omni.go | 3 ++- internal/api/gemini_files.go | 8 +++++--- internal/api/middleware.go | 10 ++++++++++ internal/api/router.go | 2 +- internal/i18n/messages/de.json | 2 ++ internal/i18n/messages/en.json | 2 ++ internal/i18n/messages/fr.json | 2 ++ internal/i18n/messages/ja.json | 2 ++ internal/i18n/messages/ko.json | 2 ++ internal/i18n/messages/zh-TW.json | 2 ++ 15 files changed, 65 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 45fd6679..c404d8b4 100644 --- a/README.md +++ b/README.md @@ -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 为例: diff --git a/README_en.md b/README_en.md index 5dbbc9e2..95e4fe5e 100644 --- a/README_en.md +++ b/README_en.md @@ -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: ` 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: diff --git a/docs/protocol.md b/docs/protocol.md index ea6012cc..760124e4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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":"","password":"","remember":false}`,成功返回 `{"enabled":true,"authenticated":true,"username":""}`: diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index 9b6aea88..f12d2ae7 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -2850,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 { diff --git a/internal/aistudio/channel.go b/internal/aistudio/channel.go index ff495769..789d6fac 100644 --- a/internal/aistudio/channel.go +++ b/internal/aistudio/channel.go @@ -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() @@ -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] diff --git a/internal/aistudio/video_omni.go b/internal/aistudio/video_omni.go index 7ae32102..c80f227c 100644 --- a/internal/aistudio/video_omni.go +++ b/internal/aistudio/video_omni.go @@ -2,6 +2,7 @@ package aistudio import ( "bytes" + "cmp" "context" "crypto/rand" "errors" @@ -41,7 +42,7 @@ func (s *PooledService) generateOmniVideo(ctx context.Context, request VideoRequ return VideoOperation{}, err } selection := AccountSelection{ - ModelID: model.ID, Method: "generateContent", AccountID: strings.TrimSpace(request.AccountID), + ModelID: model.ID, Method: "generateContent", AccountID: cmp.Or(strings.TrimSpace(request.AccountID), contextAccount(ctx)), ResourceID: videoImageResourceID(request), } lease, owned, err := resolveAccountLease(ctx, s.pool, selection) diff --git a/internal/api/gemini_files.go b/internal/api/gemini_files.go index 1f6105d2..ddb68255 100644 --- a/internal/api/gemini_files.go +++ b/internal/api/gemini_files.go @@ -102,12 +102,13 @@ func resolveGeminiFileNames(ctx context.Context, service aistudio.Service, conte return nil } -// geminiUploadSession 是一次可续传上传的元数据与已接收的分块 +// geminiUploadSession 是一次可续传上传的元数据、开始请求的选号范围与已接收的分块 type geminiUploadSession struct { mu sync.Mutex alias string name string mime string + scope context.Context received int64 buffer *os.File expires time.Time @@ -279,7 +280,8 @@ func (s *server) startGeminiUpload(w http.ResponseWriter, r *http.Request) { } session := &geminiUploadSession{ alias: alias, name: geminiUploadName(metadata, "", r), - mime: firstMediaType(r.Header.Get("X-Goog-Upload-Header-Content-Type"), metadata.File.MIMEType), + mime: firstMediaType(r.Header.Get("X-Goog-Upload-Header-Content-Type"), metadata.File.MIMEType), + scope: aistudio.ContextWithSelectionScope(context.Background(), r.Context()), } id := s.uploads.start(session) w.Header().Set("X-Goog-Upload-URL", requestOrigin(r)+"/upload/v1beta/files?upload_id="+url.QueryEscape(id)+"&upload_protocol=resumable") @@ -367,7 +369,7 @@ func (s *server) handleGeminiUploadChunk(w http.ResponseWriter, r *http.Request) } content = session.buffer } - metadata, err := uploadGeminiFile(r.Context(), s.service.(aistudio.FileService), session.alias, session.name, session.mime, content) + metadata, err := uploadGeminiFile(aistudio.ContextWithSelectionScope(r.Context(), session.scope), s.service.(aistudio.FileService), session.alias, session.name, session.mime, content) s.uploads.discard(id, session) if err != nil { if shouldWriteRequestError(r, err) { diff --git a/internal/api/middleware.go b/internal/api/middleware.go index a39b939b..972333a1 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -495,6 +495,16 @@ func authMiddleware(configuredKeys string, next http.Handler) http.Handler { }) } +// accountContextMiddleware 把 X-AIStudio-Account 请求头指定的账户放进请求上下文,账户池只用该账户 +func accountContextMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if accountID := strings.TrimSpace(r.Header.Get("X-AIStudio-Account")); accountID != "" { + r = r.WithContext(aistudio.ContextWithAccount(r.Context(), accountID)) + } + next.ServeHTTP(w, r) + }) +} + // apiKeyContextMiddleware 把请求所用的已配置 API key 放进请求上下文,供账户池按 key 绑定的账户选号 func apiKeyContextMiddleware(configuredKeys string, next http.Handler) http.Handler { keys := apiKeys(configuredKeys) diff --git a/internal/api/router.go b/internal/api/router.go index 5ed5aa67..00d6a545 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -145,7 +145,7 @@ func NewHandler(service aistudio.Service, config Config) http.Handler { root := http.NewServeMux() root.Handle("GET /health", corsMiddleware(http.HandlerFunc(s.handleHealth))) - publicHandler := bodyLimitMiddleware(browserOriginMiddleware(config.APIKey, authMiddleware(config.APIKey, apiKeyContextMiddleware(config.APIKey, requestBodyMiddleware(config.Ledger, waitLaunchMiddleware(config.WaitLaunch, public)))))) + publicHandler := bodyLimitMiddleware(browserOriginMiddleware(config.APIKey, authMiddleware(config.APIKey, apiKeyContextMiddleware(config.APIKey, accountContextMiddleware(requestBodyMiddleware(config.Ledger, waitLaunchMiddleware(config.WaitLaunch, public))))))) loggedHandler := requestLoggingMiddleware(config.Admin, corsMiddleware(publicHandler)) root.Handle("/v1/", loggedHandler) root.Handle("/v1beta/", loggedHandler) diff --git a/internal/i18n/messages/de.json b/internal/i18n/messages/de.json index a1bb66bb..20658a17 100644 --- a/internal/i18n/messages/de.json +++ b/internal/i18n/messages/de.json @@ -1492,6 +1492,8 @@ "媒体目录回收失败": "Medienverzeichnis konnte nicht bereinigt werden", "账户 timezone 无效: %q": "Ungültige Zeitzone des Kontos: {0}", "%w:模型 %s 绑定的账户都无法调用": "{0}: Keines der an Modell {1} gebundenen Konten kann es aufrufen", + "%w:账户 %s 不在本次请求可用的账户中": "{0}: Konto {1} gehört nicht zu den für diese Anfrage verfügbaren Konten", + "%w:账户 %s 不存在": "{0}: Konto {1} existiert nicht", "DBSC 刷新请求失败: %w": "DBSC-Aktualisierungsanfrage fehlgeschlagen: {0}", "Google DBSC 会话刷新被拒绝": "Google hat die Aktualisierung der DBSC-Sitzung abgelehnt", "MODEL_ACCOUNTS 中模型 %s 重复": "Modell {0} ist in MODEL_ACCOUNTS doppelt vorhanden", diff --git a/internal/i18n/messages/en.json b/internal/i18n/messages/en.json index 976ae4ce..0a0d489f 100644 --- a/internal/i18n/messages/en.json +++ b/internal/i18n/messages/en.json @@ -1492,6 +1492,8 @@ "媒体目录回收失败": "Failed to reclaim the media directory", "账户 timezone 无效: %q": "Invalid account timezone: {0}", "%w:模型 %s 绑定的账户都无法调用": "{0}: none of the accounts bound to model {1} can serve it", + "%w:账户 %s 不在本次请求可用的账户中": "{0}: account {1} is not among the accounts available to this request", + "%w:账户 %s 不存在": "{0}: account {1} does not exist", "DBSC 刷新请求失败: %w": "DBSC refresh request failed: {0}", "Google DBSC 会话刷新被拒绝": "Google rejected the DBSC session refresh", "MODEL_ACCOUNTS 中模型 %s 重复": "Model {0} is duplicated in MODEL_ACCOUNTS", diff --git a/internal/i18n/messages/fr.json b/internal/i18n/messages/fr.json index b55df330..eef92ae7 100644 --- a/internal/i18n/messages/fr.json +++ b/internal/i18n/messages/fr.json @@ -1492,6 +1492,8 @@ "媒体目录回收失败": "Impossible de nettoyer le répertoire multimédia", "账户 timezone 无效: %q": "Fuseau horaire du compte non valide : {0}", "%w:模型 %s 绑定的账户都无法调用": "{0} : aucun des comptes liés au modèle {1} ne peut l’appeler", + "%w:账户 %s 不在本次请求可用的账户中": "{0} : le compte {1} ne fait pas partie des comptes disponibles pour cette requête", + "%w:账户 %s 不存在": "{0} : le compte {1} n’existe pas", "DBSC 刷新请求失败: %w": "Échec de la requête d’actualisation DBSC : {0}", "Google DBSC 会话刷新被拒绝": "Google a refusé l’actualisation de la session DBSC", "MODEL_ACCOUNTS 中模型 %s 重复": "Le modèle {0} apparaît en double dans MODEL_ACCOUNTS", diff --git a/internal/i18n/messages/ja.json b/internal/i18n/messages/ja.json index 8b2be86d..6d1b80fd 100644 --- a/internal/i18n/messages/ja.json +++ b/internal/i18n/messages/ja.json @@ -1492,6 +1492,8 @@ "媒体目录回收失败": "メディア ディレクトリを回収できませんでした", "账户 timezone 无效: %q": "アカウント timezone が無効です: {0}", "%w:模型 %s 绑定的账户都无法调用": "{0}:モデル {1} に割り当てられたアカウントはいずれも呼び出せません", + "%w:账户 %s 不在本次请求可用的账户中": "{0}:アカウント {1} はこのリクエストで使用できるアカウントに含まれていません", + "%w:账户 %s 不存在": "{0}:アカウント {1} は存在しません", "DBSC 刷新请求失败: %w": "DBSC 更新リクエストに失敗しました: {0}", "Google DBSC 会话刷新被拒绝": "Google が DBSC セッションの更新を拒否しました", "MODEL_ACCOUNTS 中模型 %s 重复": "MODEL_ACCOUNTS でモデル {0} が重複しています", diff --git a/internal/i18n/messages/ko.json b/internal/i18n/messages/ko.json index b63f15ab..7b8233da 100644 --- a/internal/i18n/messages/ko.json +++ b/internal/i18n/messages/ko.json @@ -1492,6 +1492,8 @@ "媒体目录回收失败": "미디어 디렉터리를 회수하지 못했습니다", "账户 timezone 无效: %q": "계정 timezone이 잘못되었습니다: {0}", "%w:模型 %s 绑定的账户都无法调用": "{0}: 모델 {1}에 연결된 계정을 모두 호출할 수 없습니다", + "%w:账户 %s 不在本次请求可用的账户中": "{0}: 계정 {1}은(는) 이 요청에서 사용할 수 있는 계정이 아닙니다", + "%w:账户 %s 不存在": "{0}: 계정 {1}이(가) 없습니다", "DBSC 刷新请求失败: %w": "DBSC 새로고침 요청 실패: {0}", "Google DBSC 会话刷新被拒绝": "Google에서 DBSC 세션 새로고침을 거부했습니다", "MODEL_ACCOUNTS 中模型 %s 重复": "MODEL_ACCOUNTS에 모델 {0}이(가) 중복되어 있습니다", diff --git a/internal/i18n/messages/zh-TW.json b/internal/i18n/messages/zh-TW.json index 639bae18..0ab7a6c6 100644 --- a/internal/i18n/messages/zh-TW.json +++ b/internal/i18n/messages/zh-TW.json @@ -1492,6 +1492,8 @@ "媒体目录回收失败": "媒體目錄回收失敗", "账户 timezone 无效: %q": "帳號 timezone 無效: {0}", "%w:模型 %s 绑定的账户都无法调用": "{0}:模型 {1} 綁定的帳號都無法呼叫", + "%w:账户 %s 不在本次请求可用的账户中": "{0}:帳號 {1} 不在本次請求可用的帳號中", + "%w:账户 %s 不存在": "{0}:帳號 {1} 不存在", "DBSC 刷新请求失败: %w": "DBSC 重新整理請求失敗: {0}", "Google DBSC 会话刷新被拒绝": "Google 拒絕了 DBSC 工作階段重新整理", "MODEL_ACCOUNTS 中模型 %s 重复": "MODEL_ACCOUNTS 中模型 {0} 重複", From ef05bb024e9ed32e14d4c8713d83f2394f663f21 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Thu, 12 Nov 2026 03:58:10 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E7=BB=AD=E5=86=99?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=9A=84=E4=BB=BB=E5=8A=A1=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E4=B8=8E=E7=94=A8=E9=87=8F=E5=B9=B6=E5=90=88=E5=B9=B6=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写代码助手追加任务改用 continuationPrefixStripper 去掉重复前缀,删除重复的末尾截取函数 - 重新生成循环去掉空分支,按写代码助手、从头重新生成与续写三种情况判断 - 写代码助手在截断处仍在运行的任务于追加用户轮之前取消,动作数组只解析一次,两个重写提示共用调用格式说明 - 流中断后续写的用量计入中断那一次最近一帧的用量 - 模拟上游流的用例移出正式仓库,保留调用块尾随逗号修正的单元测试 --- internal/aistudio/assistant.go | 208 ++++------------- internal/aistudio/compatibility_test.go | 282 +----------------------- internal/aistudio/generate.go | 35 +-- 3 files changed, 77 insertions(+), 448 deletions(-) diff --git a/internal/aistudio/assistant.go b/internal/aistudio/assistant.go index 23858afa..6d657ae7 100644 --- a/internal/aistudio/assistant.go +++ b/internal/aistudio/assistant.go @@ -70,10 +70,12 @@ const ( assistantLatestPrompt = "Continue the conversation from the last turn of the conversation history at the end of your instructions." // assistantEmptyReplyPrompt 是模型一轮结束时没有正文与函数调用后在同一应用中追加的用户轮 assistantEmptyReplyPrompt = "Your last turn ended without any visible reply. Continue now and give your reply." + // assistantCallFormat 是要求重写调用时附带的 function_calls 代码块格式与交回调用的说明 + assistantCallFormat = assistantCallFence + "\n[{\"name\": \"\", \"arguments\": {}}]\n```\nRight after the block, call your " + assistantQuestionTool + " tool once with the question \"" + assistantCallQuestion + "\" and write nothing more. Do not repeat any earlier text." // assistantMalformedCallPrompt 是模型未写出 function_calls 代码块或代码块 JSON 无效时在同一应用中追加的用户轮 - assistantMalformedCallPrompt = "Your last function_calls block was missing or contained invalid JSON. Output only a valid " + assistantCallFence + " fenced JSON block:\n" + assistantCallFence + "\n[{\"name\": \"\", \"arguments\": {}}]\n```\nRight after the block, call your " + assistantQuestionTool + " tool once with the question \"" + assistantCallQuestion + "\" and write nothing more. Do not repeat any earlier text." + assistantMalformedCallPrompt = "Your last function_calls block was missing or contained invalid JSON. Output only a valid " + assistantCallFence + " fenced JSON block:\n" + assistantCallFormat // assistantInvalidToolPrompt 是模型调用了被禁止的远程工作区内置工具而非客户端函数时在同一应用中追加的用户轮 - assistantInvalidToolPrompt = "You called a forbidden remote workspace tool instead of a client function. Never call remote workspace tools. Output only a valid " + assistantCallFence + " fenced JSON block for the client function call(s):\n" + assistantCallFence + "\n[{\"name\": \"\", \"arguments\": {}}]\n```\nRight after the block, call your " + assistantQuestionTool + " tool once with the question \"" + assistantCallQuestion + "\" and write nothing more. Do not repeat any earlier text." + assistantInvalidToolPrompt = "You called a forbidden remote workspace tool instead of a client function. Never call remote workspace tools. Output only a valid " + assistantCallFence + " fenced JSON block for the client function call(s):\n" + assistantCallFormat // assistantInterruptedPrompt 是上游步骤在已输出正文后失败时在同一应用中追加的用户轮,%s 为已输出正文的末尾 assistantInterruptedPrompt = "Your last turn was cut off by an internal error. The user already received your reply up to the end of this text:\n\n%s\n\nContinue the reply right after it without repeating it." // assistantPartialReplyPrompt 是模型已输出部分正文后需要续出或修正工具调用时附带的已输出正文末尾,%s 为已输出正文的末尾 @@ -428,112 +430,14 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent var reply []Part var usage *Usage var finish *Event - stripPrefix := "" - trimmedPrefix := "" - tailPrefix := "" - trimmedTail := "" - var pendingPrefix strings.Builder - emitText := func(text string) error { - if text == "" { - return nil - } - answer.WriteString(text) - reply = append(reply, Part{Text: text}) - return emit(Event{Kind: EventText, Text: text}) - } - flushPrefix := func(dropExact bool) error { - if pendingPrefix.Len() == 0 { - stripPrefix = "" - trimmedPrefix = "" - tailPrefix = "" - trimmedTail = "" - return nil - } - buffered := pendingPrefix.String() - pendingPrefix.Reset() - target := stripPrefix - trimmed := trimmedPrefix - tail := tailPrefix - trimmedT := trimmedTail - stripPrefix = "" - trimmedPrefix = "" - tailPrefix = "" - trimmedTail = "" - if dropExact && (strings.HasPrefix(target, buffered) || - (trimmed != "" && strings.TrimRight(buffered, " \t\r\n") == trimmed) || - (tail != "" && strings.HasPrefix(tail, buffered)) || - (trimmedT != "" && strings.TrimRight(buffered, " \t\r\n") == trimmedT)) { - return nil - } - return emitText(buffered) - } - collect := func(event Event) error { + // stripper 去掉追加任务开头重复已输出正文的前缀 + var stripper *continuationPrefixStripper + record := func(event Event) error { switch event.Kind { case EventText: - if stripPrefix == "" && tailPrefix == "" { - return emitText(event.Text) - } - pendingPrefix.WriteString(event.Text) - combined := pendingPrefix.String() - if stripPrefix != "" && strings.HasPrefix(combined, stripPrefix) { - rest := combined[len(stripPrefix):] - pendingPrefix.Reset() - stripPrefix = "" - trimmedPrefix = "" - tailPrefix = "" - trimmedTail = "" - return emitText(rest) - } - if stripPrefix != "" && strings.HasPrefix(stripPrefix, combined) { - return nil - } - if trimmedPrefix != "" && stripPrefix != trimmedPrefix && strings.HasPrefix(combined, trimmedPrefix) { - rest := combined[len(trimmedPrefix):] - if strings.TrimLeft(rest, " \t\r\n") == "" { - return nil - } - if rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\r' || rest[0] == '\n' { - pendingPrefix.Reset() - stripPrefix = "" - trimmedPrefix = "" - tailPrefix = "" - trimmedTail = "" - return emitText(strings.TrimLeft(rest, " \t\r\n")) - } - } - if tailPrefix != "" { - if strings.HasPrefix(combined, tailPrefix) { - rest := combined[len(tailPrefix):] - pendingPrefix.Reset() - stripPrefix = "" - trimmedPrefix = "" - tailPrefix = "" - trimmedTail = "" - return emitText(rest) - } - if strings.HasPrefix(tailPrefix, combined) { - return nil - } - if trimmedTail != "" && tailPrefix != trimmedTail && strings.HasPrefix(combined, trimmedTail) { - rest := combined[len(trimmedTail):] - if strings.TrimLeft(rest, " \t\r\n") == "" { - return nil - } - if rest[0] == ' ' || rest[0] == '\t' || rest[0] == '\r' || rest[0] == '\n' { - pendingPrefix.Reset() - stripPrefix = "" - trimmedPrefix = "" - tailPrefix = "" - trimmedTail = "" - return emitText(strings.TrimLeft(rest, " \t\r\n")) - } - } - } - return flushPrefix(false) + answer.WriteString(event.Text) + reply = append(reply, Part{Text: event.Text}) case EventToolCall: - if err := flushPrefix(true); err != nil { - return err - } reply = append(reply, Part{FunctionCall: event.FunctionCall}) case EventUsage: if usage != nil { @@ -542,21 +446,15 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent } usage = event.Usage case EventFinish: - if err := flushPrefix(true); err != nil { - return err - } value := event finish = &value return nil - default: - if event.Kind != EventReasoning && event.Kind != EventGrounding { - if err := flushPrefix(false); err != nil { - return err - } - } } return emit(event) } + collect := func(event Event) error { + return stripper.filter(event, record) + } decoder := newDecoder(streamBody) err := DecodeAssistantStream(source, decoder, collect) // searches 是之前各任务完成的 search_web,interruption 是本轮接着写之前最后一次上游步骤失败 @@ -580,7 +478,7 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent if err != nil { break } - prompt = fmt.Sprintf(assistantInterruptedPrompt, assistantPartialTail(answer.String())) + prompt = fmt.Sprintf(assistantInterruptedPrompt, partialReplyTail(answer.String())) } else if !decoder.called && (decoder.malformed || decoder.invalidTool) { if decoder.malformed { prompt = assistantMalformedCallPrompt @@ -588,20 +486,18 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent prompt = assistantInvalidToolPrompt } if strings.TrimSpace(answer.String()) != "" { - prompt += fmt.Sprintf(assistantPartialReplyPrompt, assistantPartialTail(answer.String())) + prompt += fmt.Sprintf(assistantPartialReplyPrompt, partialReplyTail(answer.String())) } } else if len(reply) > replied || decoder.overran { break } - pendingPrefix.Reset() - stripPrefix = answer.String() - trimmedPrefix = strings.TrimRight(stripPrefix, " \t\r\n") - tailPrefix = "" - trimmedTail = "" - if tail := assistantPartialTail(stripPrefix); tail != "" && tail != stripPrefix { - tailPrefix = tail - trimmedTail = strings.TrimRight(tail, " \t\r\n") + // 任务在被禁止的工具或交回调用的提问处截断时上游仍在运行,追加用户轮之前取消 + if decoder.overran && !decoder.failed { + if err = cleanup.cancel(ctx, *cleanup.generation.Load()); err != nil { + break + } } + stripper = newContinuationPrefixStripper(answer.String()) replied = len(reply) searches = append(searches, decoder.searches...) followUp := request @@ -639,12 +535,6 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent }, nil } -// assistantPartialTail 返回用于恢复提示词的已输出正文末尾,最多保留 assistantPartialReplyRunes 个字符 -func assistantPartialTail(text string) string { - partial := []rune(text) - return string(partial[max(0, len(partial)-assistantPartialReplyRunes):]) -} - // openAssistantGeneration 在应用中提交请求的对话轮并打开任务流,返回任务 ID、任务流请求正文与任务流;continued 为 true 时在应用已有对话之后继续 func (c *Client) openAssistantGeneration(ctx context.Context, request GenerateRequest, entry modelEntry, applet string, continued bool) (string, []byte, *RPCResponse, error) { body, err := EncodeAssistantRequest(request, entry.defaults, applet, continued, strings.ToUpper(uuid.NewString())) @@ -838,6 +728,17 @@ type assistantCleanup struct { once sync.Once } +// cancel 取消应用中的任务 generation +func (cleanup *assistantCleanup) cancel(ctx context.Context, generation string) error { + body, _ := json.Marshal([]any{generation, cleanup.session.applet}) + response, err := cleanup.client.do(ctx, "CancelCodeAssistantOfflineGeneration", cleanup.accountID, "", body, false) + if err == nil { + _, err = io.Copy(io.Discard, response.Body) + err = errors.Join(err, response.Body.Close()) + } + return err +} + // run 执行一次清理:结束会话上的请求,未完成的任务在后台以独立期限取消 func (cleanup *assistantCleanup) run() { cleanup.once.Do(func() { @@ -852,13 +753,7 @@ func (cleanup *assistantCleanup) run() { go func() { ctx, cancel := context.WithTimeout(context.Background(), assistantCleanupTimeout) defer cancel() - body, _ := json.Marshal([]any{*generation, cleanup.session.applet}) - response, err := cleanup.client.do(ctx, "CancelCodeAssistantOfflineGeneration", cleanup.accountID, "", body, false) - if err == nil { - _, err = io.Copy(io.Discard, response.Body) - err = errors.Join(err, response.Body.Close()) - } - if err != nil { + if err := cleanup.cancel(ctx, *generation); err != nil { slog.Warn("Build 写代码助手清理失败", "account", cleanup.accountID, "method", "CancelCodeAssistantOfflineGeneration", "applet", cleanup.session.applet, "error", err) } }() @@ -1058,12 +953,15 @@ func (d *AssistantStreamDecoder) decodeStep(raw json.RawMessage, path string, ev invalidAction := false missingCallQuestion := false if actionRaw := rawAt(step, 9); !isJSONNull(actionRaw) && status == 1 && !d.searched[number] { - var action []json.RawMessage - if action, err = d.decodeSearch(actionRaw, path+"[9]", evidence); err != nil { + action, err := rawArray(actionRaw, path+"[9]", evidence) + if err != nil { + return nil, withMethod(err, assistantStreamMethod) + } + if err := d.decodeSearch(action, path+"[9]", evidence); err != nil { return nil, err } var isQuestion bool - if events, isQuestion, err = d.decodeQuestion(actionRaw, path+"[9]", evidence); err != nil { + if events, isQuestion, err = d.decodeQuestion(action, path+"[9]", evidence); err != nil { return nil, err } invalidAction = !isQuestion && isJSONNull(rawAt(action, 18)) && len(events) == 0 @@ -1387,11 +1285,7 @@ func validJSONEscape(rest string) bool { } // decodeQuestion 解码 ask_question 工具动作 field 22 [标题, null, 说明, [[参数名, 值]...]]:没有输出函数调用时把各问题与选项输出为正文,交回函数调用的提问不输出 -func (d *AssistantStreamDecoder) decodeQuestion(raw json.RawMessage, path string, evidence json.RawMessage) ([]Event, bool, error) { - action, err := rawArray(raw, path, evidence) - if err != nil { - return nil, false, withMethod(err, assistantStreamMethod) - } +func (d *AssistantStreamDecoder) decodeQuestion(action []json.RawMessage, path string, evidence json.RawMessage) ([]Event, bool, error) { toolRaw := rawAt(action, 21) if isJSONNull(toolRaw) || d.called { return nil, false, nil @@ -1450,22 +1344,18 @@ func (d *AssistantStreamDecoder) decodeQuestion(raw json.RawMessage, path string } // decodeSearch 把完成的 search_web 工具动作(field 19:查询、域名、摘要、[标题, 链接] 列表)并入搜索来源,查询与摘要记入 searches -func (d *AssistantStreamDecoder) decodeSearch(raw json.RawMessage, path string, evidence json.RawMessage) ([]json.RawMessage, error) { - action, err := rawArray(raw, path, evidence) - if err != nil { - return nil, withMethod(err, assistantStreamMethod) - } +func (d *AssistantStreamDecoder) decodeSearch(action []json.RawMessage, path string, evidence json.RawMessage) error { searchRaw := rawAt(action, 18) if isJSONNull(searchRaw) { - return action, nil + return nil } search, err := rawArray(searchRaw, path+"[18]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } query, err := rawString(rawAt(search, 0), path+"[18][0]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } if d.grounding == nil { d.grounding = &GroundingMetadata{} @@ -1474,33 +1364,33 @@ func (d *AssistantStreamDecoder) decodeSearch(raw json.RawMessage, path string, if summaryRaw := rawAt(search, 2); !isJSONNull(summaryRaw) { summary, err := rawString(summaryRaw, path+"[18][2]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } d.searches = append(d.searches, fmt.Sprintf(assistantSearchTranscript, query, summary)) } if resultsRaw := rawAt(search, 3); !isJSONNull(resultsRaw) { results, err := rawArray(resultsRaw, path+"[18][3]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } for index, resultRaw := range results { resultPath := fmt.Sprintf("%s[18][3][%d]", path, index) result, err := rawArray(resultRaw, resultPath, evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } title, err := rawString(rawAt(result, 0), resultPath+"[0]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } uri, err := rawString(rawAt(result, 1), resultPath+"[1]", evidence) if err != nil { - return nil, withMethod(err, assistantStreamMethod) + return withMethod(err, assistantStreamMethod) } d.grounding.Chunks = append(d.grounding.Chunks, GroundingChunk{Source: "web", URI: uri, Title: title}) } } - return action, nil + return nil } // recorded 返回记录的搜索来源与各步骤用量之和 diff --git a/internal/aistudio/compatibility_test.go b/internal/aistudio/compatibility_test.go index 7ae516c1..0b3f64dc 100644 --- a/internal/aistudio/compatibility_test.go +++ b/internal/aistudio/compatibility_test.go @@ -1,13 +1,8 @@ package aistudio import ( - "bytes" - "context" "encoding/json" - "fmt" - "io" "net/http" - "reflect" "strings" "testing" "time" @@ -302,278 +297,13 @@ func TestAssistantFailureQuotaCooldown(t *testing.T) { }) } -// fakeSequenceTransport 按顺序返回预设的受保护与普通 RPC 响应 -type fakeSequenceTransport struct { - responses []string -} - -func (t *fakeSequenceTransport) next() (*RPCResponse, error) { - body := "" - if len(t.responses) > 0 { - body = t.responses[0] - t.responses = t.responses[1:] - } - return &RPCResponse{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil -} - -func (t *fakeSequenceTransport) Do(_ context.Context, rpc RPCRequest) (*RPCResponse, error) { - switch rpc.Method { - case "CreateApplet": - return &RPCResponse{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, - Body: io.NopCloser(strings.NewReader(`["applet-1"]`)), - }, nil - case assistantStreamMethod: - return t.next() - default: - return &RPCResponse{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, - Body: io.NopCloser(strings.NewReader(`[]`)), - }, nil - } -} - -func (t *fakeSequenceTransport) DoProtected(_ context.Context, _ GenerateRequest, rpc RPCRequest) (*RPCResponse, error) { - if rpc.Method == assistantOfflineMethod { - return &RPCResponse{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{JSONProtobufContentType}}, - Body: io.NopCloser(strings.NewReader(`["gen-1","applet-1"]`)), - }, nil - } - return t.next() -} - -// TestGeneratePartialTextMalformedFunctionCallContinuation 验证已输出纯文本后遇到 malformed_function_call 时续写补出函数调用、去重重复前缀并合并用量 -func TestGeneratePartialTextMalformedFunctionCallContinuation(t *testing.T) { - malformedCode := int64(10) - stopCode := int64(1) - frame := func(parts [][]any, code *int64, usage *Usage) []any { - candidate := []any{[]any{parts, "model"}} - if code != nil { - candidate = append(candidate, *code) - } - item := make([]any, 3) - item[0] = []any{candidate} - if usage != nil { - item[2] = []any{usage.InputTokens, usage.OutputTokens, usage.TotalTokens} - } - return item - } - streamBody := func(frames ...[]any) string { - raw, err := json.Marshal([]any{frames, []any{int64(0)}}) - if err != nil { - t.Fatal(err) - } - return string(raw) - } - callPart := make([]any, 11) - callPart[10] = []any{"get_weather", encodeWireStruct(map[string]any{"city": "Tokyo"}), "call_1"} - sigPart := make([]any, 15) - sigPart[1] = "Let me check the weather. " - sigPart[14] = "sig-kept" - - round1 := streamBody( - frame([][]any{{nil, "Let me check the weather. "}}, nil, nil), - frame([][]any{{nil, "Hold on"}}, &malformedCode, &Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}), - ) - round2 := streamBody( - frame([][]any{sigPart}, nil, nil), - frame([][]any{{nil, "Hold on, calling tool now."}}, nil, nil), - frame([][]any{callPart}, &stopCode, &Usage{InputTokens: 25, OutputTokens: 8, TotalTokens: 33}), - ) - transport := &fakeSequenceTransport{responses: []string{round1, round2}} - client := NewClient(ClientOptions{Protected: transport}) - model := Model{ - ID: "gemini-test", Name: "Gemini Test", Methods: []string{"generateContent"}, OutputTokenLimit: 8192, - Capabilities: map[string]bool{"chat_model": true, "function_declarations": true, "thinking": true}, - } - client.setModelCatalog("acc@example.com", modelCatalog{ - models: []Model{model}, - entries: map[string]modelEntry{model.ID: {model: model, defaults: GenerationDefaults{MaxOutputTokens: 8192, Thinking: true}}}, - }) - stream, err := client.Generate(context.Background(), GenerateRequest{ - ID: "req-1", AccountID: "acc@example.com", Model: model.ID, - Contents: []Content{{Role: RoleUser, Parts: []Part{{Text: "Weather in Tokyo?"}}}}, - Tools: Tools{ - Functions: []FunctionDeclaration{{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`)}}, - ToolConfig: ToolConfig{Mode: "required"}, - }, - }) - if err != nil { +// TestRepairJSONStringsTrailingComma 验证调用块 JSON 去掉 } 与 ] 之前的尾随逗号后可以解码,字符串中的逗号保持原样 +func TestRepairJSONStringsTrailingComma(t *testing.T) { + var calls []map[string]any + if err := json.Unmarshal([]byte(repairJSONStrings("[{\"name\": \"read_file\", \"arguments\": {\"path\": \"a,}b.txt\",},},\n]")), &calls); err != nil { t.Fatal(err) } - var text strings.Builder - var signatures []string - var calls []FunctionCall - var usage *Usage - var finish string - for event := range stream { - switch event.Kind { - case EventText: - text.WriteString(event.Text) - if event.ThoughtSignature != "" { - signatures = append(signatures, event.ThoughtSignature) - } - case EventThoughtSignature: - signatures = append(signatures, event.ThoughtSignature) - case EventToolCall: - calls = append(calls, *event.FunctionCall) - case EventUsage: - value := *event.Usage - usage = &value - case EventFinish: - finish = event.FinishReason - case EventError: - t.Fatal(event.Err) - } - } - if text.String() != "Let me check the weather. Hold on, calling tool now." { - t.Fatalf("text=%q", text.String()) - } - if !reflect.DeepEqual(signatures, []string{"sig-kept"}) { - t.Fatalf("signatures=%v", signatures) - } - if len(calls) != 1 || calls[0].Name != "get_weather" { - t.Fatalf("calls=%+v", calls) - } - if usage == nil || usage.InputTokens != 24 || usage.OutputTokens != 13 || usage.TotalTokens != 37 { - t.Fatalf("usage=%+v", usage) - } - if finish != "stop" { - t.Fatalf("finish=%q", finish) - } -} - -// TestAssistantFunctionCallRepairAndContinuation 验证写代码助手修复 JSON 尾随逗号,并在调用块损坏或调用被禁止的工作区工具时在同一应用内续出客户端函数调用 -func TestAssistantFunctionCallRepairAndContinuation(t *testing.T) { - step := func(number, status int64, text string, action []any) []any { - item := make([]any, 16) - if text != "" { - item[0] = []any{[]any{[]any{[]any{nil, text}}, "model"}} - } - item[9] = action - item[11] = number - item[12] = status - return item - } - actionTool := func(tag, questions string) []any { - action := make([]any, 22) - action[21] = []any{"Tool", nil, nil, [][]string{{"tag", tag}, {"questions", questions}}} - return action - } - streamPayload := func(steps ...[]any) string { - raw, err := json.Marshal([]any{[]any{[]any{nil, nil, steps, "applet-1"}}, []any{int64(0)}}) - if err != nil { - t.Fatal(err) - } - return string(raw) - } - callQuestion, _ := json.Marshal([]map[string]any{{"question": assistantCallQuestion}}) - - for _, test := range []struct { - name string - responses []string - }{ - { - name: "trailing comma repair", - responses: []string{ - streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\",},},]\n```\n", nil)), - }, - }, - { - name: "malformed function_calls block", - responses: []string{ - streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{bad}]\n```\n", nil)), - streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\"}}]\n```\n", nil)), - }, - }, - { - name: "missing function_calls before ask_question", - responses: []string{ - streamPayload(step(1, 1, "Checking file.\n", actionTool(assistantQuestionTool, string(callQuestion)))), - streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\"}}]\n```\n", nil)), - }, - }, - { - name: "forbidden workspace tool", - responses: []string{ - streamPayload( - step(1, 1, "Checking file.\n", nil), - step(2, 1, "", actionTool("run_command", "ls")), - step(3, 1, "BOGUS WORKSPACE OUTPUT", nil), - ), - streamPayload(step(1, 1, "Checking file.\n```function_calls\n[{\"name\":\"read_file\",\"arguments\":{\"path\":\"config.yaml\"}}]\n```\n", nil)), - }, - }, - } { - t.Run(test.name, func(t *testing.T) { - transport := &fakeSequenceTransport{responses: test.responses} - baseModel := Model{ - ID: "gemini-3.8-pro", Name: "Gemini 3.8 Pro", Methods: []string{"generateContent"}, - Capabilities: map[string]bool{"chat_model": true, "code_assistant": true}, - } - entry := assistantModelEntry(modelEntry{ - model: baseModel, - defaults: GenerationDefaults{MaxOutputTokens: 65536, Thinking: true, ThinkingLevel: true, ThinkingLevels: []int64{1, 2, 3, 4}}, - }) - account := &Account{ - ID: "acc@example.com", Config: AccountConfig{Enabled: true}, State: AccountReady, - Models: []Model{baseModel, entry.model}, modelAccessGeneration: 1, - } - pool := NewAccountPool([]*Account{account}, 1) - lease, _, err := pool.TryAcquireFor(context.Background(), AccountSelection{ModelID: entry.model.ID, Method: "generateContent"}) - if err != nil || lease == nil { - t.Fatalf("lease=%v err=%v", lease, err) - } - defer func() { _ = lease.Release() }() - client := NewClient(ClientOptions{Transport: transport, Protected: transport}) - client.setModelCatalog(account.ID, modelCatalog{ - models: []Model{baseModel, entry.model}, - entries: map[string]modelEntry{entry.model.ID: entry}, - }) - stream, err := client.Generate(ContextWithAccountLease(context.Background(), lease), GenerateRequest{ - ID: fmt.Sprintf("req-%s", test.name), AccountID: account.ID, Model: entry.model.ID, - Contents: []Content{{Role: RoleUser, Parts: []Part{{Text: "Read config.yaml"}}}}, - Tools: Tools{Functions: []FunctionDeclaration{{Name: "read_file"}}}, - }) - if err != nil { - t.Fatal(err) - } - var text strings.Builder - var calls []*FunctionCall - var finish string - for event := range stream { - switch event.Kind { - case EventText: - text.WriteString(event.Text) - case EventToolCall: - calls = append(calls, event.FunctionCall) - case EventFinish: - finish = event.FinishReason - case EventError: - t.Fatal(event.Err) - } - } - if text.String() != "Checking file.\n" { - t.Fatalf("text=%q", text.String()) - } - if len(calls) != 1 || calls[0].Name != "read_file" { - t.Fatalf("calls=%+v", calls) - } - var gotArgs bytes.Buffer - if err := json.Compact(&gotArgs, calls[0].Arguments); err != nil || gotArgs.String() != `{"path":"config.yaml"}` { - t.Fatalf("args=%s err=%v", string(calls[0].Arguments), err) - } - if finish != "stop" { - t.Fatalf("finish=%q", finish) - } - }) + if len(calls) != 1 || calls[0]["name"] != "read_file" || calls[0]["arguments"].(map[string]any)["path"] != "a,}b.txt" { + t.Fatalf("calls=%v", calls) } } diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index d6282026..0bf9eb00 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -938,38 +938,46 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan } } var stripper *continuationPrefixStripper + // roundUsage 是本次尝试最近一帧的上游用量,流中断时没有用量事件,续写按它合并 + var roundUsage *Usage roundEmit := func() func(Event) error { emit := stopSequenceEmitter(matcher, emitEvent, &matchedStopSequence) - if stripper == nil { - return emit - } return func(event Event) error { turn.turnToken = cmp.Or(event.TurnToken, turn.turnToken) + roundUsage = cmp.Or(event.FrameUsage, roundUsage) return stripper.filter(event, emit) } } err := decodeStream(observeStreamActivity(ctx, response.Body), roundEmit()) + assistant := entry.model.Capabilities["assistant_route"] for regenerated := 0; regenerated < regenerations; regenerated++ { + // 写代码助手的调用块错误已在应用内续写,这里只重新生成发送与读取失败 + if assistant && err == nil { + break + } + // emittedText 是续写时已向客户端输出的纯文本,为空时从头重新生成 emittedText := "" - recoverCommitted := false - if (!entry.model.Capabilities["assistant_route"] || err != nil) && regenerable(ctx, err, finish, committed) { - } else if committed && !entry.model.Capabilities["assistant_route"] && regenerable(ctx, err, finish, false) { - var ok bool - if emittedText, ok = committedTextOnly(output); !ok { + if !regenerable(ctx, err, finish, committed) { + text, ok := committedTextOnly(output) + if !ok || assistant || !regenerable(ctx, err, finish, false) { break } - recoverCommitted = true - } else { - break + emittedText = text } stopClose() _ = response.Body.Close() - if recoverCommitted { + if emittedText != "" { trimTrailingSignatureParts(&output.Parts) trimTrailingSignatureParts(&turn.parts) turn.turnToken = "" - if usage != nil { + switch { + case usage != nil: previousUsage = usage + case roundUsage != nil && previousUsage != nil: + merged := mergeContinuedUsage(*previousUsage, *roundUsage) + previousUsage = &merged + case roundUsage != nil: + previousUsage = roundUsage } wireRequest = request wireRequest.Config.StopSequences = nil @@ -987,6 +995,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan usage, finish, sources, turn = nil, nil, nil, modelTurn{} stripper = nil } + roundUsage = nil if response, decodeStream, err = sendRequest(); err != nil { if ctx.Err() == nil { _ = send(Event{Kind: EventError, Err: err}) From bd7f2021cdf1c3d5f288d944a5fbf92e18bb7d21 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Thu, 12 Nov 2026 06:16:07 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Gemini=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E6=A8=A1=E5=9E=8B=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BB=AD=E5=86=99=E6=8D=A2=E8=B4=A6=E6=88=B7=E4=B8=8E=E5=A4=A7?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E9=97=AE=E9=A2=98=20-=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20Gemini=20Decision=20=E7=9A=84=E5=85=AC=E5=BC=80=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81=E7=9B=AE=E5=BD=95=E6=96=B9=E6=B3=95=E3=80=81?= =?UTF-8?q?=E5=8D=95=E4=BB=B7=E4=B8=8E=E7=AE=A1=E7=90=86=E7=AB=AF=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E7=A4=BA=E4=BE=8B=EF=BC=8C=E8=BE=93=E5=85=A5=E8=A2=AB?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E6=97=B6=E6=8C=89=E5=AE=98=E6=96=B9=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20400=20=E4=B8=8E=E6=8B=A6=E6=88=AA=E7=B1=BB=E5=88=AB?= =?UTF-8?q?=20-=20=E4=BF=AE=E6=AD=A3=E5=86=99=E4=BB=A3=E7=A0=81=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E5=8F=96=E6=B6=88=E5=B7=B2=E7=BB=93=E6=9D=9F=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=B8=8E=E6=88=AA=E6=96=AD=E5=90=8E=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E7=9A=84=E6=9D=A1=E4=BB=B6=EF=BC=8C=E8=BF=BD=E9=97=AE=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=B2=A1=E6=9C=89=E6=A8=A1=E5=9E=8B=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=8D=B3=E7=BB=93=E6=9D=9F=E6=97=B6=E5=86=8D=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=20-=20=E8=AE=A9=E7=BB=AD=E5=86=99=E8=BD=AE?= =?UTF-8?q?=E6=8D=A2=E8=B4=A6=E6=88=B7=E5=90=8E=E7=9A=84=E7=BB=AD=E5=86=99?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=A4=B1=E8=B4=A5=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E6=96=B0=E8=B4=A6=E6=88=B7=EF=BC=8C=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E8=80=97=E5=B0=BD=E6=97=B6=E8=BF=94=E5=9B=9E=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E5=8E=9F=E5=A7=8B=E9=94=99=E8=AF=AF=20-=20=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=BB=AD=E5=86=99=E5=8E=BB=E9=87=8D=E6=97=B6=E5=B0=9A?= =?UTF-8?q?=E6=9C=AA=E7=A1=AE=E8=AE=A4=E9=87=8D=E5=A4=8D=E7=9A=84=E7=9F=AD?= =?UTF-8?q?=E6=AD=A3=E6=96=87=EF=BC=8C=E7=BB=AD=E6=8E=A5=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E5=88=9B=E5=BB=BA=E7=9A=84=20agent=20?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=97=B6=E5=AF=B9=E8=AF=B7=E6=B1=82=E5=A4=B4?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E7=9A=84=E8=B4=A6=E6=88=B7=E6=8A=A5=E9=94=99?= =?UTF-8?q?=20-=20=E5=8F=AA=E4=BF=9D=E7=95=99=E4=B8=8A=E6=B8=B8=E5=9C=A8?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=B5=81=E4=B8=AD=E4=BA=A4=E9=94=99=E5=8F=91?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E7=AC=AC=E4=B8=80=E6=9D=A1=E7=94=9F=E6=88=90?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E4=B8=8E=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= =?UTF-8?q?=20-=20=E4=BB=A5=E5=8D=95=E6=AC=A1=E6=89=AB=E6=8F=8F=E6=94=B9?= =?UTF-8?q?=E5=86=99=20Gemini=20=E8=AF=B7=E6=B1=82=E7=9A=84=20snake=5Fcase?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E5=90=8D=EF=BC=8C=E9=99=8D=E4=BD=8E?= =?UTF-8?q?=E5=A4=A7=E9=99=84=E4=BB=B6=E8=AF=B7=E6=B1=82=E7=9A=84=E5=86=85?= =?UTF-8?q?=E5=AD=98=E5=B3=B0=E5=80=BC=E5=B9=B6=E6=8B=92=E7=BB=9D=E6=A0=B9?= =?UTF-8?q?=E5=80=BC=E4=B9=8B=E5=90=8E=E7=9A=84=E5=A4=9A=E4=BD=99=E5=86=85?= =?UTF-8?q?=E5=AE=B9=20-=20=E6=8A=8A=20Windows=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=89=88=E7=9A=84=20WebView2=20=E6=95=B0=E6=8D=AE=E5=86=99?= =?UTF-8?q?=E5=85=A5=E6=9C=AC=E6=9C=BA=E5=BA=94=E7=94=A8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=EF=BC=8C=E7=BC=A9=E7=9F=AD=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E7=B9=81=E5=BF=99=E6=97=B6=E7=9A=84=E7=AA=97=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 ++- README_en.md | 26 ++- docs/build.md | 2 +- docs/development.md | 2 + docs/protocol.md | 38 +++- internal/aistudio/assistant.go | 49 +++-- internal/aistudio/benefit.go | 1 + internal/aistudio/decision.go | 342 +++++++++++++++++++++++++++++++++ internal/aistudio/event.go | 10 + internal/aistudio/generate.go | 65 ++++--- internal/api/decisions.go | 297 ++++++++++++++++++++++++++++ internal/api/gemini.go | 134 ++++++++----- internal/api/gemini_files.go | 2 +- internal/api/interactions.go | 4 + internal/api/live.go | 2 +- internal/api/media_test.go | 2 +- internal/api/middleware.go | 7 +- internal/api/router.go | 1 + internal/app/auth_retry.go | 11 ++ internal/app/lifecycle.go | 7 + internal/app/runtime.go | 33 +++- internal/app/shell_desktop.go | 6 +- internal/pricing/pricing.go | 1 + web/src/access/capabilities.ts | 12 ++ web/src/access/endpoints.ts | 20 ++ web/src/access/snippets.ts | 71 +++++++ web/src/icons.ts | 2 + web/src/locales/de.ts | 6 + web/src/locales/en.ts | 6 + web/src/locales/fr.ts | 6 + web/src/locales/ja.ts | 4 + web/src/locales/ko.ts | 4 + web/src/locales/zh-CN.ts | 4 + web/src/locales/zh-TW.ts | 4 + 34 files changed, 1096 insertions(+), 111 deletions(-) create mode 100644 internal/aistudio/decision.go create mode 100644 internal/api/decisions.go diff --git a/README.md b/README.md index c404d8b4..3e3cd65d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ - **图片生成**: 支持 Nano Banana 图片生成 - **视频生成**: 视频长任务与图片转视频由 Gemini Omni 生成;Gemini Omni 通过四套生成接口接收文本、图片与视频输入,输出文本与 MP4 视频 - **Deep Research**: Pro 与 Ultra 账户可通过四套生成接口与 Interactions 的 `agent` 字段调用 Deep Research,输出研究过程、带引用的报告与图表 +- **决策模型**: `gemini-decision-1` 经 `/v1beta/decisions` 对同一份输入一次回答多个是非、单选与评分问题,返回校准后的概率与置信度;输入可以是文本、图片、音频、视频与 PDF,只计输入 token - **YouTube 输入**: YouTube 视频 URL 作为外部视频附件读取;开启 `YOUTUBE_LINKS` 后,消息文本中的 YouTube 链接同样作为视频读取 - **视频理解设置**: Gemini 与 Interactions 支持逐视频的起止时间、0–24 FPS 范围内的正数采样率和静态/Agentic 处理;Agentic 返回服务端处理步骤并支持历史续接 - **智能模型切换**: 从 AI Studio 实时发现模型并按 `model` 字段路由 @@ -510,6 +511,28 @@ curl http://127.0.0.1:2048/v1/videos \ 创建任务后通过 `GET /v1/videos/{id}` 查询状态,通过 `GET /v1/videos/{id}/content` 下载结果;`gemini-omni-1.1-flash` 等能输出视频的 Omni 模型同样经 `:predictLongRunning` 创建任务。 +### 决策 (Gemini Decision) + +```bash +curl http://127.0.0.1:2048/v1beta/decisions \ + -H "x-goog-api-key: 123" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-decision-1", + "input": "I was charged twice for my order.", + "questions": { + "is_refund": {"type": "noul", "instructions": "Is the customer asking for money back?"}, + "department": { + "type": "choice", + "instructions": "Which department should handle this request?", + "criteria": {"billing": "Payments and refunds.", "shipping": "Delivery and tracking.", "other": ""} + } + } + }' +``` + +`questions` 的键由调用方命名,回答按同样的键返回。`noul` 返回为真的概率 `noul`,`criteria` 可写 `true_description` 与 `false_description`;`choice` 的 `criteria` 为选项 ID 到说明,返回 `choice` 与各选项的 `probabilities`;`score` 的 `criteria` 为从低到高的等级说明,返回期望等级 `score`、各等级的 `probabilities` 与 `legend`。每个回答都带 `confidence`。`input` 接受字符串或 Interactions 内容块,图片、音频、视频与 PDF 以 `data`(Base64)或 `uri`(HTTPS 链接或本服务 Files API 的文件)给出并写明 `mime_type`。Google Gen AI SDK 以 `client.decisions.create` 调用。 + ## 模型 模型目录会随 AI Studio 更新,客户端从 `/v1/models` 或 `/v1beta/models` 读取当前值。下表为目录中的常用模型,模型 ID 与上限以运行时结果为准: @@ -526,6 +549,7 @@ curl http://127.0.0.1:2048/v1/videos \ | `gemma-4-31b-it` | Gemma 4 31B IT | 文本 | 262144 | 32768 | | `gemini-omni-1.1-flash` | Gemini Omni 1.1 Flash | 文本与视频 | 131072 | 65536 | | `deep-research-preview-04-2026` | Deep Research Preview | 深度研究 | 131072 | 65536 | +| `gemini-decision-1` | Gemini Decision 1 | 决策 | 262144 | 0 | | `gemini-nano-banana-2.1` | Nano Banana 2.1 | 图片 | 65536 | 65536 | | `gemini-3.1-flash-image` | Nano Banana 2 | 图片 | 65536 | 65536 | | `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite | 图片 | 65536 | 65536 | @@ -540,7 +564,7 @@ curl http://127.0.0.1:2048/v1/videos \ 目录中另有 `-latest` 别名、Robotics-ER、早期版本的图片与 TTS 模型,以及 Build 通道的 `-build` 模型。 -公开端点实现标准 `generateContent`、`countTokens`、`embedContent`、`batchEmbedContents` 和 `predictLongRunning`;`bidiGenerateContent` 与 `bidiGenerateMusic` 模型经 `/v1/live` 调用。`/v1/models` 与 `/v1beta/models` 原样汇总各账户实时上游目录;调度按目录明确提供的模型 ID、方法、能力字段和账户当前运行状态选择账户。 +公开端点实现标准 `generateContent`、`countTokens`、`embedContent`、`batchEmbedContents`、`predictLongRunning` 和 `createDecision`(`/v1beta/decisions`);`bidiGenerateContent` 与 `bidiGenerateMusic` 模型经 `/v1/live` 调用。`/v1/models` 与 `/v1beta/models` 原样汇总各账户实时上游目录;调度按目录明确提供的模型 ID、方法、能力字段和账户当前运行状态选择账户。 启用账户的订阅都不满足的模型(如只对 Google AI Ultra 开放的模型)同样列在目录中、不含 `channels`,调用时返回 403 并写明所需订阅。能力含 `output_continuation` 的模型(如 Gemini 4 Argon)与官网一样接受到 `input_token_limit` 的输出上限,未指定输出上限时取 `input_token_limit`,超过 32768 时分轮自动续写,各轮输出合并为一个响应。模型名可以带思考后缀,例如 `gemini-4-argon(low)`:括号内为 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`、`auto` 或思考预算 token 数,后缀优先于请求体中的思考参数。客户端写死的模型名可用 `MODEL_MAPPING` 映射到目录中的模型,例如 `MODEL_MAPPING=claude-haiku-5-5:gemini-3.8-flash`,别名以目标模型的字段列在模型目录中。 diff --git a/README_en.md b/README_en.md index 95e4fe5e..b48a13b2 100644 --- a/README_en.md +++ b/README_en.md @@ -85,6 +85,7 @@ The three projects connect to AI Studio, the Gemini web app and Antigravity, and - **Image Generation**: Nano Banana image generation - **Video Generation**: Gemini Omni generates video tasks and image-to-video; Gemini Omni accepts text, image, and video input and returns text and MP4 video through the four generation APIs - **Deep Research**: Pro and Ultra accounts can run Deep Research through the four generation APIs and the Interactions `agent` field, returning the research process, a cited report, and charts +- **Decision Model**: `gemini-decision-1` answers many yes/no, choice, and score questions about one shared input in a single call to `/v1beta/decisions`, returning calibrated probabilities and confidence; input can be text, images, audio, video, and PDF, and only input tokens are billed - **YouTube Input**: YouTube video URLs are read as external video attachments; with `YOUTUBE_LINKS` enabled, YouTube links in message text are read as video too - **Video Understanding Controls**: Gemini and Interactions support per-video start/end offsets, sampling rates greater than 0 and up to 24 FPS, and static/agentic processing; agentic results preserve server-side processing steps and conversation history - **Smart Model Switching**: Discover models from AI Studio and route through the `model` field @@ -510,6 +511,28 @@ curl http://127.0.0.1:2048/v1/videos \ After creating the task, poll `GET /v1/videos/{id}` and download the result from `GET /v1/videos/{id}/content`; video-capable Omni models such as `gemini-omni-1.1-flash` also create tasks through `:predictLongRunning`. +### Decisions (Gemini Decision) + +```bash +curl http://127.0.0.1:2048/v1beta/decisions \ + -H "x-goog-api-key: 123" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-decision-1", + "input": "I was charged twice for my order.", + "questions": { + "is_refund": {"type": "noul", "instructions": "Is the customer asking for money back?"}, + "department": { + "type": "choice", + "instructions": "Which department should handle this request?", + "criteria": {"billing": "Payments and refunds.", "shipping": "Delivery and tracking.", "other": ""} + } + } + }' +``` + +You name the keys of `questions`, and answers come back under the same keys. `noul` returns the probability `noul` that the statement holds, with optional `true_description` and `false_description` in `criteria`; `choice` takes option IDs mapped to descriptions in `criteria` and returns `choice` plus `probabilities` for every option; `score` takes level descriptions from low to high in `criteria` and returns the expected level `score`, per-level `probabilities`, and `legend`. Every answer includes `confidence`. `input` accepts a string or Interactions content blocks; images, audio, video, and PDF use `data` (Base64) or `uri` (an HTTPS URL or a file from this service's Files API) with `mime_type`. The Google Gen AI SDK calls it through `client.decisions.create`. + ## Models The model catalog follows AI Studio updates; clients read the current values from `/v1/models` or `/v1beta/models`. The table below lists common catalog models; runtime results are authoritative for model IDs and limits: @@ -526,6 +549,7 @@ The model catalog follows AI Studio updates; clients read the current values fro | `gemma-4-31b-it` | Gemma 4 31B IT | Text | 262144 | 32768 | | `gemini-omni-1.1-flash` | Gemini Omni 1.1 Flash | Text and video | 131072 | 65536 | | `deep-research-preview-04-2026` | Deep Research Preview | Deep research | 131072 | 65536 | +| `gemini-decision-1` | Gemini Decision 1 | Decision | 262144 | 0 | | `gemini-nano-banana-2.1` | Nano Banana 2.1 | Image | 65536 | 65536 | | `gemini-3.1-flash-image` | Nano Banana 2 | Image | 65536 | 65536 | | `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite | Image | 65536 | 65536 | @@ -540,7 +564,7 @@ The model catalog follows AI Studio updates; clients read the current values fro The catalog also includes `-latest` aliases, Robotics-ER, earlier image and TTS models, and the Build channel's `-build` models. -Public endpoints implement `generateContent`, `countTokens`, `embedContent`, `batchEmbedContents`, and `predictLongRunning`; `bidiGenerateContent` and `bidiGenerateMusic` models are called through `/v1/live`. `/v1/models` and `/v1beta/models` preserve the live upstream catalogs across accounts; scheduling uses explicit model ID, method, and capability fields plus current account runtime state. +Public endpoints implement `generateContent`, `countTokens`, `embedContent`, `batchEmbedContents`, `predictLongRunning`, and `createDecision` (`/v1beta/decisions`); `bidiGenerateContent` and `bidiGenerateMusic` models are called through `/v1/live`. `/v1/models` and `/v1beta/models` preserve the live upstream catalogs across accounts; scheduling uses explicit model ID, method, and capability fields plus current account runtime state. Models that no enabled account's subscription covers (such as models open only to Google AI Ultra) are still listed without `channels`, and calls return 403 naming the required subscription. Models with the `output_continuation` capability (such as Gemini 4 Argon) accept an output limit up to `input_token_limit`, as on the official site, and use `input_token_limit` when the request sets no output limit; beyond 32768 tokens the response continues automatically in multiple rounds and is returned as one response. Model names accept a thinking suffix such as `gemini-4-argon(low)`: the value in parentheses is `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `auto`, or a thinking budget in tokens, and it overrides thinking parameters in the request body. Model names fixed in a client can be mapped to catalog models with `MODEL_MAPPING`, for example `MODEL_MAPPING=claude-haiku-5-5:gemini-3.8-flash`; each alias is listed in the model catalog with the fields of its target. diff --git a/docs/build.md b/docs/build.md index 9ebe2779..c15eb8a5 100644 --- a/docs/build.md +++ b/docs/build.md @@ -726,7 +726,7 @@ Part 的 field 2 为文本,field 13 为 `1` 或 `true` 时是思考。只修 9. 消息列表结束后读取流尾状态,code 0 为成功,其他 code 按“错误、额度与能力边界”的表换算 HTTP 状态。上游有时在模型步骤仍为状态 `2` 时正常结束消息列表;此时以同一任务 ID 与应用再请求一次 `StreamCodeAssistantOfflineGeneration`,已完成的任务立即返回状态为 `1` 的最终快照与用量,按同一组规则继续解码,已输出的正文与函数调用不重复输出;重新打开的任务流仍在模型步骤完成前结束时,已解出函数调用或写出正文则以已有内容结束本轮,任务流关闭后取消生成任务,下一轮新建应用并带全部历史 10. 本轮回复结束或流关闭时回复已完成:依次输出暂缓的文本、记录的搜索 grounding、用量之和,再以 `stop` 结束(无法解码的 `function_calls` 块或调用了被禁止的工作区内置工具且没有解出函数调用时为 `malformed_function_call`);回复没有完成、也没有函数调用与正文时返回协议错误 “流结束前没有完成的模型回复” -一次任务的回复完成时没有输出正文、问题与函数调用(例如只有思考),或 `function_calls` 代码块缺失、无法解码,或调用了被禁止的工作区内置工具时,在同一应用中继续:没有正文、问题与函数调用时 field 1 为用户轮 `Your last turn ended without any visible reply. Continue now and give your reply.`;代码块缺失、无法解码或调用了被禁止的工作区内置工具时提交要求只输出 `function_calls` 代码块并调用 `ask_question` 的用户轮,已输出正文时附上 ``(最后至多 20000 个字符),后续任务开头重复已输出正文的前缀自动去掉;再读取新任务的任务流,按同一组规则解码,最多 3 次;用量的输入与缓存取最后一次,输出为各次之和。 +一次任务的回复完成时没有输出正文、问题与函数调用(例如只有思考),或 `function_calls` 代码块缺失、无法解码,或调用了被禁止的工作区内置工具时,在同一应用中继续:没有正文、问题与函数调用时 field 1 为用户轮 `Your last turn ended without any visible reply. Continue now and give your reply.`,继续的任务在上游没有任何模型步骤即结束(任务流与重新打开的任务流都只有用户步骤)且本轮已输出正文时同样提交这一用户轮;代码块缺失、无法解码或调用了被禁止的工作区内置工具时提交要求只输出 `function_calls` 代码块并调用 `ask_question` 的用户轮,已输出正文时附上 ``(最后至多 20000 个字符),后续任务开头重复已输出正文的前缀自动去掉;再读取新任务的任务流,按同一组规则解码,最多 3 次;用量的输入与缓存取最后一次,输出为各次之和。 本轮已输出正文、还没有输出函数调用时出现失败步骤且不带额度标记的,同样在同一应用中继续:field 1 只含下面的用户轮,`` 中为本轮已输出正文的最后至多 20000 个字符;新任务的输出接在已输出的正文之后,失败任务中已完成步骤的搜索来源与用量照常输出,用量按上一段的规则合并,搜索记录一并保存,与上一段的追加合计最多 3 次,仍失败时以该失败步骤的错误结束。失败之前只有思考时返回错误,由生成层重新生成: diff --git a/docs/development.md b/docs/development.md index c021070c..f1bb2843 100644 --- a/docs/development.md +++ b/docs/development.md @@ -476,6 +476,8 @@ Linux 的主窗口与托盘菜单把 WebKitGTK 的硬件加速策略设为 `Webv Windows 桌面版启动时经 Wails 查询 WebView2 运行时版本。查不到且以显示窗口方式启动时,用消息框询问是否安装,询问的同时在后台下载微软 Evergreen 引导程序:确定后等下载完成,后台下载失败时重新下载一次,以 `/install` 按当前用户权限安装,下载与安装进度显示在引导程序自己的窗口中,完成后创建桌面窗口;取消询问时删除已下载的引导程序。在引导程序窗口中取消安装与取消询问相同,安装失败时先以消息框说明。取消、安装失败或以隐藏方式启动时只创建托盘:服务就绪后在默认浏览器打开管理页面,双击托盘图标或再次运行可执行文件也打开管理页面。 +Windows 桌面版的 WebView2 数据写入 `%LocalAppData%\AIStudio2API\WebView2`。 + 主窗口在显示时创建,关闭时连同 WebView 一起销毁,进程只保留托盘;再次运行或从托盘显示窗口时,以上次关闭前的位置、大小与最大化状态重新创建,并直接打开关闭前所在的管理页面路由。页面经 `busy:1`、`busy:0` 消息告知桌面壳是否需要保留:有进行中的生成或管理写请求、待写入或写入失败的对话、未发送的对话输入、未保存的设置,或只存在于页面的生成图片与音频时,关闭窗口只隐藏窗口,页面报告空闲后再销毁,在此之前从托盘显示窗口取回原页面。托盘菜单在页面加载前按系统语言显示,页面加载后跟随界面语言。 Windows 与 Linux 的主窗口无边框,标题栏由页面绘制。Windows 上 WebView2 以合成托管方式运行,标题栏与最小化、最大化、关闭按钮作为非客户区参与系统命中测试:悬停最大化按钮时显示贴靠布局,右键标题栏打开系统菜单,双击标题栏最大化或还原。 diff --git a/docs/protocol.md b/docs/protocol.md index 72855fbf..7182a861 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -606,10 +606,11 @@ AUDIO 采用独立输出模态,与其他模态同时请求时按模型能力 | `$[0][frame][0][0][6]` | citations | | `$[0][frame][0][0][7]` | grounding metadata | | `$[0][frame][2]` | usage | +| `$[0][frame][4]` | 生成标记 `[id, 序号]`,同一流交错多条生成时出现 | | `$[0][frame][7]` | turnToken | | `$[0][frame][3]` 且 frame 0 为空 | interaction metadata | -传输正文是一个 JSON 根值,网络 chunk 提供字节;解码器在 `$[0]` 中每出现一个完整 repeated frame 时立即消费该 frame。每个内容帧包含一个 candidate,candidate content 为 `[[parts...], "model"]`。完成帧可以同时携带最后一组 Part、usage、turnToken 和 finish reason,根数组解析完成后结束读取。出错时根索引 `1` 为流尾 google.rpc 状态 `[code, message, details]`,code 不为 0 时按 [Build 通道](build.md) 的流尾状态表换算 HTTP 状态,以该上游错误结束回复。turnToken 为 `v1_` 加 Base64 protobuf,field 2 为该响应的 responseId,同一响应的各帧相同;服务以该 responseId 作为 Gemini 输出的 `responseId`。 +传输正文是一个 JSON 根值,网络 chunk 提供字节;解码器在 `$[0]` 中每出现一个完整 repeated frame 时立即消费该 frame。每个内容帧包含一个 candidate,candidate content 为 `[[parts...], "model"]`。完成帧可以同时携带最后一组 Part、usage、turnToken 和 finish reason,根数组解析完成后结束读取。出错时根索引 `1` 为流尾 google.rpc 状态 `[code, message, details]`,code 不为 0 时按 [Build 通道](build.md) 的流尾状态表换算 HTTP 状态,以该上游错误结束回复。turnToken 为 `v1_` 加 Base64 protobuf,field 2 为该响应的 responseId,同一响应的各帧相同;服务以该 responseId 作为 Gemini 输出的 `responseId`。上游有时在同一个流中交错发出两条独立生成:流首帧 frame 0 为空、索引 `5` 为 `[id, 0]`,每条生成的内容帧在索引 `4` 带各自的生成标记,并有各自的 usage、finish reason 与 turnToken,流尾为每条生成各一个 frame 0 为空、索引 `4` 为生成标记的帧;服务只保留第一条出现内容帧的生成,丢弃其他生成标记的帧。 从 `$[0]` 提取出的文本帧: @@ -1047,6 +1048,24 @@ Drive token、上传和下载使用文件所属账户的固定出口。文件 ID 配置数组中未设置的项为 null,末尾的 null 省略。响应 field 1 为 repeated 图片:1 base64 数据、2 MIME、3 被安全策略拦下的原因(字符串,非空即拦下)。 +### Decision + +`CreateDecision` 发给实时目录中带 `createDecision` 方法的模型,请求头带权益档位,WAA proof 位于 field 5,binding 为输入中各文本、Base64 数据或 URI 以空格连接: + +```json +[ + "models/", + [...], + [["", [[...], , , ]]...], + null, + "" +] +``` + +Content 只写一项:field 1 文本 `[[""]]`,field 2 至 5 依次为图片、音频、文档与视频 `[, "", "", <分辨率>]`,MIME 枚举与 Omni Interaction 相同,URI 为 HTTPS 链接。问题只写类型对应的一项,末尾的 null 省略:noul 为 `[]` 或 `[["", ""]]`,choice 为 `[[["", ""]...]]`,score 为 `[["", ""...]]`。 + +响应 field 1 为 `["", ]` 列表。回答 field 1 为置信度;field 4 为 noul,第 2 项为为真的概率;field 5 为 choice,第 1 项为选中的选项,第 2 项为 `[["", <概率>]...]`;field 6 为 score,第 1 项为期望等级,第 4 项为 `[[<等级>, <概率>]...]`,第 5 项为 `[[<等级>, "<说明>"]...]`。响应 field 2 为用量:1 输入 token、2 `[[<模态>, ]...]`(1 文本、2 图片、3 音频,视频计为图片帧与音轨)、10 总 token。输入被拦截时响应 field 1 为 null,field 3 为 `[<拦截原因>, "<说明>"]`,原因编号与 GenerateContent 的 prompt 拦截原因相同(1 safety、2 other、3 blocklist、4 prohibited_content、5 image_safety)。 + ### Omni Interaction 模型目录 field 79 类型为 `2` 且不是后台任务的模型(`gemini-omni-1.1-flash`、`gemini-omni-flash-preview`)不接受 `GenerateContent`,四套公开生成接口对这类模型改用 `CreateInteractionStream`,账户选择、Worker 与 WAA 与 GenerateContent 相同,WAA proof 位于 field 5,binding 为发送的文本与内联图片的 Base64 按内容顺序以空格连接: @@ -1244,7 +1263,7 @@ 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`,错误写明该账户。 +公开 `/v1`、`/v1beta` 与 `/upload/` 的请求带 `X-AIStudio-Account: <账户 ID>` 时,账户池只在该账户上选号,视频后台生成与可续传上传同样只用该账户;上游出错时返回该错误,不换到其他账户,账户冷却中的处理与只有这一个可用账户时相同;该账户仍受所用 key 的 `API_KEY_ACCOUNTS` 绑定限制,不受 `MODEL_ACCOUNTS` 限制。账户不存在或不在本次请求可用的账户中时返回 400 `account_required`,错误写明该账户。以 `previous_interaction_id` 续接其他账户创建的 agent 后台任务时返回 400 `INVALID_ARGUMENT`。 `/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"}`。 @@ -2511,6 +2530,21 @@ Gemini `POST /v1beta/models/{model}:predict` 以 Imagen 模型生成图片,`{m 响应 `predictions[]` 按 `instances` 顺序排列,每项为 `bytesBase64Encoded` 与 `mimeType`;某个实例生成出错时返回该错误。被安全策略拦下的图片在 `includeRaiReason` 为 true 时以 `{"raiFilteredReason": "<上游给出的原因>"}` 列出,否则省略;全部图片被拦下时响应为 `{}`。模型不在实时目录或没有 `predict` 方法时返回 404 `NOT_FOUND`,上游错误按 Gemini 错误体返回。 +### Decisions + +`POST /v1beta/decisions` 以实时目录中带 `createDecision` 方法的模型回答决策问题,请求与响应同官方 Decisions API: + +| 字段 | 取值与处理 | +| --- | --- | +| `model` | 模型 ID,可带 `models/` 前缀 | +| `input` | 字符串、单个内容块或内容块数组;内容块为 Interactions 的 `text`、`image`、`audio`、`video` 与 `document`,媒体以 `data` 或 `uri` 给出并带 `mime_type`,`image` 与 `video` 可带 `resolution` | +| `questions` | 问题 ID 到问题的映射,按 ID 排序发送 | +| `type` | `noul`、`choice` 或 `score` | +| `instructions` | 字符串、`text` 内容块或其数组 | +| `criteria` | noul 为可选的 `true_description` 与 `false_description`;choice 为选项 ID 到说明,按 ID 排序发送;score 为从低到高的等级说明 | + +本服务 Files API 的文件下载后以内联数据发送,HTTPS 链接原样发送。回答按问题 ID 返回,字段依次为 `type`、`noul`/`choice`/`score`、`legend`、`probabilities` 与 `confidence`:choice 的 `probabilities` 按概率从高到低,score 的 `probabilities` 与 `legend` 按等级序号。`usage` 为 `total_input_tokens`、`input_tokens_by_modality`、`total_output_tokens`(始终为 0)与 `total_tokens`。输入被上游拦截时与官方一样返回 400,错误体为 `{"error":{"code":"<类别>","message":"<上游说明>"}}`,类别为 `safety`、`prohibited_content`、`blocklist`、`image_safety`,其余原因为 `content_blocked`。其他上游错误按 Gemini 错误体返回。 + ### Video OpenAI `POST /v1/videos` 接受 JSON 或 multipart: diff --git a/internal/aistudio/assistant.go b/internal/aistudio/assistant.go index 6d657ae7..d050eca8 100644 --- a/internal/aistudio/assistant.go +++ b/internal/aistudio/assistant.go @@ -80,8 +80,6 @@ const ( assistantInterruptedPrompt = "Your last turn was cut off by an internal error. The user already received your reply up to the end of this text:\n\n%s\n\nContinue the reply right after it without repeating it." // assistantPartialReplyPrompt 是模型已输出部分正文后需要续出或修正工具调用时附带的已输出正文末尾,%s 为已输出正文的末尾 assistantPartialReplyPrompt = "\n\nThe user already received your reply up to the end of this text:\n\n%s\n\nDo not repeat it." - // assistantPartialReplyRunes 是恢复提示带上的已输出正文末尾的字符数上限 - assistantPartialReplyRunes = 20000 ) // assistantWorkspaceTools 是写代码助手服务端指令声明的工作区内置工具,search_web 在最后 @@ -465,7 +463,10 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent for followed := 0; followed < regenerations; followed++ { prompt := assistantEmptyReplyPrompt var failure *RPCError - if err != nil { + if err != nil && decoder.unanswered && answer.Len() > 0 { + // 追加的任务没有任何模型回复即结束时以空回复提示再追加一轮 + err = nil + } else if err != nil { if !decoder.failed || answer.Len() == 0 || !errors.As(err, &failure) || failure.StatusCode != http.StatusBadGateway { break } @@ -488,11 +489,11 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent if strings.TrimSpace(answer.String()) != "" { prompt += fmt.Sprintf(assistantPartialReplyPrompt, partialReplyTail(answer.String())) } - } else if len(reply) > replied || decoder.overran { + } else if len(reply) > replied || decoder.stopped { break } - // 任务在被禁止的工具或交回调用的提问处截断时上游仍在运行,追加用户轮之前取消 - if decoder.overran && !decoder.failed { + // 任务在被禁止的工作区工具处截断时上游仍在运行,追加用户轮之前取消 + if decoder.invalidTool { if err = cleanup.cancel(ctx, *cleanup.generation.Load()); err != nil { break } @@ -510,13 +511,13 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent } err = errors.Join(DecodeAssistantStream(follow.Body, decoder, collect), follow.Body.Close()) } - cleanup.completed.Store(decoder.failed || err == nil && decoder.finished && !decoder.overran) + cleanup.completed.Store(decoder.failed || err == nil && decoder.finished && !decoder.stopped) if err == nil && interruption != nil && len(reply) == replied && !decoder.malformed && !decoder.invalidTool { err = interruption } if err == nil { assistantSearches.store(apiKey, historyDigest, answer.String(), append(searches, decoder.searches...)) - if decoder.finished && !decoder.overran && !decoder.malformed && !decoder.invalidTool && interruption == nil { + if decoder.finished && !decoder.stopped && !decoder.malformed && !decoder.invalidTool && interruption == nil { cleanup.tip.Store(&assistantSessionKey{apiKey: apiKey, digest: assistantLinkDigest(links[len(links)-1].digest, true, reply)}) } if !decoder.called && (decoder.malformed || decoder.invalidTool) { @@ -728,7 +729,7 @@ type assistantCleanup struct { once sync.Once } -// cancel 取消应用中的任务 generation +// cancel 取消应用中的任务 generation,任务已在上游结束时上游返回 FAILED_PRECONDITION,同样视为已停止 func (cleanup *assistantCleanup) cancel(ctx context.Context, generation string) error { body, _ := json.Marshal([]any{generation, cleanup.session.applet}) response, err := cleanup.client.do(ctx, "CancelCodeAssistantOfflineGeneration", cleanup.accountID, "", body, false) @@ -736,6 +737,9 @@ func (cleanup *assistantCleanup) cancel(ctx context.Context, generation string) _, err = io.Copy(io.Discard, response.Body) err = errors.Join(err, response.Body.Close()) } + if failure := (*RPCError)(nil); errors.As(err, &failure) && failure.Code == 9 { + return nil + } return err } @@ -799,16 +803,18 @@ type AssistantStreamDecoder struct { finished bool // answered 表示最近解码的步骤是已完成、含正文且没有工具动作的模型步骤,即本轮回复结束 answered bool - // overran 表示本轮回复在函数调用处结束:调用之后模型继续输出正文或上游后续步骤失败 - overran bool + // stopped 表示不再读取本任务后续步骤:函数调用之后模型继续输出正文、上游后续步骤失败,或模型调用了被禁止的工作区工具、交回的提问缺少调用块 + stopped bool // failed 表示上游任务以失败步骤结束,不再需要取消 failed bool + // unanswered 表示任务流正常结束而本任务没有任何模型回复 + unanswered bool } // DecodeAssistantStream 解码 StreamCodeAssistantOfflineGeneration 响应:field 1 为消息列表,其后可带 google.rpc 状态;本轮回复结束、函数调用之后模型继续输出正文或上游后续步骤失败时不再读取上游,消息列表在回复完成前正常结束时经 Reopen 重新打开一次任务流继续读取,仍未完成而已解出完整函数调用或写出正文时以已有内容结束本轮 func DecodeAssistantStream(source io.Reader, decoder *AssistantStreamDecoder, emit func(Event) error) error { err := decodeAssistantMessages(source, decoder, emit) - if err == nil && !decoder.finished && !decoder.overran && decoder.Reopen != nil { + if err == nil && !decoder.finished && !decoder.stopped && decoder.Reopen != nil { var reopened io.ReadCloser if reopened, err = decoder.Reopen(); err == nil { err = errors.Join(decodeAssistantMessages(reopened, decoder, emit), reopened.Close()) @@ -847,7 +853,7 @@ func decodeAssistantMessages(source io.Reader, decoder *AssistantStreamDecoder, if token, err := stream.Token(); err != nil || token != json.Delim('[') { return fail("$[0]", "消息列表不是数组") } - for ; !decoder.answered && !decoder.overran && stream.More(); index++ { + for ; !decoder.answered && !decoder.stopped && stream.More(); index++ { var raw json.RawMessage if err := stream.Decode(&raw); err != nil { return fail(fmt.Sprintf("$[0][%d]", index), err.Error()) @@ -862,7 +868,7 @@ func decodeAssistantMessages(source io.Reader, decoder *AssistantStreamDecoder, } } } - if !decoder.answered && !decoder.overran { + if !decoder.answered && !decoder.stopped { if token, err := stream.Token(); err != nil || token != json.Delim(']') { return fail("$[0]", "消息列表没有正常结束") } @@ -906,7 +912,7 @@ func (d *AssistantStreamDecoder) Decode(raw json.RawMessage, path string) ([]Eve return nil, err } events = append(events, stepEvents...) - if d.overran { + if d.stopped { break } } @@ -937,7 +943,7 @@ func (d *AssistantStreamDecoder) decodeStep(raw json.RawMessage, path string, ev } d.failed = true if d.called { - d.overran = true + d.stopped = true return nil, nil } return nil, failed @@ -1008,10 +1014,10 @@ func (d *AssistantStreamDecoder) decodeStep(raw json.RawMessage, path string, ev } if invalidAction && len(d.Functions) > 0 && !d.called { d.invalidTool = true - d.overran = true + d.stopped = true } else if missingCallQuestion && len(d.Functions) > 0 && !d.called && !d.block { d.malformed = true - d.overran = true + d.stopped = true } return events, nil } @@ -1115,7 +1121,7 @@ func (d *AssistantStreamDecoder) text(delta string) []Event { return []Event{{Kind: EventText, Text: delta}} } if d.called { - d.overran = d.overran || strings.TrimSpace(delta) != "" + d.stopped = d.stopped || strings.TrimSpace(delta) != "" return nil } d.held += delta @@ -1180,7 +1186,7 @@ func (d *AssistantStreamDecoder) text(delta string) []Event { d.other = true } } - d.overran = strings.TrimSpace(d.held) != "" + d.stopped = strings.TrimSpace(d.held) != "" d.held = "" return events } @@ -1414,7 +1420,8 @@ func (d *AssistantStreamDecoder) recorded() []Event { // End 在本轮回复结束、函数调用之后模型继续输出正文或流结束时返回扣下的正文或函数调用、搜索来源、各步骤用量之和与完成事件 func (d *AssistantStreamDecoder) End() ([]Event, error) { - if !d.finished && !d.overran && !d.called && !d.wroteText { + if !d.finished && !d.stopped && !d.called && !d.wroteText { + d.unanswered = true return nil, &ProtocolEvidenceError{Method: assistantStreamMethod, Path: "$", Detail: "流结束前没有完成的模型回复"} } var events []Event diff --git a/internal/aistudio/benefit.go b/internal/aistudio/benefit.go index 6e62a1a5..f56a1136 100644 --- a/internal/aistudio/benefit.go +++ b/internal/aistudio/benefit.go @@ -27,6 +27,7 @@ var tieredRPCMethods = map[string]struct{}{ "CodeAssistantOffline": {}, "CancelInteraction": {}, "CreateInteraction": {}, + "CreateDecision": {}, "CreateInteractionStream": {}, "GetInteractionStream": {}, "StreamExtractVideoFrames": {}, diff --git a/internal/aistudio/decision.go b/internal/aistudio/decision.go new file mode 100644 index 00000000..454c5c1f --- /dev/null +++ b/internal/aistudio/decision.go @@ -0,0 +1,342 @@ +package aistudio + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +// DecisionRequest 是 Decisions API 请求,Model 为实时目录中带 createDecision 方法的模型,Input 为各问题共用的输入 +type DecisionRequest struct { + Model string + Input []Part + Questions []DecisionQuestion + AccountID string +} + +// DecisionQuestion 是一个决策问题,Type 为 noul、choice 或 score +type DecisionQuestion struct { + Key string + Type string + Instructions []string + // Criteria 是 noul 为真与为假的说明,HasCriteria 表示请求给出了 criteria + TrueDescription string + FalseDescription string + HasCriteria bool + // Options 是 choice 的选项 ID 与说明 + Options []DecisionOption + // Levels 是 score 从低到高各等级的说明 + Levels []string +} + +// DecisionOption 是 choice 问题的一个选项 +type DecisionOption struct { + ID string + Description string +} + +// DecisionAnswer 是一个问题的回答,Probabilities 与 Legend 按上游顺序排列 +type DecisionAnswer struct { + Key string + Type string + Confidence float64 + Noul float64 + Choice string + Score float64 + Probabilities []DecisionProbability + Legend []DecisionProbability +} + +// DecisionProbability 是选项或等级的概率,Legend 中 Description 为等级说明 +type DecisionProbability struct { + Key string + Value float64 + Description string +} + +// DecisionModalityTokens 是一种输入模态的 token 数,Modality 为 text、image 或 audio +type DecisionModalityTokens struct { + Modality string + Tokens int64 +} + +// DecisionResult 是 CreateDecision 的回答与输入用量 +type DecisionResult struct { + Answers []DecisionAnswer + InputTokens int64 + InputByModality []DecisionModalityTokens + TotalTokens int64 +} + +// DecisionService 以 Gemini Decision 模型回答决策问题 +type DecisionService interface { + CreateDecision(context.Context, DecisionRequest) (DecisionResult, error) +} + +// DecisionProtectedTransport 原子完成 CreateDecision fresh WAA proof、field 5 写入和同 context 发送 +type DecisionProtectedTransport interface { + DoProtectedDecision(context.Context, DecisionRequest, RPCRequest) (*RPCResponse, error) +} + +var _ DecisionProtectedTransport = (*WorkerProtectedTransport)(nil) + +// decisionModalities 是 CreateDecision 用量中的模态枚举 +var decisionModalities = map[int64]string{1: "text", 2: "image", 3: "audio"} + +// CreateDecision 以目录中有该模型 createDecision 方法的账户回答决策问题 +func (s *PooledService) CreateDecision(ctx context.Context, request DecisionRequest) (DecisionResult, error) { + request.Model = strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") + lease, owned, err := resolveAccountLease(ctx, s.pool, AccountSelection{ + ModelID: request.Model, Method: "createDecision", AccountID: strings.TrimSpace(request.AccountID), + }) + if err != nil { + return DecisionResult{}, err + } + request.AccountID = lease.Account().ID + result, err := s.client.CreateDecision(ContextWithAccountLease(ctx, lease), request) + if owned { + err = errors.Join(err, lease.Release()) + } + return result, err +} + +// CreateDecision 发送 CreateDecision 并返回各问题的回答 +func (c *Client) CreateDecision(ctx context.Context, request DecisionRequest) (DecisionResult, error) { + entry, err := c.modelEntry(ctx, request.AccountID, request.Model) + if err != nil { + return DecisionResult{}, err + } + if !hasMethod(entry.model, "createDecision") { + return DecisionResult{}, fmt.Errorf("%w: 模型 %q 的实时目录没有 createDecision 方法", ErrInvalidArgument, entry.model.ID) + } + body, err := EncodeCreateDecisionRequest(request) + if err != nil { + return DecisionResult{}, fmt.Errorf("%w: %v", ErrInvalidArgument, err) + } + rpc := newRPCRequest("CreateDecision", request.AccountID, "", body, false) + c.applyBenefitTier(rpc.Method, request.AccountID, rpc.Header) + response, err := c.protected.(DecisionProtectedTransport).DoProtectedDecision(ctx, request, rpc) + if err != nil { + return DecisionResult{}, fmt.Errorf("发送 AI Studio CreateDecision: %w", err) + } + response, err = validateRPCResponse("CreateDecision", response) + if err != nil { + return DecisionResult{}, err + } + defer response.Body.Close() + return DecodeCreateDecisionResponse(response.Body) +} + +// DoProtectedDecision 写入 CreateDecision fresh proof 后经账户的 WAA 运行时发送,binding 为输入各块以空格连接,页面输入框为空 +func (t *WorkerProtectedTransport) DoProtectedDecision(ctx context.Context, request DecisionRequest, rpc RPCRequest) (*RPCResponse, error) { + prompt, err := bindingPrompt(GenerateRequest{Contents: []Content{{Role: RoleUser, Parts: request.Input}}}) + if err != nil { + return nil, err + } + return t.doPrepared(ctx, prompt, "", 5, "", AccountSelection{ + ModelID: request.Model, Method: "createDecision", AccountID: request.AccountID, + }, rpc) +} + +// EncodeCreateDecisionRequest 编码官网 CreateDecision 数组协议:模型、输入 Content 列表、问题 map 与 field 5 的 proof 位 +func EncodeCreateDecisionRequest(request DecisionRequest) ([]byte, error) { + if len(request.Input) == 0 { + return nil, fmt.Errorf("input 不能为空") + } + input := make([]any, 0, len(request.Input)) + for index, part := range request.Input { + content, err := decisionContent(part) + if err != nil { + return nil, fmt.Errorf("input[%d]: %w", index, err) + } + input = append(input, content) + } + questions := make([]any, 0, len(request.Questions)) + for _, question := range request.Questions { + instructions := make([]any, 0, len(question.Instructions)) + for _, text := range question.Instructions { + instructions = append(instructions, []any{[]any{text}}) + } + // Question 依次为说明、noul、choice 与 score,只写出问题类型对应的一项 + wire := []any{instructions, nil, nil, nil} + switch question.Type { + case "noul": + noul := []any{} + if question.HasCriteria { + noul = []any{[]any{question.TrueDescription, question.FalseDescription}} + } + wire[1] = noul + case "choice": + options := make([]any, 0, len(question.Options)) + for _, option := range question.Options { + options = append(options, []any{option.ID, option.Description}) + } + wire[2] = []any{options} + case "score": + wire[3] = []any{question.Levels} + default: + return nil, fmt.Errorf("questions.%s.type 必须是 noul、choice 或 score", question.Key) + } + questions = append(questions, []any{question.Key, trimWireNil(wire)}) + } + return json.Marshal([]any{"models/" + request.Model, input, questions, nil, nil}) +} + +// decisionContent 编码一个输入块:field 1 文本,field 2 至 5 依次为图片、音频、文档与视频的 MIME 枚举、Base64 数据或 URI 与分辨率 +func decisionContent(part Part) ([]any, error) { + if part.ExternalMedia != nil { + return nil, fmt.Errorf("Decisions API 不接受 YouTube 链接") + } + if part.InlineData == nil && part.File == nil { + return []any{[]any{part.Text}}, nil + } + mime, data, uri := "", "", "" + if part.InlineData != nil { + mime, data = part.InlineData.MIME, base64.StdEncoding.EncodeToString(part.InlineData.Data) + } else { + mime, uri = part.File.MIME, part.File.ID + } + field, code, ok := interactionMediaCode(mime) + if !ok { + return nil, fmt.Errorf("不支持的 MIME 类型 %q", mime) + } + media := []any{code, emptyNil(data), emptyNil(uri), nil} + if part.MediaResolution != "" { + resolution, ok := mediaResolutions[part.MediaResolution] + if !ok { + return nil, fmt.Errorf("不支持的分辨率 %q", part.MediaResolution) + } + media[3] = resolution + } + content := make([]any, field+1) + content[field] = trimWireNil(media) + return content, nil +} + +// interactionMediaCode 返回 MIME 所属媒体在 Content 中的数组下标与 MIME 枚举 +func interactionMediaCode(mime string) (int, int64, bool) { + base, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mime)), ";") + for field, table := range interactionMediaMIME { + for code, value := range table.codes { + if value == base { + return field, code, true + } + } + } + return 0, 0, false +} + +// DecodeCreateDecisionResponse 解码 CreateDecision 响应:field 1 为问题键与回答,field 2 为用量(输入 token、各模态 token 与 field 10 的总 token),输入被拦截时 field 3 为拦截原因与说明,返回 PromptFeedbackError +func DecodeCreateDecisionResponse(body io.Reader) (DecisionResult, error) { + raw, err := io.ReadAll(body) + if err != nil { + return DecisionResult{}, fmt.Errorf("读取 AI Studio CreateDecision 响应: %w", err) + } + fail := func(path string, detail string) error { + return &ProtocolEvidenceError{Method: "CreateDecision", Path: path, Detail: detail, Raw: raw} + } + var wire struct { + Answers [][]json.RawMessage + Usage []json.RawMessage + } + var root []json.RawMessage + if json.Unmarshal(raw, &root) != nil || len(root) == 0 || json.Unmarshal(root[0], &wire.Answers) != nil { + return DecisionResult{}, fail("$[0]", "期望回答数组") + } + if len(root) > 1 && json.Unmarshal(root[1], &wire.Usage) != nil { + return DecisionResult{}, fail("$[1]", "期望用量数组") + } + if len(root) > 2 && !isJSONNull(root[2]) { + var reason int64 + var message string + if json.Unmarshal(root[2], &[]any{&reason, &message}) != nil { + return DecisionResult{}, fail("$[2]", "期望拦截原因与说明") + } + return DecisionResult{}, &PromptFeedbackError{Reason: decodePromptBlockReason(reason), Message: message, Raw: raw} + } + result := DecisionResult{Answers: make([]DecisionAnswer, 0, len(wire.Answers))} + for index, pair := range wire.Answers { + var key string + var fields []json.RawMessage + if len(pair) != 2 || json.Unmarshal(pair[0], &key) != nil || json.Unmarshal(pair[1], &fields) != nil { + return DecisionResult{}, fail(fmt.Sprintf("$[0][%d]", index), "期望问题键与回答") + } + answer, err := decodeDecisionAnswer(key, fields) + if err != nil { + return DecisionResult{}, fail(fmt.Sprintf("$[0][%d][1]", index), err.Error()) + } + result.Answers = append(result.Answers, answer) + } + var modalities [][2]int64 + for index, target := range map[int]any{0: &result.InputTokens, 1: &modalities, 9: &result.TotalTokens} { + if len(wire.Usage) > index && !isJSONNull(wire.Usage[index]) && json.Unmarshal(wire.Usage[index], target) != nil { + return DecisionResult{}, fail(fmt.Sprintf("$[1][%d]", index), "用量无效") + } + } + for _, item := range modalities { + result.InputByModality = append(result.InputByModality, DecisionModalityTokens{Modality: decisionModalities[item[0]], Tokens: item[1]}) + } + return result, nil +} + +// decodeDecisionAnswer 解码一个回答:field 1 为置信度;field 4 为 noul,第 2 项为 P(true);field 5 为 choice,第 1、2 项为选项与各选项概率;field 6 为 score,第 1、4、5 项为期望等级、各等级概率与各等级说明 +func decodeDecisionAnswer(key string, fields []json.RawMessage) (DecisionAnswer, error) { + answer := DecisionAnswer{Key: key} + if len(fields) == 0 || json.Unmarshal(fields[0], &answer.Confidence) != nil { + return answer, fmt.Errorf("回答缺少置信度") + } + field := func(index int) []json.RawMessage { + var items []json.RawMessage + if len(fields) > index { + _ = json.Unmarshal(fields[index], &items) + } + return items + } + switch noul, choice, score := field(3), field(4), field(5); { + case len(noul) > 1: + answer.Type = "noul" + if json.Unmarshal(noul[1], &answer.Noul) != nil { + return answer, fmt.Errorf("noul 概率无效") + } + case len(choice) > 1: + answer.Type = "choice" + var probabilities [][2]json.RawMessage + if json.Unmarshal(choice[0], &answer.Choice) != nil || json.Unmarshal(choice[1], &probabilities) != nil { + return answer, fmt.Errorf("choice 回答无效") + } + for _, pair := range probabilities { + item := DecisionProbability{} + if json.Unmarshal(pair[0], &item.Key) != nil || json.Unmarshal(pair[1], &item.Value) != nil { + return answer, fmt.Errorf("choice 概率无效") + } + answer.Probabilities = append(answer.Probabilities, item) + } + case len(score) > 4: + answer.Type = "score" + var probabilities, legend [][2]json.RawMessage + if json.Unmarshal(score[0], &answer.Score) != nil || json.Unmarshal(score[3], &probabilities) != nil || json.Unmarshal(score[4], &legend) != nil { + return answer, fmt.Errorf("score 回答无效") + } + for _, pair := range probabilities { + item := DecisionProbability{Key: string(pair[0])} + if json.Unmarshal(pair[1], &item.Value) != nil { + return answer, fmt.Errorf("score 概率无效") + } + answer.Probabilities = append(answer.Probabilities, item) + } + for _, pair := range legend { + item := DecisionProbability{Key: string(pair[0])} + if json.Unmarshal(pair[1], &item.Description) != nil { + return answer, fmt.Errorf("score 等级说明无效") + } + answer.Legend = append(answer.Legend, item) + } + default: + return answer, fmt.Errorf("回答没有 noul、choice 或 score") + } + return answer, nil +} diff --git a/internal/aistudio/event.go b/internal/aistudio/event.go index 0f9dcbfb..1e515970 100644 --- a/internal/aistudio/event.go +++ b/internal/aistudio/event.go @@ -28,6 +28,8 @@ type FrameDecoder struct { safetyRatings []SafetyRating // systemRoleTokens 是请求系统指令的 user 角色计入上游输入用量的 token 数 systemRoleTokens int64 + // generation 是同一流交错多条生成时保留的第一条生成的标记 + generation string } // NewFrameDecoder 创建单次 GenerateContent 的有状态解码器 @@ -52,6 +54,14 @@ func (d *FrameDecoder) Decode(raw json.RawMessage) ([]Event, error) { } return nil, nil } + var generation []json.RawMessage + if json.Unmarshal(rawAt(frame, 4), &generation) == nil && len(generation) > 0 { + if d.generation == "" { + d.generation = string(generation[0]) + } else if string(generation[0]) != d.generation { + return nil, nil + } + } candidates, err := rawArray(frame[0], "$[0][][0]", raw) if err != nil { return nil, withMethod(err, "GenerateContent") diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index 5426968c..061ad979 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -824,14 +824,19 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan request.Contents = applySpeechTranscript(request.Contents, entry.model, request.Config) wireRequest := request wireRequest.Config.StopSequences = nil + // accountCtx 是发送请求所用的上下文,续写轮换账户后为新账户的上下文 + accountCtx := ctx + switched := func(next context.Context, accountID string) { + accountCtx, request.AccountID, wireRequest.AccountID = next, accountID, accountID + } sendRequest := func() (*RPCResponse, func(io.Reader, func(Event) error) error, error) { switch { case entry.model.Capabilities["assistant_route"]: - return c.sendAssistant(ctx, wireRequest, entry) + return c.sendAssistant(accountCtx, wireRequest, entry) case build: - return c.sendBuild(ctx, wireRequest, entry) + return c.sendBuild(accountCtx, wireRequest, entry) default: - return c.sendPlayground(ctx, wireRequest, entry) + return c.sendPlayground(accountCtx, wireRequest, entry, switched) } } response, decodeStream, err := sendRequest() @@ -1117,11 +1122,14 @@ func trimTrailingSignatureParts(parts *[]Part) { *parts = items } -// partialReplyTail 返回已输出正文末尾至多 assistantPartialReplyRunes 个 Unicode 字符 +// partialReplyRunes 是续写恢复提示带上的已输出正文末尾的字符数上限 +const partialReplyRunes = 20000 + +// partialReplyTail 返回已输出正文末尾至多 partialReplyRunes 个 Unicode 字符 func partialReplyTail(text string) string { runes := []rune(text) - if len(runes) > assistantPartialReplyRunes { - return string(runes[len(runes)-assistantPartialReplyRunes:]) + if len(runes) > partialReplyRunes { + return string(runes[len(runes)-partialReplyRunes:]) } return text } @@ -1191,6 +1199,17 @@ func (s *continuationPrefixStripper) longerPrefixPending(index int, combined str return false } +// flush 停止去重并输出尚未确认重复的暂存正文 +func (s *continuationPrefixStripper) flush(emit func(Event) error) error { + s.active = false + if s.pending == "" { + return nil + } + text, sig := s.pending, s.pendingSig + s.pending, s.pendingSig = "", "" + return emit(Event{Kind: EventText, Text: text, ThoughtSignature: sig}) +} + // filter 过滤续写轮开头重复的已输出正文前缀并保留思考签名 func (s *continuationPrefixStripper) filter(event Event, emit func(Event) error) error { if s == nil || !s.active { @@ -1205,25 +1224,14 @@ func (s *continuationPrefixStripper) filter(event Event, emit func(Event) error) return emit(event) } } - sig := s.pendingSig - s.pending = "" - s.pendingSig = "" - s.active = false - if sig != "" { - if err := emit(Event{Kind: EventThoughtSignature, Part: Part{ThoughtSignature: sig}}); err != nil { - return err - } + if err := s.flush(emit); err != nil { + return err } return emit(event) } if event.Transcript != nil { - s.active = false - if s.pending != "" { - if err := emit(Event{Kind: EventText, Text: s.pending, ThoughtSignature: s.pendingSig}); err != nil { - return err - } - s.pending = "" - s.pendingSig = "" + if err := s.flush(emit); err != nil { + return err } return emit(event) } @@ -1407,8 +1415,8 @@ func nextConversationTurn(contents []Content) int { return -1 } -// sendPlayground 编码并发送 Playground GenerateContent,返回响应与含完成帧校验的流解码 -func (c *Client) sendPlayground(ctx context.Context, request GenerateRequest, entry modelEntry) (*RPCResponse, func(io.Reader, func(Event) error) error, error) { +// sendPlayground 编码并发送 Playground GenerateContent,返回响应与含完成帧校验的流解码,续写轮换账户后以新账户的上下文与 ID 调用 switched +func (c *Client) sendPlayground(ctx context.Context, request GenerateRequest, entry modelEntry, switched func(context.Context, string)) (*RPCResponse, func(io.Reader, func(Event) error) error, error) { reason := "" if request.Unary { reason = "Playground GenerateContent 使用服务端流,收集完成后返回" @@ -1432,7 +1440,7 @@ func (c *Client) sendPlayground(ctx context.Context, request GenerateRequest, en } 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)) + return c.decodePlaygroundRounds(ctx, request, entry.defaults, runtime, source, restoreFunctionNames(request.Tools.Functions, emit), switched) }, nil } @@ -1447,8 +1455,8 @@ func ContextWithContinuationAccounts(ctx context.Context, accounts ContinuationA 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 { +// decodePlaygroundRounds 解码 GenerateContent 流,上游要求续写时携带续写 token 发送下一轮,续写轮以可换账户的错误失败时经上下文中的 ContinuationAccounts 换账户重发并调用 switched,没有其他账户时返回该轮的上游错误,各轮输出依次转发 +func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateRequest, defaults GenerationDefaults, runtime RequestContext, source io.Reader, emit func(Event) error, switched func(context.Context, string)) error { total := int64(0) if defaults.ContinuedOutputTokens > 0 { config, err := encodeGenerationConfig(request.Config, defaults) @@ -1514,12 +1522,15 @@ func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateReq } next, switchErr := accounts(ctx, err) if switchErr != nil { - err = errors.Join(err, switchErr) + if !errors.Is(switchErr, ErrNoEligibleAccount) { + err = errors.Join(err, switchErr) + } break } ctx = next lease, _ := AccountLeaseFromContext(ctx) request.AccountID, request.continuationProof = lease.Account().ID, "" + switched(ctx, request.AccountID) if c.contextProvider != nil { if runtime, err = c.contextProvider.RequestContext(ctx, request.AccountID); err != nil { return fmt.Errorf("读取 AI Studio 请求上下文: %w", err) diff --git a/internal/api/decisions.go b/internal/api/decisions.go new file mode 100644 index 00000000..99e3d8ea --- /dev/null +++ b/internal/api/decisions.go @@ -0,0 +1,297 @@ +package api + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strconv" + + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +// decisionRequest 是 Decisions API 请求:model、各问题共用的 input 与按问题 ID 给出的问题 +type decisionRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Questions map[string]decisionQuestion `json:"questions"` +} + +// decisionQuestion 是一个决策问题,Criteria 在 noul 时为真假说明、choice 时为选项 ID 到说明、score 时为从低到高的等级说明 +type decisionQuestion struct { + Type string `json:"type"` + Instructions json.RawMessage `json:"instructions"` + Criteria json.RawMessage `json:"criteria"` +} + +// decisionAnswer 是一个问题的回答,字段顺序同官方 Decisions API +type decisionAnswer struct { + Type string `json:"type"` + Noul *float64 `json:"noul,omitempty"` + Choice *string `json:"choice,omitempty"` + Score *float64 `json:"score,omitempty"` + Legend orderedFields `json:"legend,omitempty"` + Probabilities orderedFields `json:"probabilities,omitempty"` + Confidence float64 `json:"confidence"` +} + +// decisionUsage 是 Decisions API 的 token 用量,输出始终为 0 +type decisionUsage struct { + TotalInputTokens int64 `json:"total_input_tokens"` + InputTokensByModality []map[string]any `json:"input_tokens_by_modality,omitempty"` + TotalOutputTokens int64 `json:"total_output_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +// orderedFields 是按切片顺序编码的 JSON 对象成员 +type orderedFields []orderedField + +// orderedField 是 JSON 对象的一个成员 +type orderedField struct { + key string + value any +} + +// MarshalJSON 按切片顺序写出对象成员 +func (fields orderedFields) MarshalJSON() ([]byte, error) { + var buffer bytes.Buffer + buffer.WriteByte('{') + for index, field := range fields { + if index > 0 { + buffer.WriteByte(',') + } + key, _ := json.Marshal(field.key) + value, err := json.Marshal(field.value) + if err != nil { + return nil, err + } + buffer.Write(key) + buffer.WriteByte(':') + buffer.Write(value) + } + buffer.WriteByte('}') + return buffer.Bytes(), nil +} + +// handleDecision 以 Gemini Decision 模型回答 POST /v1beta/decisions 的决策问题 +func (s *server) handleDecision(w http.ResponseWriter, r *http.Request) { + service, ok := s.service.(aistudio.DecisionService) + if !ok { + writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "decisions are unavailable") + return + } + var request decisionRequest + if err := decodeJSON(r, &request); err != nil { + writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) + return + } + decision, err := request.decision() + if err == nil { + err = s.inlineDecisionFiles(r, decision.Input) + } + if err != nil { + writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) + return + } + result, err := service.CreateDecision(r.Context(), decision) + var blocked *aistudio.PromptFeedbackError + if errors.As(err, &blocked) { + writeDecisionBlocked(w, blocked) + return + } + if err != nil { + if shouldWriteRequestError(r, err) { + writeGeminiRequestError(w, err) + } + return + } + SetAccessLogGenerationResult(r.Context(), &aistudio.Usage{InputTokens: result.InputTokens, TotalTokens: result.TotalTokens}, 0) + answers := make(orderedFields, 0, len(result.Answers)) + for _, answer := range result.Answers { + answers = append(answers, orderedField{key: answer.Key, value: decisionAnswerJSON(answer)}) + } + usage := decisionUsage{TotalInputTokens: result.InputTokens, TotalTokens: result.TotalTokens} + for _, item := range result.InputByModality { + usage.InputTokensByModality = append(usage.InputTokensByModality, map[string]any{"modality": item.Modality, "tokens": item.Tokens}) + } + writeJSON(w, http.StatusOK, struct { + Answers orderedFields `json:"answers"` + Usage decisionUsage `json:"usage"` + }{answers, usage}) +} + +// decisionBlockCodes 是官方 Decisions API 输入被拦截时错误 code 直接使用的拦截原因,其余原因为 content_blocked +var decisionBlockCodes = map[string]bool{"safety": true, "prohibited_content": true, "blocklist": true, "image_safety": true} + +// writeDecisionBlocked 按官方 Decisions API 以 400 与拦截类别 code 返回输入被拦截的错误 +func writeDecisionBlocked(w http.ResponseWriter, blocked *aistudio.PromptFeedbackError) { + code := blocked.Reason + if !decisionBlockCodes[code] { + code = "content_blocked" + } + type blockError struct { + Code string `json:"code"` + Message string `json:"message"` + } + writeJSON(w, http.StatusBadRequest, struct { + Error blockError `json:"error"` + }{blockError{code, blocked.Message}}) +} + +// decision 把请求转换为上游决策请求,问题按 ID 排序 +func (request decisionRequest) decision() (aistudio.DecisionRequest, error) { + input, err := decisionInput(request.Input) + if err != nil { + return aistudio.DecisionRequest{}, fmt.Errorf("input: %w", err) + } + if len(request.Questions) == 0 { + return aistudio.DecisionRequest{}, fmt.Errorf("questions must contain at least one question") + } + decision := aistudio.DecisionRequest{Model: request.Model, Input: input} + for _, key := range slices.Sorted(maps.Keys(request.Questions)) { + question, err := request.Questions[key].decision(key) + if err != nil { + return aistudio.DecisionRequest{}, fmt.Errorf("questions.%s: %w", key, err) + } + decision.Questions = append(decision.Questions, question) + } + return decision, nil +} + +// decision 转换一个问题的说明与 criteria +func (question decisionQuestion) decision(key string) (aistudio.DecisionQuestion, error) { + result := aistudio.DecisionQuestion{Key: key, Type: question.Type} + instructions, err := decisionContents(question.Instructions) + if err != nil { + return result, fmt.Errorf("instructions: %w", err) + } + for _, content := range instructions { + if content.Type != "text" || content.Text == nil { + return result, fmt.Errorf("instructions only accept text content") + } + result.Instructions = append(result.Instructions, *content.Text) + } + if len(result.Instructions) == 0 { + return result, fmt.Errorf("instructions are required") + } + switch question.Type { + case "noul": + if rawJSONConfigured(question.Criteria) { + var criteria struct { + TrueDescription string `json:"true_description"` + FalseDescription string `json:"false_description"` + } + if err := json.Unmarshal(question.Criteria, &criteria); err != nil { + return result, fmt.Errorf("criteria: %w", err) + } + result.HasCriteria, result.TrueDescription, result.FalseDescription = true, criteria.TrueDescription, criteria.FalseDescription + } + case "choice": + var options map[string]string + if err := json.Unmarshal(question.Criteria, &options); err != nil { + return result, fmt.Errorf("criteria: %w", err) + } + for _, id := range slices.Sorted(maps.Keys(options)) { + result.Options = append(result.Options, aistudio.DecisionOption{ID: id, Description: options[id]}) + } + case "score": + if err := json.Unmarshal(question.Criteria, &result.Levels); err != nil { + return result, fmt.Errorf("criteria: %w", err) + } + default: + return result, fmt.Errorf("type must be noul, choice or score") + } + return result, nil +} + +// decisionContents 解析字符串、单个内容块或内容块数组 +func decisionContents(raw json.RawMessage) ([]interactionInput, error) { + var text string + if json.Unmarshal(raw, &text) == nil { + return []interactionInput{{Type: "text", Text: &text}}, nil + } + return interactionList[interactionInput](raw) +} + +// decisionInput 把 input 的文本、图片、音频、文档与视频内容块转换为 part +func decisionInput(raw json.RawMessage) ([]aistudio.Part, error) { + contents, err := decisionContents(raw) + if err != nil { + return nil, err + } + var parts []aistudio.Part + for _, content := range contents { + converted, err := content.parts() + if err != nil { + return nil, err + } + parts = append(parts, converted...) + } + if len(parts) == 0 { + return nil, fmt.Errorf("at least one content block is required") + } + return parts, nil +} + +// inlineDecisionFiles 把经本服务 Files API 上传的文件下载为内联数据,http 与 https 链接原样交给上游 +func (s *server) inlineDecisionFiles(r *http.Request, parts []aistudio.Part) error { + for index := range parts { + part := &parts[index] + if part.File == nil || remoteMediaURL(part.File.ID) { + continue + } + service, ok := s.service.(aistudio.FileService) + if !ok { + return fmt.Errorf("file content is unavailable") + } + media, err := service.DownloadFile(r.Context(), part.File.ID) + if err != nil { + return err + } + data, err := io.ReadAll(media.Body) + closeErr := media.Body.Close() + if err = cmp.Or(err, closeErr); err != nil { + return err + } + part.InlineData = &aistudio.Blob{MIME: cmp.Or(part.File.MIME, media.MIME), Data: data} + part.File = nil + } + return nil +} + +// decisionAnswerJSON 按官方形状写出回答:choice 概率从高到低,score 概率与等级说明按等级序号 +func decisionAnswerJSON(answer aistudio.DecisionAnswer) decisionAnswer { + result := decisionAnswer{Type: answer.Type, Confidence: answer.Confidence} + probabilities := slices.Clone(answer.Probabilities) + switch answer.Type { + case "noul": + result.Noul = &answer.Noul + case "choice": + result.Choice = &answer.Choice + slices.SortStableFunc(probabilities, func(a, b aistudio.DecisionProbability) int { return cmp.Compare(b.Value, a.Value) }) + case "score": + result.Score = &answer.Score + legend := slices.Clone(answer.Legend) + slices.SortFunc(legend, compareDecisionLevel) + slices.SortFunc(probabilities, compareDecisionLevel) + for _, item := range legend { + result.Legend = append(result.Legend, orderedField{key: item.Key, value: item.Description}) + } + } + for _, item := range probabilities { + result.Probabilities = append(result.Probabilities, orderedField{key: item.Key, value: item.Value}) + } + return result +} + +// compareDecisionLevel 按等级序号比较 score 的概率或等级说明 +func compareDecisionLevel(a, b aistudio.DecisionProbability) int { + left, _ := strconv.Atoi(a.Key) + right, _ := strconv.Atoi(b.Key) + return cmp.Compare(left, right) +} diff --git a/internal/api/gemini.go b/internal/api/gemini.go index dbf1498e..677d4501 100644 --- a/internal/api/gemini.go +++ b/internal/api/gemini.go @@ -392,7 +392,7 @@ func (s *server) handleGeminiModel(w http.ResponseWriter, r *http.Request) { } // geminiServedMethods 是本服务以 Gemini API 形式提供的模型方法 -var geminiServedMethods = []string{"generateContent", "countTokens", "createCachedContent", "embedContent", "batchEmbedContents", "predict", "predictLongRunning"} +var geminiServedMethods = []string{"generateContent", "countTokens", "createCachedContent", "embedContent", "batchEmbedContents", "predict", "predictLongRunning", "createDecision"} func geminiModelObject(model aistudio.Model) map[string]any { methods := make([]string, 0, len(model.Methods)) @@ -524,7 +524,7 @@ func (s *server) handleGeminiAction(w http.ResponseWriter, r *http.Request) { func decodeGeminiJSON(r *http.Request, target any) error { body, err := io.ReadAll(r.Body) if err == nil { - body, _, err = geminiCamelKeys(body) + body, err = geminiCamelKeys(body) } if err == nil { err = json.Unmarshal(body, target) @@ -545,62 +545,110 @@ var geminiOpaqueFields = map[string]bool{ } // geminiCamelKeys 按 proto3 JSON 规则把各层消息字段名转为 lowerCamelCase,同层两种写法并存时保留驼峰字段,自由 JSON 字段的取值原样保留 -func geminiCamelKeys(raw []byte) ([]byte, bool, error) { +func geminiCamelKeys(raw []byte) ([]byte, error) { value := bytes.TrimSpace(raw) if len(value) == 0 || value[0] != '{' && value[0] != '[' { - return raw, false, nil + return raw, nil } if camel, err := geminiKeysCamel(value); err == nil && camel { - return raw, false, nil + return raw, nil } - changed := false - if value[0] == '[' { - var items []json.RawMessage - if err := json.Unmarshal(value, &items); err != nil { - return nil, false, err - } - for index, item := range items { - converted, itemChanged, err := geminiCamelKeys(item) - if err != nil { - return nil, false, err - } - items[index], changed = converted, changed || itemChanged + output := bytes.NewBuffer(make([]byte, 0, len(value))) + if err := writeGeminiCamelKeys(output, value); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +// writeGeminiCamelKeys 把 JSON 对象或数组 value 中各层消息字段名转为 lowerCamelCase 后写入 output,字段取值按原字节写出,根值之后还有其他内容时返回错误 +func writeGeminiCamelKeys(output *bytes.Buffer, value []byte) error { + decoder := jsontext.NewDecoder(bytes.NewBuffer(value), jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true)) + // end 读取根值的结束括号,确认其后只有空白 + end := func() error { + if _, err := decoder.ReadToken(); err != nil { + return err } - if !changed { - return raw, false, nil + if _, err := decoder.ReadToken(); err != io.EOF { + return cmp.Or(err, errors.New("unexpected data after top-level value")) } - encoded, err := json.Marshal(items) - return encoded, true, err + return nil } - var fields map[string]json.RawMessage - if err := json.Unmarshal(value, &fields); err != nil { - return nil, false, err + // next 读取下一个值,返回它在 value 中的原字节 + next := func() ([]byte, error) { + item, err := decoder.ReadValue() + end := decoder.InputOffset() + return value[end-int64(len(item)) : end], err } - converted := make(map[string]json.RawMessage, len(fields)) - for key, field := range fields { - name := geminiJSONName(key) - if name != key { - changed = true - if _, exists := fields[name]; exists { - continue - } + write := func(item []byte, opaque bool) error { + if opaque || item[0] != '{' && item[0] != '[' { + output.Write(item) + return nil } - if !geminiOpaqueFields[name] { - var fieldChanged bool - var err error - field, fieldChanged, err = geminiCamelKeys(field) + return writeGeminiCamelKeys(output, item) + } + open, err := decoder.ReadToken() + if err != nil { + return err + } + if open.Kind() == '[' { + output.WriteByte('[') + for index := 0; decoder.PeekKind() != ']'; index++ { + item, err := next() if err != nil { - return nil, false, err + return err } - changed = changed || fieldChanged + if index > 0 { + output.WriteByte(',') + } + if err := write(item, false); err != nil { + return err + } + } + output.WriteByte(']') + return end() + } + type member struct { + key string + value []byte + } + var members []member + keys := make(map[string]bool) + for decoder.PeekKind() != '}' { + token, err := decoder.ReadToken() + if err != nil { + return err + } + key := token.String() + item, err := next() + if err != nil { + return err } - converted[name] = field + members = append(members, member{key: key, value: item}) + keys[key] = true } - if !changed { - return raw, false, nil + output.WriteByte('{') + written := 0 + for _, field := range members { + name := geminiJSONName(field.key) + if name != field.key && keys[name] { + continue + } + if written > 0 { + output.WriteByte(',') + } + written++ + quoted, err := jsontext.AppendQuote(output.AvailableBuffer(), name) + if err != nil { + return err + } + output.Write(quoted) + output.WriteByte(':') + if err := write(field.value, geminiOpaqueFields[name]); err != nil { + return err + } } - encoded, err := json.Marshal(converted) - return encoded, true, err + output.WriteByte('}') + return end() } // geminiKeysCamel 逐个读取 JSON token,判断各层消息字段名是否都已是 lowerCamelCase,不读取自由 JSON 字段的取值 diff --git a/internal/api/gemini_files.go b/internal/api/gemini_files.go index ddb68255..d085e02b 100644 --- a/internal/api/gemini_files.go +++ b/internal/api/gemini_files.go @@ -494,7 +494,7 @@ func readGeminiFileMetadata(body io.Reader) (geminiFileMetadata, error) { if len(bytes.TrimSpace(raw)) == 0 { return metadata, nil } - raw, _, err = geminiCamelKeys(doubleQuotedJSON(raw)) + raw, err = geminiCamelKeys(doubleQuotedJSON(raw)) if err == nil { err = json.Unmarshal(raw, &metadata) } diff --git a/internal/api/interactions.go b/internal/api/interactions.go index 517967b8..07fb7dc7 100644 --- a/internal/api/interactions.go +++ b/internal/api/interactions.go @@ -64,6 +64,10 @@ func (s *server) handleInteraction(w http.ResponseWriter, r *http.Request) { return } if previous.Upstream != "" && previous.Agent == generate.Model { + if requested := requestedAccount(r); requested != "" && requested != previous.AccountID { + writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "previous_interaction_id was created by a different account than X-AIStudio-Account") + return + } // 同一 agent 续接自己的后台任务时与官网一样只发送新输入并以其上游 interaction 续接,使用创建它的账户 generate.PreviousInteractionID, generate.AccountID = previous.Upstream, previous.AccountID } else { diff --git a/internal/api/live.go b/internal/api/live.go index dcfdeb25..0c8daa42 100644 --- a/internal/api/live.go +++ b/internal/api/live.go @@ -334,7 +334,7 @@ func sendBidiClientMessages( err = session.SendMusicPrompts(ctx, message.WeightedPrompts) case "music_config": var config []byte - config, _, err = geminiCamelKeys(message.MusicConfig) + config, err = geminiCamelKeys(message.MusicConfig) if err == nil { err = session.SendMusicConfig(ctx, config) } diff --git a/internal/api/media_test.go b/internal/api/media_test.go index d0108391..cabf2d7e 100644 --- a/internal/api/media_test.go +++ b/internal/api/media_test.go @@ -20,7 +20,7 @@ func TestGeminiInlineDataCompatibility(t *testing.T) { {name: "data URL", raw: `[{"inlineData":{"mimeType":"image/png","data":"data:image/png;base64,+//v"}}]`}, } { t.Run(test.name, func(t *testing.T) { - raw, _, err := geminiCamelKeys([]byte(test.raw)) + raw, err := geminiCamelKeys([]byte(test.raw)) if err != nil { t.Fatal(err) } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 972333a1..846e7d15 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -495,10 +495,15 @@ func authMiddleware(configuredKeys string, next http.Handler) http.Handler { }) } +// requestedAccount 返回 X-AIStudio-Account 请求头指定的账户 ID,没有指定时为空 +func requestedAccount(r *http.Request) string { + return strings.TrimSpace(r.Header.Get("X-AIStudio-Account")) +} + // accountContextMiddleware 把 X-AIStudio-Account 请求头指定的账户放进请求上下文,账户池只用该账户 func accountContextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if accountID := strings.TrimSpace(r.Header.Get("X-AIStudio-Account")); accountID != "" { + if accountID := requestedAccount(r); accountID != "" { r = r.WithContext(aistudio.ContextWithAccount(r.Context(), accountID)) } next.ServeHTTP(w, r) diff --git a/internal/api/router.go b/internal/api/router.go index 00d6a545..54b295c2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -123,6 +123,7 @@ func NewHandler(service aistudio.Service, config Config) http.Handler { public.HandleFunc("GET /v1beta/models/{model...}", s.handleGeminiModel) public.HandleFunc("POST /v1beta/models/{action}", s.handleGeminiAction) public.HandleFunc("GET /v1beta/operations/{operation}", s.handleGeminiVideoOperation) + public.HandleFunc("POST /v1beta/decisions", s.handleDecision) public.HandleFunc("POST /upload/v1beta/files", s.handleGeminiFileUpload) public.HandleFunc("GET /v1beta/files", s.handleGeminiFileList) public.HandleFunc("GET /v1beta/files/{file}", s.handleGeminiFileGet) diff --git a/internal/app/auth_retry.go b/internal/app/auth_retry.go index 1dd8efde..c873493f 100644 --- a/internal/app/auth_retry.go +++ b/internal/app/auth_retry.go @@ -359,6 +359,17 @@ func (transport *authRetryProtectedTransport) DoProtectedImage( }) } +// DoProtectedDecision 在认证失败后续签同一账户并重放 CreateDecision 请求 +func (transport *authRetryProtectedTransport) DoProtectedDecision( + ctx context.Context, + request aistudio.DecisionRequest, + rpc aistudio.RPCRequest, +) (*aistudio.RPCResponse, error) { + return transport.refresher.do(ctx, rpc.Method, func() (*aistudio.RPCResponse, error) { + return transport.transport.(aistudio.DecisionProtectedTransport).DoProtectedDecision(ctx, request, rpc) + }) +} + // Refresh 续签 generation 下失效的租约账户并保存新的 storage state,此后已有续签提交时直接复用 func (refresher *authRuntimeRefresher) Refresh(ctx context.Context, generation uint64) error { lease, ok := aistudio.AccountLeaseFromContext(ctx) diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index cde51995..9b3c715c 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -340,6 +340,13 @@ func (manager *runtimeManager) GenerateImage(ctx context.Context, request aistud return manager.current.service.(aistudio.ImageService).GenerateImage(ctx, request) } +// CreateDecision 由当前生成服务回答决策问题 +func (manager *runtimeManager) CreateDecision(ctx context.Context, request aistudio.DecisionRequest) (aistudio.DecisionResult, error) { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.service.(aistudio.DecisionService).CreateDecision(ctx, request) +} + // GenerateVideo 由当前生成服务创建视频任务 func (manager *runtimeManager) GenerateVideo(ctx context.Context, request aistudio.VideoRequest) (aistudio.VideoOperation, error) { manager.mu.RLock() diff --git a/internal/app/runtime.go b/internal/app/runtime.go index ccd700c7..35bedcf1 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -1215,6 +1215,19 @@ func (service *trackedService) GenerateImage(ctx context.Context, request aistud return images, requestErr } +// CreateDecision 回答决策问题并登记请求目标与账户 +func (service *trackedService) CreateDecision(ctx context.Context, request aistudio.DecisionRequest) (aistudio.DecisionResult, error) { + request.Model = service.catalogModelID(ctx, service.mappedModel(request.Model)) + requestCtx, cancel, err := service.observedDataRequestContext(ctx, request.Model) + if err != nil { + return aistudio.DecisionResult{}, err + } + defer cancel() + result, requestErr := service.service.(aistudio.DecisionService).CreateDecision(requestCtx, request) + api.SetAccessLogError(requestCtx, requestErr) + return result, requestErr +} + // GenerateVideo 创建一个视频长任务 func (service *trackedService) GenerateVideo(ctx context.Context, request aistudio.VideoRequest) (aistudio.VideoOperation, error) { request.Model = service.mappedModel(request.Model) @@ -1930,10 +1943,10 @@ func (service *trackedService) generateWithRetry( if winner != nil { activity = winner.activity if winner.hedge { - service.markAttemptAccount(requestCtx, request.ID, modelID, winner.lease) + service.markAttemptAccount(requestCtx, request.ID, modelID, winner.serving.Load()) } api.SetAccessLogFirstEvent(requestCtx, winner.firstAt.Sub(generationStartedAt)) - api.SetAccessLogTarget(requestCtx, winner.first.ProviderModel, winner.accountLabel) + api.SetAccessLogTarget(requestCtx, winner.first.ProviderModel, winner.serving.Load().Account().Config.Label) if request.Unary { collected, collectErr := collectGenerateEvents(requestCtx, winner.source, func(waited time.Duration, events []aistudio.Event) { reasoning, content := 0, 0 @@ -2255,7 +2268,7 @@ func (err *quotaCooldownError) RetryAt(time.Time) time.Time { return err.until } -// settleAttempt 处理失败的尝试:记录访问日志与冷却,释放租约,mayRecover 为真时按需重建 Worker +// settleAttempt 处理失败的尝试:在正在生成回答的账户上记录访问日志、冷却与认证失败,释放其租约,mayRecover 为真时按需重建 Worker func (service *trackedService) settleAttempt( requestCtx context.Context, requestID string, @@ -2265,8 +2278,12 @@ func (service *trackedService) settleAttempt( mayRecover bool, ) attemptOutcome { err := attempt.err - lease := attempt.lease - accountID, accountLabel := lease.Account().ID, attempt.accountLabel + lease := attempt.serving.Load() + accountID, accountLabel := lease.Account().ID, lease.Account().Config.Label + proxy, workerGeneration := attempt.proxy, attempt.workerGeneration + if lease != attempt.lease { + proxy, workerGeneration = service.proxies.Binding(accountID), service.workers.WorkerGeneration(accountID) + } missingInlineMedia := false if attempt.copies != nil { err = errors.Join(err, attempt.copies.Cleanup()) @@ -2281,7 +2298,7 @@ func (service *trackedService) settleAttempt( DurationMS: time.Since(attempt.startedAt).Milliseconds(), Hedge: attempt.hedge, }) if proxyNetworkError(requestCtx, err) { - service.reportProxyFailure(attempt.proxy, accountLabel, err) + service.reportProxyFailure(proxy, accountLabel, err) } workerFailed := service.workers.WorkerFailed(accountID) waaRuntimeFailed := aistudio.DefinitiveWAARuntimeFailure(err) @@ -2341,14 +2358,14 @@ func (service *trackedService) settleAttempt( if !quotaFailure && !buildRejected { retry.attemptFailure = err } - if releaseErr := attempt.serving.Load().Release(); releaseErr != nil { + if releaseErr := lease.Release(); releaseErr != nil { return attemptOutcome{err: errors.Join(err, releaseErr), accountID: accountID, accountLabel: accountLabel} } recoverWorker := false if requestCtx.Err() == nil && mayRecover { var resetErr error recoverWorker, _, resetErr = service.recoverWorkerOnce( - requestCtx, accountID, attempt.workerGeneration, retry.recoveredWorker, + requestCtx, accountID, workerGeneration, retry.recoveredWorker, needsWAARuntimeRecovery(err, workerFailed, workerReplaced) || protectedTransport, workerFailed || waaRuntimeFailed, ) diff --git a/internal/app/shell_desktop.go b/internal/app/shell_desktop.go index 081723ed..5ec2a05d 100644 --- a/internal/app/shell_desktop.go +++ b/internal/app/shell_desktop.go @@ -73,10 +73,10 @@ type uiShell struct { app *desktop.App } -// newShell 创建桌面应用,open 为 false 时窗口保持隐藏、只显示托盘,hideDock 为 true 时 macOS 不显示 Dock 图标,stop 在桌面应用退出前结束生成服务 +// newShell 创建桌面应用,open 为 false 时窗口保持隐藏、只显示托盘,hideDock 为 true 时 macOS 不显示 Dock 图标,stop 在桌面应用退出前结束生成服务;Windows 的 WebView2 数据写入 %LocalAppData%\AIStudio2API\WebView2 func newShell(open, hideDock bool, stop func()) *uiShell { - webview, _ := filepath.Abs(filepath.Join("runtime", "webview")) - return &uiShell{app: desktop.New(open, hideDock, webview, stop)} + cache, _ := os.UserCacheDir() + return &uiShell{app: desktop.New(open, hideDock, filepath.Join(cache, "AIStudio2API", "WebView2"), stop)} } // run 运行窗口与托盘:服务就绪前显示启动画面,就绪后显示管理页面,启动失败时显示错误对话框后退出 diff --git a/internal/pricing/pricing.go b/internal/pricing/pricing.go index c615029c..6220408e 100644 --- a/internal/pricing/pricing.go +++ b/internal/pricing/pricing.go @@ -28,6 +28,7 @@ var prices = map[string]price{ "gemini-2.5-flash-lite": {input: 0.10, cached: 0.01, output: 0.40}, "gemini-robotics-er-2-preview": {input: 1, cached: 0.10, output: 5}, "gemini-robotics-er-2-streaming-preview": {input: 1, cached: 0.10, output: 5}, + "gemini-decision-1": {input: 0.10}, } // Priced 返回模型是否有单价 diff --git a/web/src/access/capabilities.ts b/web/src/access/capabilities.ts index 45f24a53..52c5ae93 100644 --- a/web/src/access/capabilities.ts +++ b/web/src/access/capabilities.ts @@ -13,6 +13,7 @@ import type { Model } from '@/types' import { anthropicPython, anthropicTypescript, + decisionCode, embeddingCode, filesCode, transcriptionCode, @@ -258,6 +259,17 @@ export const capabilities: Capability[] = [ languages: () => openAIOptions, code: (input) => embeddingCode(input.language, input), }, + { + id: 'decision', + label: 'access.cap.decision', + description: 'access.cap.decisionHelp', + icon: 'decision', + endpoints: [['POST', '/v1beta/decisions']], + matches: (model) => model.methods.includes('createDecision'), + protocols: [], + languages: () => googleOptions, + code: (input) => decisionCode(input.language, input), + }, { id: 'live', label: 'access.cap.live', diff --git a/web/src/access/endpoints.ts b/web/src/access/endpoints.ts index 20cf7c72..2046b31d 100644 --- a/web/src/access/endpoints.ts +++ b/web/src/access/endpoints.ts @@ -256,6 +256,26 @@ export const endpointGroups: EndpointGroup[] = [ }, ], }, + { + name: 'Gemini Decisions', + description: 'access.endpoint.decisions', + auth: 'gemini', + endpoints: [ + { + method: 'POST', + path: '/v1beta/decisions', + summary: 'access.ep.decisionCreate', + docs: 'https://ai.google.dev/gemini-api/docs/decisions-api', + body: { + model: '{model}', + input: 'I was charged twice for my order.', + questions: { + is_refund: { type: 'noul', instructions: 'Is the customer asking for money back?' }, + }, + }, + }, + ], + }, { name: 'Embeddings', description: 'access.endpoint.embeddings', diff --git a/web/src/access/snippets.ts b/web/src/access/snippets.ts index 6ec38818..37802915 100644 --- a/web/src/access/snippets.ts +++ b/web/src/access/snippets.ts @@ -122,6 +122,77 @@ console.log(result.data[0].embedding.length); } } +// decisionCode 生成 Decisions API 示例:对同一段输入同时提出是非与单选问题 +export function decisionCode(language: string, { origin, modelID }: SnippetInput): string { + switch (language) { + case 'python': + return `import os + +from google import genai +from google.genai import types + +client = genai.Client( + api_key=os.environ.get("GEMINI_API_KEY"), + http_options=types.HttpOptions(base_url="${origin}", api_version="v1beta"), +) + +result = client.decisions.create( + model="${modelID}", + input="I was charged twice for my order.", + questions={ + "is_refund": {"type": "noul", "instructions": "Is the customer asking for money back?"}, + "department": { + "type": "choice", + "instructions": "Which department should handle this request?", + "criteria": {"billing": "Payments and refunds.", "shipping": "Delivery and tracking.", "other": ""}, + }, + }, +) +print(result.answers["is_refund"].noul, result.answers["department"].choice) +` + case 'typescript': + return `import { GoogleGenAI } from '@google/genai'; + +const ai = new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY, + apiVersion: 'v1beta', + httpOptions: { baseUrl: '${origin}' }, +}); + +const result = await ai.decisions.create({ + model: '${modelID}', + input: 'I was charged twice for my order.', + questions: { + is_refund: { type: 'noul', instructions: 'Is the customer asking for money back?' }, + department: { + type: 'choice', + instructions: 'Which department should handle this request?', + criteria: { billing: 'Payments and refunds.', shipping: 'Delivery and tracking.', other: '' }, + }, + }, +}); +console.log(result.answers?.is_refund, result.answers?.department); +` + default: + return `curl "${origin}/v1beta/decisions" \\ + -H "x-goog-api-key: $GEMINI_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${modelID}", + "input": "I was charged twice for my order.", + "questions": { + "is_refund": {"type": "noul", "instructions": "Is the customer asking for money back?"}, + "department": { + "type": "choice", + "instructions": "Which department should handle this request?", + "criteria": {"billing": "Payments and refunds.", "shipping": "Delivery and tracking.", "other": ""} + } + } + }' +` + } +} + // filesCode 生成上传 document.pdf 后在提问中引用该文件的示例:Gemini SDK 经 Files API,OpenAI SDK 经 Files 与 Responses export function filesCode(language: string, { origin, modelID }: SnippetInput): string { switch (language) { diff --git a/web/src/icons.ts b/web/src/icons.ts index e832882f..8d6eb101 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -72,6 +72,7 @@ import { Binary, Radio, Bot, + Scale, FileText, } from '@lucide/vue' @@ -147,6 +148,7 @@ export const icons = { embedding: Binary, live: Radio, agent: Bot, + decision: Scale, file: FileText, folder: Folder, help: CircleHelp, diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index e92ebca7..2819459a 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -2131,6 +2131,9 @@ export const messages: Record = { 'Einen Videoauftrag einreichen und das Video nach Abschluss herunterladen', 'access.cap.embedding': 'Embeddings', 'access.cap.embeddingHelp': 'Text in Vektoren für Suche und Ähnlichkeit umwandeln', + 'access.cap.decision': 'Entscheidungen', + 'access.cap.decisionHelp': + 'Beantwortet Ja/Nein-, Auswahl- und Bewertungsfragen zu einer Eingabe mit Wahrscheinlichkeiten und Konfidenz', 'access.cap.live': 'Live', 'access.cap.liveHelp': 'Text und Audio über WebSocket in beide Richtungen austauschen', 'access.cap.agent': 'Agents', @@ -2153,6 +2156,8 @@ export const messages: Record = { 'Gemini-Generierung, Streaming-Generierung, Tokenzählung und Kontext-Caching', 'access.endpoint.interactions': 'Gemini Interactions: erstellen, abrufen, löschen und abbrechen; dieselben Pfade funktionieren auch unter /v1', + 'access.endpoint.decisions': + 'Gemini Decisions: eine Reihe von Fragen in einem Aufruf mit dem Entscheidungsmodell beantworten', 'access.endpoint.embeddings': 'Text-Embeddings im OpenAI- und Gemini-Format', 'access.endpoint.images': 'OpenAI-Bildgenerierung und -bearbeitung sowie Gemini-Imagen-predict', 'access.endpoint.audio': 'Sprachausgabe, Transkription und Übersetzung', @@ -2200,6 +2205,7 @@ export const messages: Record = { 'access.ep.interactionGet': 'Status und Ergebnis einer Interaktion abrufen', 'access.ep.interactionDelete': 'Interaktion löschen', 'access.ep.interactionCancel': 'Laufende Interaktion abbrechen', + 'access.ep.decisionCreate': 'Eine Reihe von Entscheidungsfragen beantworten', 'access.ep.embeddings': 'Embeddings erzeugen', 'access.ep.embedContent': 'Embedding für einen Inhalt erzeugen', 'access.ep.batchEmbed': 'Embeddings für mehrere Inhalte in einer Anfrage erzeugen', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 0f5d3b8c..a9d2d2e8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2086,6 +2086,9 @@ export const messages: Record = { 'access.cap.videoHelp': 'Submit a video generation job and download the video when it finishes', 'access.cap.embedding': 'Embeddings', 'access.cap.embeddingHelp': 'Turn text into vectors for search and similarity', + 'access.cap.decision': 'Decisions', + 'access.cap.decisionHelp': + 'Answer yes/no, choice, and score questions about one input with probabilities and confidence', 'access.cap.live': 'Live', 'access.cap.liveHelp': 'Stream text and audio both ways over a WebSocket', 'access.cap.agent': 'Agents', @@ -2108,6 +2111,8 @@ export const messages: Record = { 'Gemini generation, streaming generation, token counting, and context caching', 'access.endpoint.interactions': 'Gemini Interactions: create, read, delete, and cancel; the same paths also work under /v1', + 'access.endpoint.decisions': + 'Gemini Decisions: answer a set of questions with the decision model in one call', 'access.endpoint.embeddings': 'Text embeddings in OpenAI and Gemini formats', 'access.endpoint.images': 'OpenAI image generation and editing, and Gemini Imagen predict', 'access.endpoint.audio': 'Speech, transcription, and translation', @@ -2156,6 +2161,7 @@ export const messages: Record = { 'access.ep.interactionGet': 'Get an interaction’s status and output', 'access.ep.interactionDelete': 'Delete an interaction', 'access.ep.interactionCancel': 'Cancel a running interaction', + 'access.ep.decisionCreate': 'Answer a set of decision questions', 'access.ep.embeddings': 'Create embeddings', 'access.ep.embedContent': 'Embed one piece of content', 'access.ep.batchEmbed': 'Embed several pieces of content in one request', diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index e9d84f7f..71bc4325 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -2138,6 +2138,9 @@ export const messages: Record = { 'Soumettez une tâche de génération vidéo et téléchargez la vidéo une fois terminée', 'access.cap.embedding': 'Embeddings', 'access.cap.embeddingHelp': 'Transformez du texte en vecteurs pour la recherche et la similarité', + 'access.cap.decision': 'Décisions', + 'access.cap.decisionHelp': + 'Répond à des questions oui/non, à choix et à score sur une même entrée, avec probabilités et confiance', 'access.cap.live': 'Live', 'access.cap.liveHelp': 'Échangez du texte et de l’audio dans les deux sens via WebSocket', 'access.cap.agent': 'Agents', @@ -2160,6 +2163,8 @@ export const messages: Record = { 'Génération Gemini, génération en streaming, comptage des tokens et cache de contexte', 'access.endpoint.interactions': 'Gemini Interactions : création, lecture, suppression et annulation ; les mêmes chemins fonctionnent aussi sous /v1', + 'access.endpoint.decisions': + 'Gemini Decisions : répondre en un appel à un ensemble de questions avec le modèle de décision', 'access.endpoint.embeddings': 'Embeddings de texte aux formats OpenAI et Gemini', 'access.endpoint.images': 'Génération et modification d’images OpenAI, et predict de Gemini Imagen', @@ -2209,6 +2214,7 @@ export const messages: Record = { 'access.ep.interactionGet': 'Obtenir l’état et le résultat d’une interaction', 'access.ep.interactionDelete': 'Supprimer une interaction', 'access.ep.interactionCancel': 'Annuler une interaction en cours', + 'access.ep.decisionCreate': 'Répondre à un ensemble de questions de décision', 'access.ep.embeddings': 'Créer des embeddings', 'access.ep.embedContent': 'Créer l’embedding d’un contenu', 'access.ep.batchEmbed': 'Créer les embeddings de plusieurs contenus en une requête', diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index f8046f3f..6c9cd071 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -2078,6 +2078,8 @@ export const messages: Record = { 'access.cap.videoHelp': '動画生成ジョブを送信し、完了後に動画をダウンロードします', 'access.cap.embedding': '埋め込み', 'access.cap.embeddingHelp': 'テキストをベクトルに変換し、検索や類似度の計算に使います', + 'access.cap.decision': '判定', + 'access.cap.decisionHelp': '1 つの入力に対する Yes/No・選択・スコアの質問に、確率と信頼度で回答', 'access.cap.live': 'Live', 'access.cap.liveHelp': 'WebSocket でテキストと音声を双方向にやり取りします', 'access.cap.agent': 'エージェント', @@ -2100,6 +2102,7 @@ export const messages: Record = { 'Gemini の生成、ストリーミング生成、トークン数の計算、コンテキストキャッシュ', 'access.endpoint.interactions': 'Gemini Interactions:作成、取得、削除、キャンセル。/v1 プレフィックスのパスも使えます', + 'access.endpoint.decisions': 'Gemini Decisions:判定モデルで複数の質問に一度に回答', 'access.endpoint.embeddings': 'OpenAI 形式と Gemini 形式のテキスト埋め込み', 'access.endpoint.images': 'OpenAI の画像生成と編集、Gemini Imagen の predict', 'access.endpoint.audio': '音声合成、文字起こし、翻訳', @@ -2146,6 +2149,7 @@ export const messages: Record = { 'access.ep.interactionGet': 'インタラクションの状態と結果を取得', 'access.ep.interactionDelete': 'インタラクションを削除', 'access.ep.interactionCancel': '実行中のインタラクションをキャンセル', + 'access.ep.decisionCreate': '判定の質問にまとめて回答', 'access.ep.embeddings': 'テキストの埋め込みを生成', 'access.ep.embedContent': '1 件のコンテンツを埋め込みに変換', 'access.ep.batchEmbed': '複数のコンテンツを一括で埋め込みに変換', diff --git a/web/src/locales/ko.ts b/web/src/locales/ko.ts index fefa76a6..e3b4368b 100644 --- a/web/src/locales/ko.ts +++ b/web/src/locales/ko.ts @@ -2055,6 +2055,8 @@ export const messages: Record = { 'access.cap.videoHelp': '동영상 생성 작업을 제출하고 완료되면 동영상을 내려받습니다', 'access.cap.embedding': '임베딩', 'access.cap.embeddingHelp': '텍스트를 벡터로 바꿔 검색과 유사도 계산에 사용합니다', + 'access.cap.decision': '판정', + 'access.cap.decisionHelp': '하나의 입력에 대한 예/아니요, 선택, 점수 질문에 확률과 신뢰도로 답변', 'access.cap.live': 'Live', 'access.cap.liveHelp': 'WebSocket으로 텍스트와 오디오를 양방향으로 주고받습니다', 'access.cap.agent': '에이전트', @@ -2076,6 +2078,7 @@ export const messages: Record = { 'access.endpoint.gemini': 'Gemini 생성, 스트리밍 생성, 토큰 계산, 컨텍스트 캐시', 'access.endpoint.interactions': 'Gemini Interactions: 생성, 조회, 삭제, 취소. /v1 접두사 경로도 사용할 수 있습니다', + 'access.endpoint.decisions': 'Gemini Decisions: 판정 모델로 여러 질문에 한 번에 답변', 'access.endpoint.embeddings': 'OpenAI 형식과 Gemini 형식의 텍스트 임베딩', 'access.endpoint.images': 'OpenAI 이미지 생성과 편집, Gemini Imagen predict', 'access.endpoint.audio': '음성 합성, 전사, 번역', @@ -2122,6 +2125,7 @@ export const messages: Record = { 'access.ep.interactionGet': '인터랙션 상태와 결과 조회', 'access.ep.interactionDelete': '인터랙션 삭제', 'access.ep.interactionCancel': '진행 중인 인터랙션 취소', + 'access.ep.decisionCreate': '판정 질문에 한 번에 답변', 'access.ep.embeddings': '텍스트 임베딩 생성', 'access.ep.embedContent': '콘텐츠 하나의 임베딩 생성', 'access.ep.batchEmbed': '여러 콘텐츠의 임베딩을 한 번에 생성', diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts index 54f9e776..44952909 100644 --- a/web/src/locales/zh-CN.ts +++ b/web/src/locales/zh-CN.ts @@ -1942,6 +1942,8 @@ export const messages = { 'access.cap.videoHelp': '提交视频生成任务,完成后下载视频', 'access.cap.embedding': '文本嵌入', 'access.cap.embeddingHelp': '把文本转成向量,用于检索与相似度计算', + 'access.cap.decision': '决策', + 'access.cap.decisionHelp': '对同一份输入回答是非、单选与评分问题,返回概率与置信度', 'access.cap.live': '实时对话', 'access.cap.liveHelp': '经 WebSocket 双向收发文字与音频', 'access.cap.agent': 'Agent', @@ -1962,6 +1964,7 @@ export const messages = { 'access.endpoint.gemini': 'Gemini 生成、流式生成、token 计数与上下文缓存', 'access.endpoint.interactions': 'Gemini Interactions:创建、读取、删除与取消;路径也可用 /v1 前缀', + 'access.endpoint.decisions': 'Gemini Decisions:以决策模型一次回答一组问题', 'access.endpoint.embeddings': 'OpenAI 与 Gemini 格式的文本嵌入', 'access.endpoint.images': 'OpenAI 图片生成与编辑,Gemini Imagen 预测', 'access.endpoint.audio': '语音合成、转写与翻译', @@ -2007,6 +2010,7 @@ export const messages = { 'access.ep.interactionGet': '读取交互的状态与结果', 'access.ep.interactionDelete': '删除交互', 'access.ep.interactionCancel': '取消进行中的交互', + 'access.ep.decisionCreate': '回答一组决策问题', 'access.ep.embeddings': '生成文本向量', 'access.ep.embedContent': '为一段内容生成向量', 'access.ep.batchEmbed': '批量生成向量', diff --git a/web/src/locales/zh-TW.ts b/web/src/locales/zh-TW.ts index 1e2cf0a1..9ec92fcf 100644 --- a/web/src/locales/zh-TW.ts +++ b/web/src/locales/zh-TW.ts @@ -1949,6 +1949,8 @@ export const messages: Record = { 'access.cap.videoHelp': '提交影片生成工作,完成後下載影片', 'access.cap.embedding': '文字嵌入', 'access.cap.embeddingHelp': '把文字轉成向量,用於檢索與相似度計算', + 'access.cap.decision': '決策', + 'access.cap.decisionHelp': '對同一份輸入回答是非、單選與評分問題,傳回機率與信賴度', 'access.cap.live': '即時對話', 'access.cap.liveHelp': '透過 WebSocket 雙向收發文字與音訊', 'access.cap.agent': 'Agent', @@ -1969,6 +1971,7 @@ export const messages: Record = { 'access.endpoint.gemini': 'Gemini 生成、串流生成、token 計數與內容快取', 'access.endpoint.interactions': 'Gemini Interactions:建立、讀取、刪除與取消;路徑也可使用 /v1 前綴', + 'access.endpoint.decisions': 'Gemini Decisions:以決策模型一次回答一組問題', 'access.endpoint.embeddings': 'OpenAI 與 Gemini 格式的文字嵌入', 'access.endpoint.images': 'OpenAI 圖片生成與編輯,Gemini Imagen 預測', 'access.endpoint.audio': '語音合成、轉錄與翻譯', @@ -2014,6 +2017,7 @@ export const messages: Record = { 'access.ep.interactionGet': '讀取互動的狀態與結果', 'access.ep.interactionDelete': '刪除互動', 'access.ep.interactionCancel': '取消進行中的互動', + 'access.ep.decisionCreate': '回答一組決策問題', 'access.ep.embeddings': '產生文字向量', 'access.ep.embedContent': '為一段內容產生向量', 'access.ep.batchEmbed': '批次產生向量',