-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathstream.go
More file actions
347 lines (297 loc) · 9.18 KB
/
Copy pathstream.go
File metadata and controls
347 lines (297 loc) · 9.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
package proxy
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"github.com/whtsky/copilot2api/internal/sse"
"github.com/whtsky/copilot2api/internal/types"
"github.com/whtsky/copilot2api/internal/upstream"
)
// headersSentError wraps an error that occurred after response headers were
// already written to the client. The caller must not attempt to write an HTTP
// error response because the status line has already been flushed.
type headersSentError struct{ err error }
func (e *headersSentError) Error() string { return e.err.Error() }
func (e *headersSentError) Unwrap() error { return e.err }
// HandleStreamingRequest handles streaming requests to Copilot API
func (h *Handler) HandleStreamingRequest(w http.ResponseWriter, r *http.Request, endpoint string) error {
var body interface{}
if r.Body != nil {
body = r.Body
}
resp, _, err := h.upstream.Do(r.Context(), upstream.Request{
Method: r.Method,
Endpoint: endpoint,
Body: body,
QueryString: r.URL.RawQuery,
Stream: true,
ExtraHeaders: collectForwardHeaders(r),
})
if err != nil {
var upstreamErr *upstream.UpstreamError
if errors.As(err, &upstreamErr) {
upstreamErr.WriteRawError(w)
return nil
}
return fmt.Errorf("upstream request failed: %w", err)
}
defer resp.Body.Close()
// Set up streaming response headers
sse.BeginSSE(w)
// Flush headers
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
// Stream the response — headers are already sent at this point.
if err := h.streamResponse(w, resp.Body, endpoint); err != nil {
return &headersSentError{err: err}
}
return nil
}
// streamResponse streams the SSE response line by line.
// endpoint is used to select the correct termination strategy.
func (h *Handler) streamResponse(w http.ResponseWriter, body io.ReadCloser, endpoint string) error {
scanner := bufio.NewScanner(body)
// Increase buffer size to handle large SSE lines (default is 64KB, increase to 1MB)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
flusher, canFlush := w.(http.Flusher)
isResponses := endpoint == "/responses"
for scanner.Scan() {
line := scanner.Text()
// Write the line
if _, err := io.WriteString(w, line); err != nil {
return err
}
if _, err := io.WriteString(w, "\n"); err != nil {
return err
}
// SSE events are delimited by blank lines; flush at event boundaries
if canFlush && line == "" {
flusher.Flush()
}
// Chat Completions: terminate on data: [DONE]
// Skip this check for the Responses API — it uses event-based
// termination and may send data: [DONE] before response.completed.
if !isResponses && strings.TrimSpace(line) == "data: [DONE]" {
slog.Debug("stream done", "endpoint", endpoint)
if canFlush {
flusher.Flush()
}
break
}
// Responses API termination events
if isResponses && isResponsesTerminationEvent(line) {
slog.Debug("stream termination event", "endpoint", endpoint, "event", strings.TrimSpace(line))
// Write remaining data lines for this event, then stop
for scanner.Scan() {
remaining := scanner.Text()
if _, err := io.WriteString(w, remaining); err != nil {
return err
}
if _, err := io.WriteString(w, "\n"); err != nil {
return err
}
if remaining == "" {
break
}
}
if canFlush {
flusher.Flush()
}
break
}
}
if err := scanner.Err(); err != nil {
return err
}
return nil
}
// isResponsesTerminationEvent checks if an SSE event line indicates
// stream termination for the Responses API.
func isResponsesTerminationEvent(line string) bool {
line = strings.TrimSpace(line)
return line == "event: response.completed" ||
line == "event: response.incomplete" ||
line == "event: response.failed" ||
line == "event: error"
}
// isStreamingRequest checks if the request body wants streaming.
func isStreamingRequest(body []byte) bool {
var top struct {
Stream bool `json:"stream"`
}
if err := json.Unmarshal(body, &top); err != nil {
return false
}
return top.Stream
}
// --- Streaming conversion functions ---
// streamResponsesAsChatChunks reads Responses API SSE events from body,
// converts each to Chat Completions chunks, and writes them to w.
// Used when the client requested /chat/completions but the model only
// supports /responses.
func streamResponsesAsChatChunks(w http.ResponseWriter, body io.ReadCloser) error {
scanner := bufio.NewScanner(body)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
flusher, canFlush := w.(http.Flusher)
state := NewResponsesStreamConvertState()
var currentEventType string
for scanner.Scan() {
line := scanner.Text()
// Parse SSE event type lines
if strings.HasPrefix(line, "event: ") {
currentEventType = strings.TrimPrefix(line, "event: ")
continue
}
// Parse SSE data lines
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
// The Responses API may send data: [DONE] before response.completed.
// Ignore it — we rely on the converter's Finished state to know when
// to emit the Chat Completions [DONE] sentinel.
if dataStr == "[DONE]" {
continue
}
var event types.ResponseStreamEvent
if err := json.Unmarshal([]byte(dataStr), &event); err != nil {
slog.Debug("skipping unparseable SSE data in conversion", "error", err)
continue
}
// Use the event type from the "event:" line if the JSON doesn't
// include it (some implementations put it only on the SSE line).
if event.Type == "" && currentEventType != "" {
event.Type = currentEventType
}
chunks := ConvertResponsesStreamEventToChatChunk(event, state)
for _, chunk := range chunks {
chunkData, err := json.Marshal(chunk)
if err != nil {
continue
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkData); err != nil {
return err
}
if canFlush {
flusher.Flush()
}
}
// Check if the converter signaled stream completion
if state.Finished {
// Send [DONE] sentinel for Chat Completions format
if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil {
return err
}
if canFlush {
flusher.Flush()
}
return nil
}
currentEventType = ""
continue
}
// Blank lines are SSE event delimiters — reset event type
if line == "" {
currentEventType = ""
}
}
if err := scanner.Err(); err != nil {
return err
}
// If we didn't see a termination event, return an error instead of
// silently completing — the upstream stream ended unexpectedly.
if !state.Finished {
return fmt.Errorf("responses stream ended without terminal event")
}
return nil
}
// streamChatChunksAsResponsesEvents reads Chat Completions SSE chunks from body,
// converts each to Responses API events, and writes them to w.
// Used when the client requested /responses but the model only
// supports /chat/completions.
func streamChatChunksAsResponsesEvents(w http.ResponseWriter, body io.ReadCloser) error {
scanner := bufio.NewScanner(body)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
flusher, canFlush := w.(http.Flusher)
state := NewChatStreamConvertState()
for scanner.Scan() {
line := scanner.Text()
// Only process data lines
if !strings.HasPrefix(line, "data: ") {
continue
}
dataStr := strings.TrimPrefix(line, "data: ")
// [DONE] sentinel — stream is finished
if dataStr == "[DONE]" {
// If finish_reason was seen but termination event wasn't emitted
// (no usage-only chunk arrived), emit it now without usage.
if state.FinishSeen && !state.Finished {
terminationEvent := state.buildTerminationEvent()
if err := writeResponsesSSEEvent(w, terminationEvent); err != nil {
return err
}
if canFlush {
flusher.Flush()
}
}
// If we haven't sent any termination event (edge case: no finish_reason seen)
if !state.Finished {
completedEvent := types.ResponseStreamEvent{
Type: "response.completed",
Response: &types.ResponsesResult{
ID: state.ID,
Model: state.Model,
Status: "completed",
},
}
if err := writeResponsesSSEEvent(w, completedEvent); err != nil {
return err
}
if canFlush {
flusher.Flush()
}
}
return nil
}
var chunk types.OpenAIChatCompletionChunk
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
slog.Debug("skipping unparseable SSE data in conversion", "error", err)
continue
}
events := ConvertChatChunkToResponsesStreamEvents(chunk, state)
for _, event := range events {
if err := writeResponsesSSEEvent(w, event); err != nil {
return err
}
if canFlush {
flusher.Flush()
}
}
}
if err := scanner.Err(); err != nil {
return err
}
// If we reach EOF without seeing [DONE], the stream ended unexpectedly.
if !state.Finished {
return fmt.Errorf("chat completions stream ended without [DONE] sentinel")
}
return nil
}
// writeResponsesSSEEvent writes a single Responses API SSE event to w.
func writeResponsesSSEEvent(w io.Writer, event types.ResponseStreamEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data); err != nil {
return err
}
return nil
}