diff --git a/changelog/unreleased/qoder-unique-completion-id.md b/changelog/unreleased/qoder-unique-completion-id.md new file mode 100644 index 00000000..54e12bf9 --- /dev/null +++ b/changelog/unreleased/qoder-unique-completion-id.md @@ -0,0 +1,7 @@ +### English + +- Qoder streaming responses now carry a unique completion `id` per request, shared by every chunk of that request, so clients no longer merge replies from different requests. + +### 中文 + +- Qoder 流式响应现在每个请求使用唯一的 completion `id`,同一请求的所有分片保持一致,客户端不再把不同请求的回复合并在一起。 diff --git a/internal/providers/qoder/completion_id_test.go b/internal/providers/qoder/completion_id_test.go new file mode 100644 index 00000000..d7d44cb8 --- /dev/null +++ b/internal/providers/qoder/completion_id_test.go @@ -0,0 +1,62 @@ +package qoder + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func completionIDs(t *testing.T, stream string) []string { + t.Helper() + var output bytes.Buffer + if err := writeOpenAIChunks(&output, strings.NewReader(stream), "synthetic-model"); err != nil { + t.Fatal(err) + } + var ids []string + for _, line := range strings.Split(output.String(), "\n") { + data, ok := strings.CutPrefix(line, "data: ") + if !ok || data == "[DONE]" { + continue + } + var chunk struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + t.Fatalf("decode %q: %v", data, err) + } + ids = append(ids, chunk.ID) + } + return ids +} + +// Regression for #292: every request gets its own completion id, shared by +// all of its chunks including the synthesized terminal and usage chunks. +func TestWriteOpenAIChunksUsesOneUniqueIDPerRequest(t *testing.T) { + cases := map[string]string{ + // No upstream finish: a terminal chunk is synthesized. + "synthesized_finish": "data: " + `{"choices":[{"delta":{"content":"hi"}}]}` + "\n\n", + // Finish without usage, then usage: a trailing usage chunk is synthesized. + "synthesized_usage": "data: " + `{"choices":[{"delta":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}` + "\n\n" + + "data: " + `{"choices":[{"delta":{"content":"!"},"finish_reason":"stop"}]}` + "\n\n", + } + for name, stream := range cases { + t.Run(name, func(t *testing.T) { + first := completionIDs(t, stream) + second := completionIDs(t, stream) + if len(first) < 2 { + t.Fatalf("expected synthesized chunk, got ids %v", first) + } + for _, ids := range [][]string{first, second} { + for _, id := range ids { + if id != ids[0] || !strings.HasPrefix(id, "chatcmpl-") || id == "chatcmpl-qoder" { + t.Fatalf("inconsistent or fixed ids %v", ids) + } + } + } + if first[0] == second[0] { + t.Fatalf("two requests share completion id %q", first[0]) + } + }) + } +} diff --git a/internal/providers/qoder/direct.go b/internal/providers/qoder/direct.go index 64741ba4..bdef0809 100644 --- a/internal/providers/qoder/direct.go +++ b/internal/providers/qoder/direct.go @@ -819,7 +819,18 @@ func (b *convertedBody) Close() error { return b.closeUpstream() } +// newCompletionID returns one OpenAI completion id per request. Clients that +// merge assistant chunks by id must not see two requests share one (#292). +func newCompletionID() string { + id, err := randomUUID() + if err != nil { + return fmt.Sprintf("chatcmpl-qoder-%d", time.Now().UnixNano()) + } + return "chatcmpl-" + strings.ReplaceAll(id, "-", "") +} + func writeOpenAIChunks(w io.Writer, src io.Reader, model string) error { + completionID := newCompletionID() var sawFinish, sawTools, sawPayload, sawError bool var lastUsage any var lastChunkEmittedHadUsage bool @@ -841,7 +852,7 @@ func writeOpenAIChunks(w io.Writer, src io.Reader, model string) error { return err } chunk := map[string]any{ - "id": "chatcmpl-qoder", "object": "chat.completion.chunk", "model": model, + "id": completionID, "object": "chat.completion.chunk", "model": model, "choices": []any{map[string]any{"index": 0, "delta": deltaOf(frame), "finish_reason": emptyOrNil(frame.finish)}}, } if len(frame.usage) > 0 { @@ -881,7 +892,7 @@ func writeOpenAIChunks(w io.Writer, src io.Reader, model string) error { finish = "tool_calls" } chunk := map[string]any{ - "id": "chatcmpl-qoder", "object": "chat.completion.chunk", "model": model, + "id": completionID, "object": "chat.completion.chunk", "model": model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": finish}}, } if lastUsage != nil { @@ -906,7 +917,7 @@ func writeOpenAIChunks(w io.Writer, src io.Reader, model string) error { } } else if !lastChunkEmittedHadUsage && lastUsage != nil { chunk := map[string]any{ - "id": "chatcmpl-qoder", "object": "chat.completion.chunk", "model": model, + "id": completionID, "object": "chat.completion.chunk", "model": model, "choices": []any{}, "usage": lastUsage, }