44 "context"
55 "encoding/json"
66 "fmt"
7+ "io"
78 "net/http"
89 "net/http/httptest"
910 "strings"
@@ -14,6 +15,7 @@ import (
1415 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
1516 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
1617 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
18+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
1719 sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
1820)
1921
@@ -249,6 +251,174 @@ func TestExecuteStream_NonOKResponse(t *testing.T) {
249251 }
250252}
251253
254+ func TestExecuteStream_PublishesUsageRecordFromStreamUsage (t * testing.T ) {
255+ executor := NewQoderExecutor (& config.Config {})
256+ storage := testQoderStorageWithModelConfig ()
257+ authRecord := & cliproxyauth.Auth {
258+ ID : "qoder-test-auth" ,
259+ Provider : "qoder" ,
260+ Storage : storage ,
261+ }
262+
263+ plugin := & captureQoderUsagePlugin {records : make (chan usage.Record , 4 )}
264+ usage .RegisterNamedPlugin ("test:qoder-stream-usage" , plugin )
265+
266+ ctx := context .WithValue (context .Background (), "cliproxy.roundtripper" , qoderRoundTripFunc (func (req * http.Request ) (* http.Response , error ) {
267+ return qoderSSEHTTPResponse (req , http .StatusOK , qoderStreamBodyWithUsage (t , 3 , 4 , 7 )), nil
268+ }))
269+
270+ result , err := executor .ExecuteStream (ctx , authRecord , cliproxyexecutor.Request {
271+ Model : "qoder/auto" ,
272+ Payload : []byte (`{"model":"qoder/auto","messages":[{"role":"user","content":"hi"}]}` ),
273+ }, cliproxyexecutor.Options {SourceFormat : sdktranslator .FormatOpenAI })
274+ if err != nil {
275+ t .Fatalf ("ExecuteStream() error = %v" , err )
276+ }
277+ for chunk := range result .Chunks {
278+ if chunk .Err != nil {
279+ t .Fatalf ("stream chunk error = %v" , chunk .Err )
280+ }
281+ }
282+
283+ record := waitForQoderUsageRecord (t , plugin .records , "auto" )
284+ if record .Provider != "qoder" {
285+ t .Fatalf ("Provider = %q, want qoder" , record .Provider )
286+ }
287+ if record .ExecutorType != "QoderExecutor" {
288+ t .Fatalf ("ExecutorType = %q, want QoderExecutor" , record .ExecutorType )
289+ }
290+ if record .Failed {
291+ t .Fatalf ("Failed = true, want false: %+v" , record .Fail )
292+ }
293+ if record .Detail .InputTokens != 3 || record .Detail .OutputTokens != 4 || record .Detail .TotalTokens != 7 {
294+ t .Fatalf ("Detail = %+v, want input=3 output=4 total=7" , record .Detail )
295+ }
296+ }
297+
298+ func TestExecuteStream_PublishesFailureRecordForUpstreamStatus (t * testing.T ) {
299+ executor := NewQoderExecutor (& config.Config {})
300+ storage := testQoderStorageWithModelConfig ()
301+ authRecord := & cliproxyauth.Auth {
302+ ID : "qoder-test-auth" ,
303+ Provider : "qoder" ,
304+ Storage : storage ,
305+ }
306+
307+ plugin := & captureQoderUsagePlugin {records : make (chan usage.Record , 4 )}
308+ usage .RegisterNamedPlugin ("test:qoder-status-failure" , plugin )
309+
310+ ctx := context .WithValue (context .Background (), "cliproxy.roundtripper" , qoderRoundTripFunc (func (req * http.Request ) (* http.Response , error ) {
311+ return qoderSSEHTTPResponse (req , http .StatusServiceUnavailable , "upstream down" ), nil
312+ }))
313+
314+ result , err := executor .ExecuteStream (ctx , authRecord , cliproxyexecutor.Request {
315+ Model : "qoder/auto" ,
316+ Payload : []byte (`{"model":"qoder/auto","messages":[{"role":"user","content":"hi"}]}` ),
317+ }, cliproxyexecutor.Options {SourceFormat : sdktranslator .FormatOpenAI })
318+ if result != nil {
319+ t .Fatalf ("ExecuteStream() result = %+v, want nil" , result )
320+ }
321+ if err == nil {
322+ t .Fatal ("ExecuteStream() error = nil, want error" )
323+ }
324+
325+ record := waitForQoderUsageRecord (t , plugin .records , "auto" )
326+ if ! record .Failed {
327+ t .Fatalf ("Failed = false, want true" )
328+ }
329+ if record .Fail .StatusCode != http .StatusServiceUnavailable {
330+ t .Fatalf ("Fail.StatusCode = %d, want %d" , record .Fail .StatusCode , http .StatusServiceUnavailable )
331+ }
332+ if ! strings .Contains (record .Fail .Body , "upstream down" ) {
333+ t .Fatalf ("Fail.Body = %q, want upstream body" , record .Fail .Body )
334+ }
335+ }
336+
337+ func TestExecuteStream_EnsuresUsageRecordForRawDoneWithoutUsage (t * testing.T ) {
338+ executor := NewQoderExecutor (& config.Config {})
339+ storage := testQoderStorageWithModelConfig ()
340+ authRecord := & cliproxyauth.Auth {
341+ ID : "qoder-test-auth" ,
342+ Provider : "qoder" ,
343+ Storage : storage ,
344+ }
345+
346+ plugin := & captureQoderUsagePlugin {records : make (chan usage.Record , 4 )}
347+ usage .RegisterNamedPlugin ("test:qoder-raw-done" , plugin )
348+
349+ ctx := context .WithValue (context .Background (), "cliproxy.roundtripper" , qoderRoundTripFunc (func (req * http.Request ) (* http.Response , error ) {
350+ return qoderSSEHTTPResponse (req , http .StatusOK , "data: [DONE]\n \n " ), nil
351+ }))
352+
353+ result , err := executor .ExecuteStream (ctx , authRecord , cliproxyexecutor.Request {
354+ Model : "qoder/auto" ,
355+ Payload : []byte (`{"model":"qoder/auto","messages":[{"role":"user","content":"hi"}]}` ),
356+ }, cliproxyexecutor.Options {SourceFormat : sdktranslator .FormatOpenAI })
357+ if err != nil {
358+ t .Fatalf ("ExecuteStream() error = %v" , err )
359+ }
360+ for chunk := range result .Chunks {
361+ if chunk .Err != nil {
362+ t .Fatalf ("stream chunk error = %v" , chunk .Err )
363+ }
364+ }
365+
366+ record := waitForQoderUsageRecord (t , plugin .records , "auto" )
367+ if record .Failed {
368+ t .Fatalf ("Failed = true, want false: %+v" , record .Fail )
369+ }
370+ if record .Detail != (usage.Detail {}) {
371+ t .Fatalf ("Detail = %+v, want empty fallback detail" , record .Detail )
372+ }
373+ assertNoExtraQoderRecord (t , plugin .records )
374+ }
375+
376+ func TestExecuteStream_PublishesFailureRecordForStreamEnvelopeStatus (t * testing.T ) {
377+ executor := NewQoderExecutor (& config.Config {})
378+ storage := testQoderStorageWithModelConfig ()
379+ authRecord := & cliproxyauth.Auth {
380+ ID : "qoder-test-auth" ,
381+ Provider : "qoder" ,
382+ Storage : storage ,
383+ }
384+
385+ plugin := & captureQoderUsagePlugin {records : make (chan usage.Record , 4 )}
386+ usage .RegisterNamedPlugin ("test:qoder-envelope-failure" , plugin )
387+
388+ ctx := context .WithValue (context .Background (), "cliproxy.roundtripper" , qoderRoundTripFunc (func (req * http.Request ) (* http.Response , error ) {
389+ body := qoderStreamBodyWithStatus (t , http .StatusTooManyRequests , "quota exhausted" )
390+ return qoderSSEHTTPResponse (req , http .StatusOK , body ), nil
391+ }))
392+
393+ result , err := executor .ExecuteStream (ctx , authRecord , cliproxyexecutor.Request {
394+ Model : "qoder/auto" ,
395+ Payload : []byte (`{"model":"qoder/auto","messages":[{"role":"user","content":"hi"}]}` ),
396+ }, cliproxyexecutor.Options {SourceFormat : sdktranslator .FormatOpenAI })
397+ if err != nil {
398+ t .Fatalf ("ExecuteStream() error = %v" , err )
399+ }
400+ var streamErr error
401+ for chunk := range result .Chunks {
402+ if chunk .Err != nil {
403+ streamErr = chunk .Err
404+ }
405+ }
406+ if streamErr == nil {
407+ t .Fatal ("stream error = nil, want envelope error" )
408+ }
409+
410+ record := waitForQoderUsageRecord (t , plugin .records , "auto" )
411+ if ! record .Failed {
412+ t .Fatal ("Failed = false, want true" )
413+ }
414+ if record .Fail .StatusCode != http .StatusTooManyRequests {
415+ t .Fatalf ("Fail.StatusCode = %d, want %d" , record .Fail .StatusCode , http .StatusTooManyRequests )
416+ }
417+ if ! strings .Contains (record .Fail .Body , "quota exhausted" ) {
418+ t .Fatalf ("Fail.Body = %q, want envelope body" , record .Fail .Body )
419+ }
420+ }
421+
252422// TestExecuteStream_StreamParsing tests successful stream parsing
253423func TestExecuteStream_StreamParsing (t * testing.T ) {
254424 // This test requires overriding QoderChatURL which is a constant
@@ -270,6 +440,128 @@ func TestExecuteStream_StreamContextCancel(t *testing.T) {
270440 t .Skip ("requires ability to override QoderChatURL" )
271441}
272442
443+ type qoderRoundTripFunc func (* http.Request ) (* http.Response , error )
444+
445+ func (f qoderRoundTripFunc ) RoundTrip (req * http.Request ) (* http.Response , error ) {
446+ return f (req )
447+ }
448+
449+ type captureQoderUsagePlugin struct {
450+ records chan usage.Record
451+ }
452+
453+ func (p * captureQoderUsagePlugin ) HandleUsage (_ context.Context , record usage.Record ) {
454+ if p == nil {
455+ return
456+ }
457+ select {
458+ case p .records <- record :
459+ default :
460+ }
461+ }
462+
463+ func waitForQoderUsageRecord (t * testing.T , records <- chan usage.Record , model string ) usage.Record {
464+ t .Helper ()
465+ timeout := time .After (2 * time .Second )
466+ for {
467+ select {
468+ case record := <- records :
469+ if record .Provider == "qoder" && record .Model == model {
470+ return record
471+ }
472+ case <- timeout :
473+ t .Fatalf ("timed out waiting for Qoder usage record for model %q" , model )
474+ }
475+ }
476+ }
477+
478+ func assertNoExtraQoderRecord (t * testing.T , records <- chan usage.Record ) {
479+ t .Helper ()
480+ select {
481+ case record := <- records :
482+ t .Fatalf ("unexpected extra Qoder usage record: %+v" , record )
483+ case <- time .After (100 * time .Millisecond ):
484+ }
485+ }
486+
487+ func testQoderStorageWithModelConfig () * qoder.QoderTokenStorage {
488+ return & qoder.QoderTokenStorage {
489+ Token : "token" ,
490+ RefreshToken : "refresh" ,
491+ ExpireTime : time .Now ().Add (1 * time .Hour ).UnixMilli (),
492+ UserID : "user123" ,
493+ Name : "Test User" ,
494+ Email : "test@example.com" ,
495+ MachineID : "machine123" ,
496+ ModelConfigs : map [string ]json.RawMessage {
497+ "auto" : json .RawMessage (`{"key":"auto","source":"system","is_reasoning":false,"max_output_tokens":8192}` ),
498+ },
499+ }
500+ }
501+
502+ func qoderSSEHTTPResponse (req * http.Request , status int , body string ) * http.Response {
503+ return & http.Response {
504+ StatusCode : status ,
505+ Status : fmt .Sprintf ("%d %s" , status , http .StatusText (status )),
506+ Header : make (http.Header ),
507+ Body : io .NopCloser (strings .NewReader (body )),
508+ Request : req ,
509+ }
510+ }
511+
512+ func qoderStreamBodyWithUsage (t * testing.T , promptTokens , completionTokens , totalTokens int ) string {
513+ t .Helper ()
514+ inner := map [string ]interface {}{
515+ "id" : "chatcmpl-test" ,
516+ "object" : "chat.completion.chunk" ,
517+ "created" : 0 ,
518+ "model" : "auto" ,
519+ "choices" : []map [string ]interface {}{
520+ {
521+ "index" : 0 ,
522+ "delta" : map [string ]interface {}{"content" : "ok" },
523+ "finish_reason" : nil ,
524+ },
525+ },
526+ "usage" : map [string ]interface {}{
527+ "prompt_tokens" : promptTokens ,
528+ "completion_tokens" : completionTokens ,
529+ "total_tokens" : totalTokens ,
530+ },
531+ }
532+ innerBytes , err := json .Marshal (inner )
533+ if err != nil {
534+ t .Fatalf ("marshal inner chunk: %v" , err )
535+ }
536+ eventBytes , err := json .Marshal (map [string ]interface {}{
537+ "statusCodeValue" : http .StatusOK ,
538+ "body" : string (innerBytes ),
539+ })
540+ if err != nil {
541+ t .Fatalf ("marshal Qoder envelope: %v" , err )
542+ }
543+ doneBytes , err := json .Marshal (map [string ]interface {}{
544+ "statusCodeValue" : http .StatusOK ,
545+ "body" : "[DONE]" ,
546+ })
547+ if err != nil {
548+ t .Fatalf ("marshal Qoder done envelope: %v" , err )
549+ }
550+ return "data: " + string (eventBytes ) + "\n \n " + "data: " + string (doneBytes ) + "\n \n "
551+ }
552+
553+ func qoderStreamBodyWithStatus (t * testing.T , status int , body string ) string {
554+ t .Helper ()
555+ eventBytes , err := json .Marshal (map [string ]interface {}{
556+ "statusCodeValue" : status ,
557+ "body" : body ,
558+ })
559+ if err != nil {
560+ t .Fatalf ("marshal Qoder status envelope: %v" , err )
561+ }
562+ return "data: " + string (eventBytes ) + "\n \n "
563+ }
564+
273565// TestBuildOpenAIChunk tests message transformation
274566func TestBuildOpenAIChunk (t * testing.T ) {
275567 inner := map [string ]interface {}{
0 commit comments