diff --git a/docs/protocol.md b/docs/protocol.md index 6b33210..5a31669 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -680,7 +680,7 @@ Anthropic 流式 `message_start` 写入即时输入估算,最终 `message_delt | 18 | missing_thought_signature | 19 | `provider_19` | | 其他整数 | `provider_` | | | -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 作为正常终态保留并映射到各公开协议。 @@ -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 轮的正文与代码执行内容作为前缀写入系统指令,模型从前缀之后续写,响应只返回续写部分;末尾未回传结果的函数调用由模型重新生成。各生成入口与计数接口使用同一转换。 四套生成入口共享同一规范请求,输入映射如下: @@ -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 数组 | @@ -1675,9 +1675,9 @@ web search 发生时,search call item 排在 message 前;无 grounding query | 字段 | 类型与语义 | | --- | --- | | `model` | 必需模型 ID | -| `messages` | 必需非空 `{role,content}` 数组;role 为 `user`、`assistant` 或 `system`,`system` 消息在原位置以 `` 包裹的用户内容发送 | +| `messages` | `{role,content}` 数组;role 为 `user`、`assistant` 或 `system`,`system` 消息在原位置以 `` 包裹的用户内容发送 | | `system` | string 或 text block 数组 | -| `max_tokens` | 必需正整数 | +| `max_tokens` | 正整数,省略时使用模型默认输出上限 | | `stop_sequences` | string array | | `stream` | boolean | | `temperature`、`top_p`、`top_k` | 生成参数 | diff --git a/internal/aistudio/service.go b/internal/aistudio/service.go index 01c0b7f..8029547 100644 --- a/internal/aistudio/service.go +++ b/internal/aistudio/service.go @@ -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 @@ -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) @@ -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) diff --git a/internal/aistudio/turns.go b/internal/aistudio/turns.go new file mode 100644 index 0000000..310a489 --- /dev/null +++ b/internal/aistudio/turns.go @@ -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" + 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() +} diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index f2e83ea..7f0bc91 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -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") @@ -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 } @@ -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) { @@ -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 { @@ -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" + prefix.String() + "" - } return aistudio.GenerateRequest{ ID: id, Model: request.Model, System: system, Contents: contents, Config: config, Tools: tools, }, nil diff --git a/internal/api/gemini.go b/internal/api/gemini.go index 4707466..e69dd0b 100644 --- a/internal/api/gemini.go +++ b/internal/api/gemini.go @@ -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()) @@ -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()) diff --git a/internal/api/generate.go b/internal/api/generate.go index bb14398..eb8c2aa 100644 --- a/internal/api/generate.go +++ b/internal/api/generate.go @@ -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) } diff --git a/internal/api/interaction_input.go b/internal/api/interaction_input.go index d6e2a07..aa5d69f 100644 --- a/internal/api/interaction_input.go +++ b/internal/api/interaction_input.go @@ -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") @@ -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, @@ -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 } diff --git a/internal/api/interactions.go b/internal/api/interactions.go index ba19400..8da4dc3 100644 --- a/internal/api/interactions.go +++ b/internal/api/interactions.go @@ -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()) diff --git a/internal/api/openai.go b/internal/api/openai.go index 55fbb30..93ec2e1 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -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") @@ -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()) diff --git a/internal/api/responses.go b/internal/api/responses.go index 7d3fb8e..9272431 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -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") @@ -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()) @@ -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