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
18 changes: 9 additions & 9 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ Anthropic 流式 `message_start` 写入即时输入估算,最终 `message_delt
| 18 | missing_thought_signature | 19 | `provider_19` |
| 其他整数 | `provider_<code>` | | |

generation config、安全设置、工具与转写配置在选择账户前按各账户已载入的模型目录校验,任一账户的条目接受即通过;流式与非流式请求的这类参数错误都在写出响应头前返回 HTTP 400;上游开始响应后的错误以各协议的流内错误事件返回。
generation config、安全设置、工具与转写配置在选择账户前按各账户已载入的模型目录校验,任一账户的条目接受即通过。流式请求在写出响应头前等待首个事件:参数错误与首个事件之前的上游错误按非流式返回 HTTP 状态与错误对象;10 秒内没有事件时先写出响应头,此后的错误以各协议的流内错误事件返回。

错误响应根形状为 `[null,[code,message,...]]`。协议核心保留 HTTP 状态、协议 code 与 message;公开适配器映射为 OpenAI、Anthropic 或 Gemini 错误对象。Chat、Responses、Anthropic Messages 与 Gemini GenerateContent 将媒体模型的普通文本作为文本结果输出;专用图片端点要求图片结果。HTTP/协议错误或缺失完成帧形成失败;上游 finish reason 作为正常终态保留并映射到各公开协议。

Expand Down Expand Up @@ -1392,17 +1392,17 @@ Gemini `GET /v1beta/models` 返回 `{"models":[...]}`,单模型路由直接返

| 端点 | 必需字段 | 主要结果 |
| --- | --- | --- |
| `/v1/chat/completions` | `model`、非空 `messages` | Chat completion 或增量 chunk |
| `/v1/responses` | `model`、`input` | Response object 或 `response.*` 事件 |
| `/v1/chat/completions` | `model` | Chat completion 或增量 chunk |
| `/v1/responses` | `model` | Response object 或 `response.*` 事件 |
| `/v1/files` | multipart `file`、`purpose` | OpenAI file object |
| `/v1/messages` | `model`、非空 `messages`、`max_tokens` | Anthropic message 或 message 事件 |
| `:generateContent` / `:streamGenerateContent` | 非空 `contents` | Gemini candidates、usage 与 grounding metadata |
| `/v1/messages` | `model` | Anthropic message 或 message 事件 |
| `:generateContent` / `:streamGenerateContent` | 路径中的模型 | Gemini candidates、usage 与 grounding metadata |
| `/v1/images/generations` | `model`、`prompt`、固定 `n=1` | `b64_json` 或 data URL |
| `/v1/audio/speech` | `model`、`input` | WAV、PCM 或 MP3 body |
| `/v1/audio/transcriptions` | multipart `file`;`model` 默认 `gemini-3.5-transcribe` | 文本或转录 JSON |
| `/v1/videos` | `model`、`prompt` | 长任务对象,随后轮询并下载内容 |

Anthropic assistant prefill 以最后一条 `assistant` text message 表示。适配器将前缀放入续写指令,响应正文返回前缀之后的内容。
生成与计数请求需要系统提示或对话内容中的至少一项,只有系统提示时以它作为 user 轮发送。请求以 assistant 轮结尾时,末尾连续 assistant 轮的正文与代码执行内容作为前缀写入系统指令,模型从前缀之后续写,响应只返回续写部分;末尾未回传结果的函数调用由模型重新生成。各生成入口与计数接口使用同一转换。

四套生成入口共享同一规范请求,输入映射如下:

Expand Down Expand Up @@ -1459,7 +1459,7 @@ OpenAI Chat 与 Anthropic 省略转换后没有 parts 的空历史消息;纯
| 字段 | 类型与语义 |
| --- | --- |
| `model` | 必需模型 ID |
| `messages` | 必需非空 message 数组 |
| `messages` | message 数组 |
| `stream` | boolean |
| `stream_options.include_usage` | 在 finish chunk 后发送 usage-only chunk |
| `tools` | function 或 Google server tool 数组 |
Expand Down Expand Up @@ -1675,9 +1675,9 @@ web search 发生时,search call item 排在 message 前;无 grounding query
| 字段 | 类型与语义 |
| --- | --- |
| `model` | 必需模型 ID |
| `messages` | 必需非空 `{role,content}` 数组;role 为 `user`、`assistant` 或 `system`,`system` 消息在原位置以 `<system-reminder>` 包裹的用户内容发送 |
| `messages` | `{role,content}` 数组;role 为 `user`、`assistant` 或 `system`,`system` 消息在原位置以 `<system-reminder>` 包裹的用户内容发送 |
| `system` | string 或 text block 数组 |
| `max_tokens` | 必需正整数 |
| `max_tokens` | 正整数,省略时使用模型默认输出上限 |
| `stop_sequences` | string array |
| `stream` | boolean |
| `temperature`、`top_p`、`top_k` | 生成参数 |
Expand Down
12 changes: 12 additions & 0 deletions internal/aistudio/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@ func (s *PooledService) CountTokens(ctx context.Context, request TokenCountReque
if modelID == "" {
return TokenCount{}, fmt.Errorf("%w: CountTokens model 不能为空", ErrInvalidArgument)
}
request.System, request.Contents = normalizeTurns(request.System, request.Contents)
if len(request.Contents) == 0 {
return TokenCount{}, fmt.Errorf("%w: 请求没有系统提示或对话内容", ErrInvalidArgument)
}
modelAccessScope := ModelAccessKey("count-tokens", modelID)
selection := AccountSelection{ModelID: modelID, ModelAccessScope: modelAccessScope, Method: "countTokens"}
var count TokenCount
Expand Down Expand Up @@ -553,6 +557,10 @@ func (s *PooledService) ValidateGenerateRequest(request GenerateRequest) error {
if strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") == "" {
return fmt.Errorf("%w: GenerateContent model 不能为空", ErrInvalidArgument)
}
request.System, request.Contents = normalizeTurns(request.System, request.Contents)
if len(request.Contents) == 0 {
return fmt.Errorf("%w: 请求没有系统提示或对话内容", ErrInvalidArgument)
}
request, _, err := prepareToolRequest(request)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidArgument, err)
Expand All @@ -566,6 +574,10 @@ func (s *PooledService) Generate(ctx context.Context, request GenerateRequest) (
if modelID == "" {
return nil, fmt.Errorf("%w: GenerateContent model 不能为空", ErrInvalidArgument)
}
request.System, request.Contents = normalizeTurns(request.System, request.Contents)
if len(request.Contents) == 0 {
return nil, fmt.Errorf("%w: 请求没有系统提示或对话内容", ErrInvalidArgument)
}
request, contract, err := prepareToolRequest(request)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err)
Expand Down
51 changes: 51 additions & 0 deletions internal/aistudio/turns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package aistudio

import "strings"

// assistantPrefixInstruction 是末尾 assistant 轮改写为续写前缀时追加的系统指令
const assistantPrefixInstruction = "Continue the assistant response after the following prefix. Return only the continuation, without repeating the prefix."

// normalizeTurns 将末尾连续 assistant 轮改写为系统指令中的续写前缀,没有对话内容时以系统提示作为 user 轮
func normalizeTurns(system string, contents []Content) (string, []Content) {
tail := len(contents)
for tail > 0 && contents[tail-1].Role == RoleAssistant {
tail--
}
if prefix := assistantPrefix(contents[tail:]); strings.TrimSpace(prefix) != "" {
instruction := assistantPrefixInstruction + "\n<assistant_prefix>" + prefix + "</assistant_prefix>"
if system != "" {
instruction = system + "\n" + instruction
}
system = instruction
}
contents = contents[:tail]
if len(contents) == 0 && strings.TrimSpace(system) != "" {
return "", []Content{{Role: RoleUser, Parts: []Part{{Text: system}}}}
}
return system, contents
}

// assistantPrefix 按顺序拼接 assistant 轮的正文与代码执行内容
func assistantPrefix(contents []Content) string {
var prefix strings.Builder
block := func(text string) {
if prefix.Len() > 0 && !strings.HasSuffix(prefix.String(), "\n") {
prefix.WriteString("\n")
}
prefix.WriteString(text + "\n")
}
for _, content := range contents {
for _, part := range content.Parts {
switch {
case part.Thought:
case part.Text != "":
prefix.WriteString(part.Text)
case part.ExecutableCode != nil:
block("```" + strings.ToLower(part.ExecutableCode.Language) + "\n" + strings.TrimSuffix(part.ExecutableCode.Code, "\n") + "\n```")
case part.CodeExecutionResult != nil:
block("```text\n" + strings.TrimSuffix(part.CodeExecutionResult.Output+part.CodeExecutionResult.Error, "\n") + "\n```")
}
}
}
return prefix.String()
}
37 changes: 13 additions & 24 deletions internal/api/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ func (s *server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if request.Model == "" || len(request.Messages) == 0 || request.MaxTokens == nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "model, messages and max_tokens are required")
if request.Model == "" {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
messageID := newID("msg")
Expand All @@ -99,6 +99,15 @@ func (s *server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
generateRequest.Unary = !request.Stream
events, err := s.service.Generate(r.Context(), generateRequest)
if err == nil && request.Stream {
events, err = awaitStreamStart(r.Context(), events)
}
if err != nil {
if shouldWriteRequestError(r, err) {
writeAnthropicError(w, statusFromError(err), anthropicErrorType(err), err.Error())
}
return
}
if request.Stream {
if err := streamHeaders(w); err != nil {
return
}
Expand All @@ -113,12 +122,6 @@ func (s *server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
s.streamAnthropic(r, writer, events)
return
}
if err != nil {
if shouldWriteRequestError(r, err) {
writeAnthropicError(w, statusFromError(err), anthropicErrorType(err), err.Error())
}
return
}
result, err := consumeEvents(r.Context(), events, nil)
if err != nil {
if shouldWriteRequestError(r, err) {
Expand All @@ -135,8 +138,8 @@ func (s *server) handleAnthropicCountTokens(w http.ResponseWriter, r *http.Reque
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if request.Model == "" || len(request.Messages) == 0 {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "model and messages are required")
if request.Model == "" {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if err := s.decodeAnthropicSearchHistory(&request); err != nil {
Expand Down Expand Up @@ -224,20 +227,6 @@ func (request anthropicRequest) toGenerateRequest(id string) (aistudio.GenerateR
if request.OutputConfig != nil {
config.ReasoningEffort = request.OutputConfig.Effort
}
if len(contents) > 0 && contents[len(contents)-1].Role == aistudio.RoleAssistant {
last := contents[len(contents)-1]
var prefix strings.Builder
for _, part := range last.Parts {
if part.FunctionCall != nil || part.InlineData != nil || part.File != nil {
return aistudio.GenerateRequest{}, fmt.Errorf("assistant prefill requires text content")
}
if !part.Thought {
prefix.WriteString(part.Text)
}
}
contents = contents[:len(contents)-1]
system += "\nContinue the assistant response after the following prefix. Return only the continuation, without repeating the prefix.\n<assistant_prefix>" + prefix.String() + "</assistant_prefix>"
}
return aistudio.GenerateRequest{
ID: id, Model: request.Model, System: system, Contents: contents, Config: config, Tools: tools,
}, nil
Expand Down
7 changes: 3 additions & 4 deletions internal/api/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,6 @@ func (s *server) handleGeminiAction(w http.ResponseWriter, r *http.Request) {
writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
if len(request.Contents) == 0 {
writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", "contents is required")
return
}
generateRequest, err := request.toGenerateRequest(newID("resp"), model)
if err != nil {
writeGeminiError(w, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
Expand Down Expand Up @@ -790,6 +786,9 @@ func (s *server) handleGeminiCountTokens(w http.ResponseWriter, r *http.Request,
func (s *server) handleGeminiGenerate(w http.ResponseWriter, r *http.Request, request aistudio.GenerateRequest, stream bool) {
request.Unary = !stream
events, err := s.service.Generate(r.Context(), request)
if err == nil && stream {
events, err = awaitStreamStart(r.Context(), events)
}
if err != nil {
if shouldWriteRequestError(r, err) {
writeGeminiError(w, statusFromError(err), geminiErrorStatus(err), err.Error())
Expand Down
36 changes: 36 additions & 0 deletions internal/api/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,42 @@ func (result *generationResult) apply(event aistudio.Event) error {
return nil
}

// awaitStreamStart 在写出流式响应头前等待首个事件,首个事件为错误时返回该错误,心跳间隔内没有事件时直接返回原事件流
func awaitStreamStart(ctx context.Context, events <-chan aistudio.Event) (<-chan aistudio.Event, error) {
timer := time.NewTimer(streamHeartbeatInterval)
defer timer.Stop()
var first aistudio.Event
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
return events, nil
case event, ok := <-events:
if !ok {
return nil, errIncompleteStream
}
if event.Kind == aistudio.EventError {
if event.Err != nil {
return nil, event.Err
}
return nil, errUpstreamStream
}
first = event
}
replay := make(chan aistudio.Event)
go func() {
defer close(replay)
for event, ok := first, true; ok; event, ok = <-events {
select {
case replay <- event:
case <-ctx.Done():
return
}
}
}()
return replay, nil
}

func consumeEvents(ctx context.Context, events <-chan aistudio.Event, emit func(aistudio.Event) error) (result generationResult, resultErr error) {
return consumeEventsWithHeartbeat(ctx, events, emit, nil)
}
Expand Down
9 changes: 3 additions & 6 deletions internal/api/interaction_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func interactionList[T any](raw json.RawMessage) ([]T, error) {
// toGenerateRequest 映射模型、结构化输入、生成参数与函数声明
func (request interactionRequest) toGenerateRequest(id string) (aistudio.GenerateRequest, error) {
generate := aistudio.GenerateRequest{ID: id, Model: strings.TrimPrefix(strings.TrimSpace(request.Model), "models/"), System: request.System}
if generate.Model == "" || len(request.Input) == 0 {
return generate, fmt.Errorf("model and input are required")
if generate.Model == "" {
return generate, fmt.Errorf("model is required")
}
if request.Background {
return generate, fmt.Errorf("background must be false")
Expand All @@ -99,9 +99,6 @@ func (request interactionRequest) toGenerateRequest(id string) (aistudio.Generat
if err != nil {
return generate, err
}
if len(generate.Contents) == 0 {
return generate, fmt.Errorf("input must contain content")
}
generation := request.Generation
generate.Config = aistudio.GenerationConfig{
Temperature: generation.Temperature, TopP: generation.TopP, TopK: generation.TopK,
Expand Down Expand Up @@ -179,7 +176,7 @@ func interactionContents(raw json.RawMessage) ([]aistudio.Content, error) {
var text string
if json.Unmarshal(raw, &text) == nil {
if strings.TrimSpace(text) == "" {
return nil, fmt.Errorf("input text is empty")
return nil, nil
}
return []aistudio.Content{{Role: aistudio.RoleUser, Parts: []aistudio.Part{{Text: text}}}}, nil
}
Expand Down
3 changes: 3 additions & 0 deletions internal/api/interactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func (s *server) handleInteraction(w http.ResponseWriter, r *http.Request) {
defer cancel()
r = r.WithContext(ctx)
events, err := s.service.Generate(ctx, generate)
if err == nil && request.Stream {
events, err = awaitStreamStart(ctx, events)
}
if err != nil {
if shouldWriteRequestError(r, err) {
writeGeminiError(w, statusFromError(err), geminiErrorStatus(err), err.Error())
Expand Down
7 changes: 5 additions & 2 deletions internal/api/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.Model == "" || len(request.Messages) == 0 {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", "model and messages are required")
if request.Model == "" {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", "model is required")
return
}
requestID := newID("chatcmpl")
Expand All @@ -193,6 +193,9 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
generateRequest.Unary = !request.Stream
s.thoughtSignatures.Restore(generateRequest.Contents)
events, err := s.service.Generate(r.Context(), generateRequest)
if err == nil && request.Stream {
events, err = awaitStreamStart(r.Context(), events)
}
if err != nil {
if shouldWriteRequestError(r, err) {
writeOpenAIError(w, statusFromError(err), openAIErrorCode(err), err.Error())
Expand Down
10 changes: 8 additions & 2 deletions internal/api/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ func (s *server) handleResponses(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.Model == "" || len(request.Input) == 0 {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", "model and input are required")
if request.Model == "" {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", "model is required")
return
}
responseID := newID("resp")
Expand Down Expand Up @@ -183,6 +183,9 @@ func (s *server) handleResponses(w http.ResponseWriter, r *http.Request) {
}
generateRequest.Unary = !request.Stream
events, err := s.service.Generate(r.Context(), generateRequest)
if err == nil && request.Stream {
events, err = awaitStreamStart(r.Context(), events)
}
if err != nil {
if shouldWriteRequestError(r, err) {
writeOpenAIError(w, statusFromError(err), openAIErrorCode(err), err.Error())
Expand Down Expand Up @@ -314,6 +317,9 @@ func (request responsesRequest) toGenerateRequest(id string) (aistudio.GenerateR
}

func responsesContents(raw json.RawMessage) ([]aistudio.Content, []string, error) {
if value := strings.TrimSpace(string(raw)); value == "" || value == "null" {
return nil, nil, nil
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return []aistudio.Content{{Role: aistudio.RoleUser, Parts: []aistudio.Part{{Text: text}}}}, nil, nil
Expand Down