Skip to content

Commit 38e86b0

Browse files
authored
fix(qoder): publish usage accounting records (#115)
1 parent 5ecdba6 commit 38e86b0

2 files changed

Lines changed: 309 additions & 3 deletions

File tree

internal/runtime/executor/qoder_executor.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (e *QoderExecutor) Identifier() string {
4545
}
4646

4747
// ExecuteStream executes a streaming request against Qoder API
48-
func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
48+
func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
4949
// Get token storage from auth record
5050
storage, ok := authRecord.Storage.(*qoderauth.QoderTokenStorage)
5151
if !ok {
@@ -80,6 +80,8 @@ func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxya
8080
// Not in static map and not in dynamic model cache — reject early.
8181
return nil, fmt.Errorf("unsupported qoder model: %q (received %q)", qoderModel, model)
8282
}
83+
reporter := helps.NewExecutorUsageReporter(ctx, e, qoderModel, authRecord)
84+
defer reporter.TrackFailure(ctx, &err)
8385

8486
// Normalize messages: flatten Anthropic/OpenAI multipart content arrays
8587
// to plain strings (Qoder's chat endpoint expects content to be a string).
@@ -221,6 +223,7 @@ func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxya
221223
httpReq.Header.Set("Accept-Encoding", "identity")
222224

223225
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, authRecord, 0)
226+
httpClient = reporter.TrackHTTPClient(httpClient)
224227
httpResp, err := httpClient.Do(httpReq)
225228
if err != nil {
226229
return nil, fmt.Errorf("request failed: %w", err)
@@ -274,6 +277,7 @@ func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxya
274277
data = bytes.TrimPrefix(data, []byte(" "))
275278
if bytes.Equal(data, []byte("[DONE]")) {
276279
emitDone(ctx, out, opts.SourceFormat, req.Model, opts.OriginalRequest, payload, &streamParam)
280+
reporter.EnsurePublished(ctx)
277281
return
278282
}
279283

@@ -297,14 +301,17 @@ func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxya
297301
if msg == "" {
298302
msg = fmt.Sprintf("upstream status %d", statusVal)
299303
}
300-
out <- cliproxyexecutor.StreamChunk{Err: newQoderStatusError(statusVal, msg)}
304+
streamErr := newQoderStatusError(statusVal, msg)
305+
reporter.PublishFailure(ctx, streamErr)
306+
out <- cliproxyexecutor.StreamChunk{Err: streamErr}
301307
return
302308
}
303309
if innerStr == "" {
304310
continue
305311
}
306312
if innerStr == "[DONE]" {
307313
emitDone(ctx, out, opts.SourceFormat, req.Model, opts.OriginalRequest, payload, &streamParam)
314+
reporter.EnsurePublished(ctx)
308315
return
309316
}
310317
var inner map[string]interface{}
@@ -323,6 +330,9 @@ func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxya
323330
// other executors feed into TranslateStream so the translators'
324331
// "expects data: prefix" assumption holds.
325332
ssePayload := append([]byte("data: "), chunkBytes...)
333+
if detail, ok := helps.ParseOpenAIStreamUsage(ssePayload); ok {
334+
reporter.Publish(ctx, detail)
335+
}
326336

327337
// Always run through TranslateStream. When source==target
328338
// (OpenAI client) it strips the "data:" prefix and returns
@@ -352,8 +362,12 @@ func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxya
352362
emitDone(ctx, out, opts.SourceFormat, req.Model, opts.OriginalRequest, payload, &streamParam)
353363
// Check for scanner errors
354364
if err := scanner.Err(); err != nil {
355-
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("scanner error: %w", err)}
365+
streamErr := fmt.Errorf("scanner error: %w", err)
366+
reporter.PublishFailure(ctx, streamErr)
367+
out <- cliproxyexecutor.StreamChunk{Err: streamErr}
368+
return
356369
}
370+
reporter.EnsurePublished(ctx)
357371
}()
358372

359373
return &cliproxyexecutor.StreamResult{

internal/runtime/executor/qoder_executor_test.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
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
253423
func 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
274566
func TestBuildOpenAIChunk(t *testing.T) {
275567
inner := map[string]interface{}{

0 commit comments

Comments
 (0)