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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
- **视频生成**: 支持 Veo 视频生成和图片转视频,目录中没有 Veo 时视频长任务由 Gemini Omni 生成;Gemini Omni 通过四套生成接口接收文本、图片与视频输入,输出文本与 MP4 视频
- **Deep Research**: Pro 与 Ultra 账户可通过四套生成接口与 Interactions 的 `agent` 字段调用 Deep Research,输出研究过程、带引用的报告与图表
- **YouTube 输入**: YouTube 视频 URL 作为外部视频附件读取;开启 `YOUTUBE_LINKS` 后,消息文本中的 YouTube 链接同样作为视频读取
- **视频理解设置**: Gemini 与 Interactions 支持逐视频的起止时间、0–24 FPS 范围内的正数采样率和静态/Agentic 处理;Agentic 返回服务端处理步骤并支持历史续接
- **智能模型切换**: 从 AI Studio 实时发现模型并按 `model` 字段路由
- **Google 工具**: 支持 Search、Image Search、URL Context、Code Execution 和 Maps
- **Files 与 Transcribe**: 支持文件上传、查询、内容读取、删除和音频转录
Expand Down
1 change: 1 addition & 0 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
- **Video Generation**: Veo video generation and image-to-video, with video tasks generated by Gemini Omni when the catalog has no Veo model; 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
- **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
- **Google Tools**: Search, Image Search, URL Context, Code Execution, and Maps
- **Files and Transcribe**: File upload, metadata, content, deletion, and audio transcription
Expand Down
26 changes: 25 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ field 1 是 `request_key`,field 2 是 `botguard_response`。正确 proof、损
| 52 | thinking level | 53 | music route |
| 54 | image search | 58 | Google Maps |
| 59 | private Interaction route | 85 | TTS 分段说话人 |
| 69 | Agentic 视频理解 | | |
| 46 | Live 实时翻译 | 48 | Build 写代码助手可选 |

未知能力码按原值保留为 `capability_code_<N>` 或 `secondary_capability_code_<N>`。
Expand All @@ -326,6 +327,7 @@ field 1 是 `request_key`,field 2 是 `botguard_response`。正确 proof、损
| 52 | `thinking_level` | 53 | `music_route` |
| 54 | `image_search` | 58 | `google_maps` |
| 59 | `interaction_route` | 74 | `transcription_word_timestamps` |
| 69 | `agentic_video` | | |
| 76 | `transcription_language_codes` | 77 | `transcription_output` |
| 80 | `transcription_speaker_labels` | 81 | `transcription_custom_vocabulary` |
| 84 | `transcription_smart` | 85 | `speech_metadata` |
Expand Down Expand Up @@ -429,8 +431,12 @@ Content 形状:
| 11 | 12 | function result `[name, Struct, callId?]` |
| 12 | 13 | thought boolean |
| 14 | 15 | thought signature |
| 15 | 16 | 视频静态处理元数据 `[startOffset?, endOffset?, fps?]`;offset 为 `[seconds,nanos?]` |
| 17 | 18 | 上传附件 `[mime, 上传路径, 文件名, 字节数]`,字节数为十进制字符串;用于 Build 写代码助手,见 [Build 通道](build.md) 第 8 节 |
| 22 | 23 | transcription metadata `[text, speaker?, timestamp spans?]` |
| 25 | 26 | 服务端媒体处理调用 `[13, name?, Struct?, callId?]` |
| 26 | 27 | 服务端媒体处理结果 `[13, name?, Struct?, callId?]` |
| 39 | 40 | 视频处理模式:`1` STATIC、`2` AGENTIC |

system instruction:

Expand Down Expand Up @@ -1969,6 +1975,7 @@ delta 联合类型为 `text_delta{text}`、`thinking_delta{thinking}`、`signatu
| --- | --- |
| `input` | 字符串、单个内容块、内容块数组或步骤数组;内容类型为 `text`、`image`、`audio`、`video`、`document` |
| 媒体内容 | `data`(Base64)或 `uri`,可选 `mime_type` |
| 视频处理 | `processing:"static"`、`"agentic"` 或 `{type:"static",start_offset?,end_offset?,fps?}`;offset 为带 `s` 的 Duration 字符串,例如 `"2.5s"` |
| 输入步骤 | `user_input`、`model_output`、`thought`、`function_call`、`function_result`、`code_execution_call`、`code_execution_result` 与工具 call/result 步骤 |
| `system_instruction` | 当前请求的系统指令 |
| `generation_config` | `temperature`、`top_p`、`top_k`、`max_output_tokens`、`seed`、`stop_sequences`、`thinking_level`、`thinking_summaries`、`speech_config`、`tool_choice`、`image_config:{aspect_ratio?,image_size?}` |
Expand All @@ -1983,7 +1990,8 @@ delta 联合类型为 `text_delta{text}`、`thinking_delta{thinking}`、`signatu
| 续接 | `previous_interaction_id`、`store` |

- 媒体内容的 `data` 与 `uri` 二选一,复用 Gemini 文件与内联媒体解析:省略 `mime_type` 时内联数据按内容识别类型,无法识别时返回 `400 INVALID_ARGUMENT`;`uri` 为外部 http(s) URL 时由服务下载为内联媒体,规则与 GenerateContent 的 `fileData` 相同;本服务 Gemini 文件接口返回的 `uri` 作为文件引用
- 函数结果通过 `call_id` 匹配历史调用,没有对应调用时使用自带的 `name`;`is_error:true` 的结果以 `{"error":<result>}` 发送上游;`code_execution_call` 与 `code_execution_result` 以代码块文本并入模型轮次;`google_search`、`url_context`、`google_maps`、`file_search`、`mcp_server_tool`、`retrieval`、`processing` 的 call 与 result 步骤不发送上游
- 函数结果通过 `call_id` 匹配历史调用,没有对应调用时使用自带的 `name`;`is_error:true` 的结果以 `{"error":<result>}` 发送上游;`code_execution_call` 与 `code_execution_result` 以代码块文本并入模型轮次;`google_search`、`url_context`、`google_maps`、`file_search`、`mcp_server_tool`、`retrieval` 的 call 与 result 步骤不发送上游
- `processing_call` 和 `processing_result` 保留服务端媒体处理调用的 `id`、对应的 `call_id` 与各自的 `signature`,作为模型历史续接;流式响应按 `step.start`、`step.delta`、`step.stop` 发布,签名位于 delta
- 文本 `response_format` 带 `schema` 时请求结构化输出,`mime_type` 默认 `application/json`;不带 `type` 或 `type` 为 JSON Schema 类型的对象直接作为结构化输出 Schema;图片指定 `mime_type:"image/jpeg"` 时其他格式的图片输出转为 JPEG
- `response_format` 中的图片比例、尺寸与文本 `mime_type` 优先于 `generation_config.image_config` 与 `response_mime_type`;`response_modalities` 中的其他取值返回 `400 INVALID_ARGUMENT`
- `speech_config` 的 `language` 不发送上游,多说话人 `mode` 为 `verbatim` 或 `conversational`;`speech_metadata` 保留说话人与风格
Expand Down Expand Up @@ -2032,11 +2040,27 @@ Content 字段为 `role` 与 `parts`。Part oneof:
| file data | `fileData:{mimeType?,fileUri,displayName}` |
| function call | `functionCall:{id,name,args}` |
| function response | `functionResponse:{id,name,response}` |
| server media processing call | `toolCall:{id?,toolType:"MEDIA_PROCESSING",toolName?,args?}` |
| server media processing response | `toolResponse:{id?,toolType:"MEDIA_PROCESSING",response?}` |
| executable code | `executableCode:{language,code}` |
| code result | `codeExecutionResult:{outcome,output,error}` |

不含数据字段且没有 `thoughtSignature` 的 Part 不发送;同一 Part 含多个数据字段时返回 `400 INVALID_ARGUMENT`。`fileData.fileUri` 为 YouTube 链接时作为外部视频,为本服务 `/upload/v1beta/files` 返回的文件 URI 或 `/v1/files` 文件 ID 时作为文件引用,为其他 http(s) URL 时由服务下载为内联媒体;其他 Gemini Files API URI 不下载。外部 URL 在同一请求内只下载一次,上限 512 MiB,MIME 依次取响应 `Content-Type`、`mimeType` 与内容识别;主机或任一重定向目标解析到本机、私网、链路本地、组播或未指定地址时不连接,按下载失败处理;下载失败返回带 URL 与原因的 `400 INVALID_ARGUMENT`。`:countTokens` 同样先下载外部 URL 再计数。

视频 Part 可设置 `mediaProcessing` 为 `STATIC`、`AGENTIC` 或 `MEDIA_PROCESSING_UNSPECIFIED`。省略时沿用模型默认处理方式。`AGENTIC` 使用模型目录的 `agentic_video` 能力,并返回 `toolType:"MEDIA_PROCESSING"` 的服务端 `toolCall` 与 `toolResponse`;调用标识与 `thoughtSignature` 随完整模型 Part 一起用于下一轮历史。

静态视频的 `videoMetadata` 可设置 `startOffset`、`endOffset` 与 `fps`,与该 Part 的视频附件一起参与生成及 token 计数。offset 为非负秒数 Duration 字符串,支持最多九位小数,例如 `"2.5s"`;显式 `"0s"` 保留起点,终点须晚于起点。FPS 范围为 `(0,24]`,静态参数与 `AGENTIC` 互斥。例如:

```json
{
"fileData": {"mimeType":"video/*","fileUri":"https://www.youtube.com/watch?v=<VIDEO_ID>"},
"mediaProcessing": "STATIC",
"videoMetadata": {"startOffset":"2s","endOffset":"4s","fps":0.5}
}
```

这些参数与媒体处理历史适用于上游使用 GenerateContent 的模型;使用原生 CreateInteractionStream 或 CreateInteraction 的模型收到这些参数时返回参数错误。

`generationConfig` 全字段:

| 类别 | 字段 |
Expand Down
49 changes: 49 additions & 0 deletions internal/aistudio/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ func encodeBuildPart(part Part) (map[string]any, error) {
result["output"] = output
}
wire["codeExecutionResult"] = result
case part.ProcessingCall != nil:
wire["toolCall"] = buildMediaProcessingStep(part.ProcessingCall, "args")
case part.ProcessingResult != nil:
wire["toolResponse"] = buildMediaProcessingStep(part.ProcessingResult, "response")
case signature != "":
wire["text"] = ""
default:
Expand All @@ -248,9 +252,37 @@ func encodeBuildPart(part Part) (map[string]any, error) {
if signature != "" {
wire["thoughtSignature"] = signature
}
if video := part.VideoMetadata; video != nil {
metadata := map[string]any{}
if video.StartOffset != "" {
metadata["startOffset"] = video.StartOffset
}
if video.EndOffset != "" {
metadata["endOffset"] = video.EndOffset
}
if video.FPS != nil {
metadata["fps"] = *video.FPS
}
wire["videoMetadata"] = metadata
}
if part.MediaProcessing != "" {
wire["mediaProcessing"] = part.MediaProcessing
}
return wire, nil
}

// buildMediaProcessingStep 编码视频理解处理的 toolCall 或 toolResponse,payload 为 args 或 response
func buildMediaProcessingStep(step *MediaProcessingStep, payload string) map[string]any {
value := map[string]any{"toolType": "MEDIA_PROCESSING", "id": step.ID}
if step.Name != "" {
value["toolName"] = step.Name
}
if len(step.Data) != 0 {
value[payload] = step.Data
}
return value
}

// buildJSONObject 把 JSON 值转成 Gemini API Struct,非对象值放在 result 字段
func buildJSONObject(raw json.RawMessage) (any, error) {
trimmed := bytes.TrimSpace(raw)
Expand Down Expand Up @@ -491,6 +523,17 @@ type buildResponsePart struct {
Name string `json:"name"`
Args json.RawMessage `json:"args"`
} `json:"functionCall"`
ToolCall *buildToolStep `json:"toolCall"`
ToolResponse *buildToolStep `json:"toolResponse"`
}

// buildToolStep 表示 Gemini API 服务端工具的 toolCall 或 toolResponse
type buildToolStep struct {
ID string `json:"id"`
ToolType string `json:"toolType"`
ToolName string `json:"toolName"`
Args json.RawMessage `json:"args"`
Response json.RawMessage `json:"response"`
}

type buildGrounding struct {
Expand Down Expand Up @@ -664,6 +707,12 @@ func decodeBuildPart(part buildResponsePart, index int, raw []byte) ([]Event, er
call := FunctionCall{ID: part.FunctionCall.ID, Name: part.FunctionCall.Name, Arguments: arguments, ThoughtSignature: signature}
events = append(events, Event{Kind: EventToolCall, ToolCall: &call, ThoughtSignature: signature})
}
if step := part.ToolCall; step != nil && step.ToolType == "MEDIA_PROCESSING" {
events = append(events, Event{Kind: EventMediaProcessingCall, MediaProcessing: &MediaProcessingStep{ID: step.ID, Name: step.ToolName, Data: step.Args}, ThoughtSignature: signature})
}
if step := part.ToolResponse; step != nil && step.ToolType == "MEDIA_PROCESSING" {
events = append(events, Event{Kind: EventMediaProcessingResult, MediaProcessing: &MediaProcessingStep{ID: step.ID, Name: step.ToolName, Data: step.Response}, ThoughtSignature: signature})
}
if len(events) == 0 && signature != "" {
events = append(events, Event{Kind: EventThoughtSignature, ThoughtSignature: signature})
}
Expand Down
14 changes: 14 additions & 0 deletions internal/aistudio/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,20 @@ func (d *FrameDecoder) decodePart(raw json.RawMessage, path string, evidence jso
})
}
}
for _, field := range []struct {
index int
kind EventKind
}{{25, EventMediaProcessingCall}, {26, EventMediaProcessingResult}} {
if value := rawAt(part, field.index); !isJSONNull(value) {
step, err := decodeMediaProcessingStep(value, fmt.Sprintf("%s[%d]", path, field.index), raw)
if err != nil {
return nil, err
}
if step != nil {
events = append(events, Event{Kind: field.kind, MediaProcessing: step, ThoughtSignature: signature})
}
}
}
if len(events) == 0 {
if signature != "" {
return []Event{{Kind: EventThoughtSignature, ThoughtSignature: signature}}, nil
Expand Down
6 changes: 6 additions & 0 deletions internal/aistudio/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,9 @@ func (c *Client) validateGenerateRequest(request GenerateRequest) error {

// validateGenerateEntry 按 Generate 的顺序校验工具、转写、生成参数与安全设置
func validateGenerateEntry(request GenerateRequest, entry modelEntry) error {
if err := validateVideoOffsets(request.Contents); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidArgument, err)
}
if _, err := supportedTools(request.Tools, entry.model); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidArgument, err)
}
Expand Down Expand Up @@ -676,6 +679,9 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan
if err != nil {
return nil, err
}
if err := validateVideoOffsets(request.Contents); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err)
}
request.Tools, err = supportedTools(request.Tools, entry.model)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err)
Expand Down
2 changes: 2 additions & 0 deletions internal/aistudio/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ func encodeInteractionSteps(contents []Content) ([]any, []Content, bool, error)
texts := make([]Part, 0, len(content.Parts))
for _, part := range content.Parts {
switch {
case part.VideoMetadata != nil || part.MediaProcessing != "" || part.ProcessingCall != nil || part.ProcessingResult != nil:
return nil, nil, false, fmt.Errorf("Interaction 模型的 contents[%d] 不接受视频截取、采样、处理方式与处理步骤", index)
case part.Thought:
continue
case part.ExecutableCode != nil || part.CodeExecutionResult != nil:
Expand Down
1 change: 1 addition & 0 deletions internal/aistudio/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var capabilityNames = map[int64]string{
54: "image_search",
58: "google_maps",
59: "interaction_route",
69: "agentic_video",
74: "transcription_word_timestamps",
76: "transcription_language_codes",
77: "transcription_output",
Expand Down
26 changes: 21 additions & 5 deletions internal/aistudio/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ func encodePart(part Part) ([]any, error) {
if part.CodeExecutionResult != nil {
variants++
}
if part.ProcessingCall != nil || part.ProcessingResult != nil {
variants++
}
if variants == 0 && part.ThoughtSignature != "" {
return setPartThoughtSignature([]any{}, part.ThoughtSignature), nil
}
Expand Down Expand Up @@ -247,28 +250,28 @@ func encodePart(part Part) ([]any, error) {
return nil, fmt.Errorf("inline data 缺少 MIME 或数据")
}
wire := []any{nil, nil, []any{part.InlineData.MIME, base64.StdEncoding.EncodeToString(part.InlineData.Data)}}
return setPartThoughtSignature(wire, part.ThoughtSignature), nil
return encodeVideoPart(wire, part)
}
if part.ExternalMedia != nil {
if part.ExternalMedia.MIME == "" || part.ExternalMedia.URL == "" {
return nil, fmt.Errorf("外部媒体缺少 MIME 或 URL")
}
wire := make([]any, 7)
wire[6] = []any{part.ExternalMedia.MIME, part.ExternalMedia.URL}
return setPartThoughtSignature(wire, part.ThoughtSignature), nil
return encodeVideoPart(wire, part)
}
if part.File != nil {
wire, err := encodeFilePart(part.File)
if err != nil {
return nil, err
}
return setPartThoughtSignature(wire, part.ThoughtSignature), nil
return encodeVideoPart(wire, part)
}
if upload := part.Upload; upload != nil {
// Part field 18 上传附件:MIME、上传路径、文件名、字节数
wire := make([]any, 18)
wire[17] = []any{upload.MIME, upload.Path, upload.Name, strconv.FormatInt(upload.Size, 10)}
return setPartThoughtSignature(wire, part.ThoughtSignature), nil
return encodeVideoPart(wire, part)
}
if part.FunctionCall != nil {
if err := validateFunctionCall(part.FunctionCall); err != nil {
Expand Down Expand Up @@ -306,6 +309,19 @@ func encodePart(part Part) ([]any, error) {
wire[11] = result
return setPartThoughtSignature(wire, part.ThoughtSignature), nil
}
if part.ProcessingCall != nil || part.ProcessingResult != nil {
step, index := part.ProcessingCall, 25
if step == nil {
step, index = part.ProcessingResult, 26
}
value, err := encodeMediaProcessingStep(step)
if err != nil {
return nil, err
}
wire := make([]any, index+1)
wire[index] = value
return setPartThoughtSignature(wire, part.ThoughtSignature), nil
}
if part.ExecutableCode != nil {
language, ok := map[string]int64{"LANGUAGE_UNSPECIFIED": 0, "PYTHON": 1}[part.ExecutableCode.Language]
if !ok {
Expand Down Expand Up @@ -352,7 +368,7 @@ func countTokensNeedsGenerateRequest(contents []Content) bool {
for _, content := range contents {
for _, part := range content.Parts {
if part.InlineData != nil || part.ExternalMedia != nil || part.File != nil || part.FunctionCall != nil || part.FunctionResult != nil ||
part.ExecutableCode != nil || part.CodeExecutionResult != nil {
part.ExecutableCode != nil || part.CodeExecutionResult != nil || part.ProcessingCall != nil || part.ProcessingResult != nil {
return true
}
}
Expand Down
Loading