diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index a619afd10..63bdf183b 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -3,6 +3,7 @@ package management import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -140,26 +141,48 @@ func (h *Handler) APICall(c *gin.Context) { var token string var tokenResolved bool var tokenErr error - for key, value := range reqHeaders { - if !strings.Contains(value, "$TOKEN$") { - continue - } + + resolveToken := func() error { if !tokenResolved { token, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth, requestProxyURL) tokenResolved = true } if auth != nil && token == "" { if tokenErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "auth token refresh failed"}) - return + return errors.New("auth token refresh failed") } - c.JSON(http.StatusBadRequest, gin.H{"error": "auth token not found"}) - return + return errors.New("auth token not found") } - if token == "" { + return nil + } + + for key, value := range reqHeaders { + if !strings.Contains(value, "$TOKEN$") { continue } - reqHeaders[key] = strings.ReplaceAll(value, "$TOKEN$", token) + if errToken := resolveToken(); errToken != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errToken.Error()}) + return + } + if token != "" { + reqHeaders[key] = strings.ReplaceAll(value, "$TOKEN$", token) + } + } + + if strings.Contains(body.Data, "$TOKEN$") { + if errToken := resolveToken(); errToken != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": errToken.Error()}) + return + } + if token != "" { + replacement := token + if json.Valid([]byte(body.Data)) && strings.ContainsAny(token, "\"\\\r\n\t") { + if b, errMarshal := json.Marshal(token); errMarshal == nil && len(b) >= 2 { + replacement = string(b[1 : len(b)-1]) + } + } + body.Data = strings.ReplaceAll(body.Data, "$TOKEN$", replacement) + } } var requestBody io.Reader @@ -237,6 +260,9 @@ func tokenValueForAuth(auth *coreauth.Auth) string { if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" { return v } + if v := strings.TrimSpace(auth.Attributes["session_token"]); v != "" { + return v + } } return "" } @@ -457,6 +483,12 @@ func tokenValueFromMetadata(metadata map[string]any) string { if v, ok := metadata["id_token"].(string); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } + if v, ok := metadata["api_key"].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + if v, ok := metadata["session_token"].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } if v, ok := metadata["cookie"].(string); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go index a50da2d35..4e530874a 100644 --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -3,6 +3,7 @@ package management import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -315,3 +316,64 @@ func TestAuthByIndexDistinguishesSharedAPIKeysAcrossProviders(t *testing.T) { t.Fatalf("authByIndex(compat) returned %q, want %q", gotCompat.ID, compatAuth.ID) } } + +func TestAPICallReplacesTokenInBodyData(t *testing.T) { + t.Parallel() + + var receivedBody string + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + receivedBody = string(b) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstreamServer.Close() + + manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil) + devinAuth := &coreauth.Auth{ + ID: "devin-test.json", + Provider: "devin", + Attributes: map[string]string{ + "api_key": "secret-session-token-xyz", + }, + Metadata: map[string]any{ + "type": "devin", + "api_key": "secret-session-token-xyz", + }, + } + if _, errRegister := manager.Register(context.Background(), devinAuth); errRegister != nil { + t.Fatalf("register devin auth: %v", errRegister) + } + authIndex := devinAuth.EnsureIndex() + + h := &Handler{ + cfg: &config.Config{}, + authManager: manager, + } + router := gin.New() + router.POST("/", h.APICall) + + reqPayload := map[string]any{ + "method": "POST", + "url": upstreamServer.URL, + "auth_index": authIndex, + "header": map[string]string{ + "Content-Type": "application/json", + }, + "data": `{"metadata":{"apiKey":"$TOKEN$","ideName":"chisel"}}`, + } + reqBytes, _ := json.Marshal(reqPayload) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(reqBytes))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d; body = %s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + expectedBody := `{"metadata":{"apiKey":"secret-session-token-xyz","ideName":"chisel"}}` + if receivedBody != expectedBody { + t.Fatalf("received body = %q, want %q", receivedBody, expectedBody) + } +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 36cf69e87..a6f8b086c 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -928,17 +928,17 @@ func (h *Host) rollbackReplacement(lp *loadedPlugin, item runtimeItemConfig) (ca return capabilityRecord{}, pluginFile{}, false } return capabilityRecord{ - id: lp.id, - path: lp.path, - version: lp.version, - priority: item.Priority, - meta: plugin.Metadata, - plugin: plugin, - }, pluginFile{ - ID: lp.id, - Path: lp.path, - Version: lp.version, - }, true + id: lp.id, + path: lp.path, + version: lp.version, + priority: item.Priority, + meta: plugin.Metadata, + plugin: plugin, + }, pluginFile{ + ID: lp.id, + Path: lp.path, + Version: lp.version, + }, true } func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) { diff --git a/internal/registry/devin_models.go b/internal/registry/devin_models.go index f28435432..a0a1824be 100644 --- a/internal/registry/devin_models.go +++ b/internal/registry/devin_models.go @@ -163,6 +163,7 @@ func sanitizeAndValidateDevinModels(models []*ModelInfo) ([]*ModelInfo, error) { if !strings.HasPrefix(strings.ToLower(id), "devin/") { id = "devin/" + id } + id = strings.ToLower(id) m.ID = id if _, exists := seen[id]; exists { return nil, fmt.Errorf("duplicate model id: %q", id) diff --git a/internal/registry/devin_models_test.go b/internal/registry/devin_models_test.go index 94e744366..df9a3a98a 100644 --- a/internal/registry/devin_models_test.go +++ b/internal/registry/devin_models_test.go @@ -98,13 +98,17 @@ func TestValidateDevinModelsJSON(t *testing.T) { } }) - t.Run("duplicate model id", func(t *testing.T) { - data := []byte(`{"devin": [{"id": "devin/swe-2"}, {"id": "devin/swe-2"}]}`) - _, err := ValidateDevinModelsJSON(data) - if err == nil { - t.Fatal("expected error on duplicate model id, got nil") - } - }) + for name, data := range map[string][]byte{ + "exact duplicate": []byte(`{"devin": [{"id": "devin/swe-2"}, {"id": "devin/swe-2"}]}`), + "case duplicate": []byte(`{"devin": [{"id": "devin/SWE-2"}, {"id": "devin/swe-2"}]}`), + } { + t.Run("duplicate model id/"+name, func(t *testing.T) { + _, err := ValidateDevinModelsJSON(data) + if err == nil { + t.Fatal("expected error on duplicate model id, got nil") + } + }) + } } func TestEmbeddedDevinModelsLoadedOnStartup(t *testing.T) { diff --git a/internal/runtime/executor/claude_thinking_replay_test.go b/internal/runtime/executor/claude_thinking_replay_test.go index 14c928566..3fb7754d8 100644 --- a/internal/runtime/executor/claude_thinking_replay_test.go +++ b/internal/runtime/executor/claude_thinking_replay_test.go @@ -33,17 +33,17 @@ func claudeReplayTestAuth(baseURL string) *cliproxyauth.Auth { func claudeReplayTestRequest(payload []byte, sessionID string, isCompat bool, source sdktranslator.Format) (cliproxyexecutor.Request, cliproxyexecutor.Options) { return cliproxyexecutor.Request{ - Model: "claude-synthetic-4772", - Payload: payload, - Metadata: map[string]any{ - claudeReplayResolvedModelInfoKey: ®istry.ModelInfo{IsCompat: isCompat}, - }, - }, cliproxyexecutor.Options{ - SourceFormat: source, - Metadata: map[string]any{ - cliproxyexecutor.ExecutionSessionMetadataKey: sessionID, - }, - } + Model: "claude-synthetic-4772", + Payload: payload, + Metadata: map[string]any{ + claudeReplayResolvedModelInfoKey: ®istry.ModelInfo{IsCompat: isCompat}, + }, + }, cliproxyexecutor.Options{ + SourceFormat: source, + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: sessionID, + }, + } } func TestClaudeThinkingReplayEnabledRequiresCompatClaudeAPIKey(t *testing.T) { diff --git a/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go b/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go index 5d507f432..745102fd0 100644 --- a/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go +++ b/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go @@ -37,12 +37,12 @@ func codexTestAuth(baseURL string) *cliproxyauth.Auth { func codexTestRequest() (cliproxyexecutor.Request, cliproxyexecutor.Options) { return cliproxyexecutor.Request{ - Model: "gpt-5.6-terra", - Payload: []byte(`{"model":"gpt-5.6-terra","input":"hello"}`), - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FromString("openai-response"), - Stream: true, - } + Model: "gpt-5.6-terra", + Payload: []byte(`{"model":"gpt-5.6-terra","input":"hello"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: true, + } } // codexSSEServer streams the supplied event payloads as an HTTP 200 SSE response. @@ -83,11 +83,11 @@ func codexWebsocketServer(t *testing.T, frames ...string) *httptest.Server { func codexWebsocketRequest() (cliproxyexecutor.Request, cliproxyexecutor.Options) { return cliproxyexecutor.Request{ - Model: "gpt-5.6-terra", - Payload: []byte(`{"model":"gpt-5.6-terra","input":[{"type":"message","role":"user","content":"hello"}]}`), - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FromString("openai-response"), - } + Model: "gpt-5.6-terra", + Payload: []byte(`{"model":"gpt-5.6-terra","input":[{"type":"message","role":"user","content":"hello"}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + } } // drainChunks collects every payload and the first error from a stream result. diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index bc11b858c..eadefd7c0 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -568,7 +568,49 @@ func (e *DevinExecutor) streamDevinFrames( } if frameRes.Usage != nil { - finalUsage = frameRes.Usage + if finalUsage == nil { + finalUsage = frameRes.Usage + } else { + if frameRes.Usage.PromptTokens > 0 { + finalUsage.PromptTokens = frameRes.Usage.PromptTokens + } + if frameRes.Usage.CompletionTokens > 0 { + finalUsage.CompletionTokens = frameRes.Usage.CompletionTokens + } + if frameRes.Usage.CachedTokens > 0 { + finalUsage.CachedTokens = frameRes.Usage.CachedTokens + } + if frameRes.Usage.RequestID != "" { + finalUsage.RequestID = frameRes.Usage.RequestID + } + if frameRes.Usage.ModelName != "" { + finalUsage.ModelName = frameRes.Usage.ModelName + } + if len(frameRes.Usage.Headers) > 0 { + if finalUsage.Headers == nil { + finalUsage.Headers = make(map[string]string, len(frameRes.Usage.Headers)) + } + for hk, hv := range frameRes.Usage.Headers { + finalUsage.Headers[hk] = hv + } + } + } + } + if len(frameRes.ResponseDimensionGroups) > 0 && (finalUsage == nil || finalUsage.PromptTokens == 0 || finalUsage.CompletionTokens == 0 || finalUsage.CachedTokens == 0) { + if inTok, outTok, cachedTok, ok := helps.ParseDevinResponseDimensionGroups(frameRes.ResponseDimensionGroups...); ok { + if finalUsage == nil { + finalUsage = &helps.DevinUsage{} + } + if finalUsage.PromptTokens == 0 { + finalUsage.PromptTokens = inTok + } + if finalUsage.CompletionTokens == 0 { + finalUsage.CompletionTokens = outTok + } + if finalUsage.CachedTokens == 0 { + finalUsage.CachedTokens = cachedTok + } + } } if len(frameRes.DeltaSignature) > 0 { accumulatedSignature = append(accumulatedSignature, frameRes.DeltaSignature...) @@ -905,7 +947,49 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string } if frameRes.Usage != nil { - finalUsage = frameRes.Usage + if finalUsage == nil { + finalUsage = frameRes.Usage + } else { + if frameRes.Usage.PromptTokens > 0 { + finalUsage.PromptTokens = frameRes.Usage.PromptTokens + } + if frameRes.Usage.CompletionTokens > 0 { + finalUsage.CompletionTokens = frameRes.Usage.CompletionTokens + } + if frameRes.Usage.CachedTokens > 0 { + finalUsage.CachedTokens = frameRes.Usage.CachedTokens + } + if frameRes.Usage.RequestID != "" { + finalUsage.RequestID = frameRes.Usage.RequestID + } + if frameRes.Usage.ModelName != "" { + finalUsage.ModelName = frameRes.Usage.ModelName + } + if len(frameRes.Usage.Headers) > 0 { + if finalUsage.Headers == nil { + finalUsage.Headers = make(map[string]string, len(frameRes.Usage.Headers)) + } + for hk, hv := range frameRes.Usage.Headers { + finalUsage.Headers[hk] = hv + } + } + } + } + if len(frameRes.ResponseDimensionGroups) > 0 && (finalUsage == nil || finalUsage.PromptTokens == 0 || finalUsage.CompletionTokens == 0 || finalUsage.CachedTokens == 0) { + if inTok, outTok, cachedTok, ok := helps.ParseDevinResponseDimensionGroups(frameRes.ResponseDimensionGroups...); ok { + if finalUsage == nil { + finalUsage = &helps.DevinUsage{} + } + if finalUsage.PromptTokens == 0 { + finalUsage.PromptTokens = inTok + } + if finalUsage.CompletionTokens == 0 { + finalUsage.CompletionTokens = outTok + } + if finalUsage.CachedTokens == 0 { + finalUsage.CachedTokens = cachedTok + } + } } if len(frameRes.DeltaSignature) > 0 { accumulatedSignature = append(accumulatedSignature, frameRes.DeltaSignature...) diff --git a/internal/runtime/executor/helps/devin_wire.go b/internal/runtime/executor/helps/devin_wire.go index 1f214e05b..820678470 100644 --- a/internal/runtime/executor/helps/devin_wire.go +++ b/internal/runtime/executor/helps/devin_wire.go @@ -94,12 +94,13 @@ type DevinPrompt struct { // DevinUsage captures token accounting from response Field 7. type DevinUsage struct { - PromptTokens int64 - CompletionTokens int64 - CachedTokens int64 - StatusCode uint64 - RequestID string - ModelName string + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + CachedTokens int64 `json:"cached_tokens"` + StatusCode uint64 `json:"status_code,omitempty"` + RequestID string `json:"request_id,omitempty"` + ModelName string `json:"model_name,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } // DevinFrameResult represents decoded content from a single Connect-proto frame. @@ -116,7 +117,7 @@ type DevinFrameResult struct { Latency float64 MessageID string Usage *DevinUsage - ResponseDimensionGroups []byte + ResponseDimensionGroups [][]byte UnknownFieldNumbers []int } @@ -587,7 +588,7 @@ func ParseDevinFrame(payload []byte) (DevinFrameResult, error) { case 21: res.DeltaSignatureType = string(val) case 28: - res.ResponseDimensionGroups = val + res.ResponseDimensionGroups = append(res.ResponseDimensionGroups, val) default: res.UnknownFieldNumbers = append(res.UnknownFieldNumbers, int(num)) } @@ -715,6 +716,42 @@ func parseDevinTimestamp(data []byte) uint64 { return secs } +// parseDevinHeaderField parses a repeated submessage in Field 7 (subfield 8) representing upstream response headers: +// Tag 1 (string): Header name (e.g. "x-request-id", "Request-Id", "openai-processing-ms") +// Tag 2 (string): Header value (e.g. "req_011Cf1JivhJrXDq9ycq7cEtH", "chatcmpl-...") +func parseDevinHeaderField(data []byte) (string, string) { + var key, val string + pos := 0 + for pos < len(data) { + num, typ, n := protowire.ConsumeTag(data[pos:]) + if n <= 0 { + break + } + pos += n + switch typ { + case protowire.BytesType: + b, bn := protowire.ConsumeBytes(data[pos:]) + if bn <= 0 { + return key, val + } + pos += bn + switch num { + case 1: + key = string(b) + case 2: + val = string(b) + } + default: + nSkip := protowire.ConsumeFieldValue(num, typ, data[pos:]) + if nSkip <= 0 { + return key, val + } + pos += nSkip + } + } + return key, val +} + func parseDevinUsageField(data []byte) *DevinUsage { u := &DevinUsage{} pos := 0 @@ -733,10 +770,12 @@ func parseDevinUsageField(data []byte) *DevinUsage { } pos += vn switch num { - case 2: // Prompt tokens (uncached input) - u.PromptTokens = int64(v) + case 2: // Prompt tokens (uncached input from turn message) + u.PromptTokens += int64(v) case 3: // Output tokens u.CompletionTokens = int64(v) + case 4: // Additional context/system prompt tokens in OpenAI-family models (total prompt = 2 + 4) + u.PromptTokens += int64(v) case 5: // Cache read tokens u.CachedTokens = int64(v) case 6: // Status code @@ -750,7 +789,18 @@ func parseDevinUsageField(data []byte) *DevinUsage { pos += bn switch num { case 8: - u.RequestID = string(val) + k, v := parseDevinHeaderField(val) + if k != "" { + if u.Headers == nil { + u.Headers = make(map[string]string) + } + u.Headers[k] = v + if (strings.EqualFold(k, "x-request-id") || strings.EqualFold(k, "request-id")) && v != "" { + u.RequestID = v + } + } else if len(val) > 0 && isPrintableASCII(val) && u.RequestID == "" { + u.RequestID = string(val) + } case 9: u.ModelName = string(val) } @@ -767,12 +817,139 @@ func parseDevinUsageField(data []byte) *DevinUsage { } pos += fn default: - return u + nSkip := protowire.ConsumeFieldValue(num, typ, data[pos:]) + if nSkip <= 0 { + return u + } + pos += nSkip } } return u } +// ParseDevinResponseDimensionGroups parses Field 28 (ResponseDimensionGroups) entries to extract Token Usage metrics: +// input_tokens, output_tokens, cached_input_tokens. +// Accepts one or more group payloads (each corresponding to a Field 28 value), or an outer envelope containing Tag 28. +func ParseDevinResponseDimensionGroups(groups ...[]byte) (promptTokens, completionTokens, cachedTokens int64, found bool) { + for _, gBytes := range groups { + if len(gBytes) == 0 { + continue + } + // If outer envelope carries Tag 28, unwrap it to get inner group bytes. + if num, typ, n := protowire.ConsumeTag(gBytes); n > 0 && num == 28 && typ == protowire.BytesType { + if inner, bn := protowire.ConsumeBytes(gBytes[n:]); bn > 0 { + gBytes = inner + } + } + + gPos := 0 + var title string + type metricItem struct { + key string + val float32 + } + var metrics []metricItem + for gPos < len(gBytes) { + gNum, gTyp, gn := protowire.ConsumeTag(gBytes[gPos:]) + if gn <= 0 { + break + } + gPos += gn + if gTyp != protowire.BytesType { + gSkip := protowire.ConsumeFieldValue(gNum, gTyp, gBytes[gPos:]) + if gSkip <= 0 { + break + } + gPos += gSkip + continue + } + gb, gbn := protowire.ConsumeBytes(gBytes[gPos:]) + if gbn <= 0 { + break + } + gPos += gbn + if gNum == 1 { + title = string(gb) + } else if gNum == 2 { + mPos := 0 + var mKey string + var mVal float32 + for mPos < len(gb) { + mNum, mTyp, mn := protowire.ConsumeTag(gb[mPos:]) + if mn <= 0 { + break + } + mPos += mn + if mTyp != protowire.BytesType { + mSkip := protowire.ConsumeFieldValue(mNum, mTyp, gb[mPos:]) + if mSkip <= 0 { + break + } + mPos += mSkip + continue + } + mb, mbn := protowire.ConsumeBytes(gb[mPos:]) + if mbn <= 0 { + break + } + mPos += mbn + if mNum == 5 { + mKey = string(mb) + } else if mNum == 4 { + dPos := 0 + for dPos < len(mb) { + dNum, dTyp, dn := protowire.ConsumeTag(mb[dPos:]) + if dn <= 0 { + break + } + dPos += dn + if dTyp == protowire.Fixed32Type { + dv, dfn := protowire.ConsumeFixed32(mb[dPos:]) + if dfn <= 0 { + break + } + dPos += dfn + if dNum == 2 { + mVal = math.Float32frombits(dv) + } + } else { + dSkip := protowire.ConsumeFieldValue(dNum, dTyp, mb[dPos:]) + if dSkip <= 0 { + break + } + dPos += dSkip + } + } + } + } + if mKey != "" { + metrics = append(metrics, metricItem{key: mKey, val: mVal}) + } + } + } + + if strings.EqualFold(title, "Token Usage") { + for _, m := range metrics { + switch m.key { + case "input_tokens": + promptTokens = int64(m.val) + found = true + case "output_tokens": + completionTokens = int64(m.val) + found = true + case "cached_input_tokens": + cachedTokens = int64(m.val) + found = true + } + } + if found { + return promptTokens, completionTokens, cachedTokens, true + } + } + } + return promptTokens, completionTokens, cachedTokens, found +} + // ParseDevinTrailerError inspects Connect-RPC EOS trailer frames and maps error status codes. func ParseDevinTrailerError(payload []byte) (statusCode int, err error) { trimmed := bytes.TrimSpace(payload) diff --git a/internal/runtime/executor/helps/devin_wire_test.go b/internal/runtime/executor/helps/devin_wire_test.go index 3255c5e17..00b962b0b 100644 --- a/internal/runtime/executor/helps/devin_wire_test.go +++ b/internal/runtime/executor/helps/devin_wire_test.go @@ -2,6 +2,7 @@ package helps import ( "bytes" + "math" "strings" "testing" @@ -588,3 +589,201 @@ func extractField15Subfields(t *testing.T, reqBytes []byte) (string, map[int]uin } return sessionID, subfields } + +func TestParseDevinUsageField_HeadersAndField4(t *testing.T) { + var f7Bytes []byte + f7Bytes = protowire.AppendTag(f7Bytes, 2, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 3) + + f7Bytes = protowire.AppendTag(f7Bytes, 4, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 58) + + f7Bytes = protowire.AppendTag(f7Bytes, 3, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 39) + + f7Bytes = protowire.AppendTag(f7Bytes, 5, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 19179) + + f7Bytes = protowire.AppendTag(f7Bytes, 6, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 66) + + // Submessage 1: openai-version + var h1 []byte + h1 = protowire.AppendTag(h1, 1, protowire.BytesType) + h1 = protowire.AppendString(h1, "openai-version") + h1 = protowire.AppendTag(h1, 2, protowire.BytesType) + h1 = protowire.AppendString(h1, "2020-10-01") + f7Bytes = protowire.AppendTag(f7Bytes, 8, protowire.BytesType) + f7Bytes = protowire.AppendBytes(f7Bytes, h1) + + // Submessage 2: x-request-id + var h2 []byte + h2 = protowire.AppendTag(h2, 1, protowire.BytesType) + h2 = protowire.AppendString(h2, "x-request-id") + h2 = protowire.AppendTag(h2, 2, protowire.BytesType) + h2 = protowire.AppendString(h2, "req_5bb00ad48ae048119e3420bddf36257f") + f7Bytes = protowire.AppendTag(f7Bytes, 8, protowire.BytesType) + f7Bytes = protowire.AppendBytes(f7Bytes, h2) + + // Submessage 3: openai-processing-ms + var h3 []byte + h3 = protowire.AppendTag(h3, 1, protowire.BytesType) + h3 = protowire.AppendString(h3, "openai-processing-ms") + h3 = protowire.AppendTag(h3, 2, protowire.BytesType) + h3 = protowire.AppendString(h3, "419") + f7Bytes = protowire.AppendTag(f7Bytes, 8, protowire.BytesType) + f7Bytes = protowire.AppendBytes(f7Bytes, h3) + + f7Bytes = protowire.AppendTag(f7Bytes, 9, protowire.BytesType) + f7Bytes = protowire.AppendString(f7Bytes, "gpt-5-6-luna-low") + + usage := parseDevinUsageField(f7Bytes) + if usage == nil { + t.Fatal("expected non-nil usage") + } + + // 3 + 58 = 61 + if usage.PromptTokens != 61 { + t.Errorf("PromptTokens = %d, want 61 (3 turn + 58 context)", usage.PromptTokens) + } + if usage.CompletionTokens != 39 { + t.Errorf("CompletionTokens = %d, want 39", usage.CompletionTokens) + } + if usage.CachedTokens != 19179 { + t.Errorf("CachedTokens = %d, want 19179", usage.CachedTokens) + } + if usage.StatusCode != 66 { + t.Errorf("StatusCode = %d, want 66", usage.StatusCode) + } + if usage.RequestID != "req_5bb00ad48ae048119e3420bddf36257f" { + t.Errorf("RequestID = %q, want clean request-id", usage.RequestID) + } + if usage.ModelName != "gpt-5-6-luna-low" { + t.Errorf("ModelName = %q, want gpt-5-6-luna-low", usage.ModelName) + } + if usage.Headers["openai-processing-ms"] != "419" { + t.Errorf("header processing-ms = %q, want 419", usage.Headers["openai-processing-ms"]) + } + if usage.Headers["openai-version"] != "2020-10-01" { + t.Errorf("header openai-version = %q, want 2020-10-01", usage.Headers["openai-version"]) + } +} + +func TestParseDevinUsageField_AnthropicRequestId(t *testing.T) { + var f7Bytes []byte + f7Bytes = protowire.AppendTag(f7Bytes, 2, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 4) + + f7Bytes = protowire.AppendTag(f7Bytes, 3, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 109) + + f7Bytes = protowire.AppendTag(f7Bytes, 5, protowire.VarintType) + f7Bytes = protowire.AppendVarint(f7Bytes, 577) + + // Anthropic uses capitalized "Request-Id" + var h []byte + h = protowire.AppendTag(h, 1, protowire.BytesType) + h = protowire.AppendString(h, "Request-Id") + h = protowire.AppendTag(h, 2, protowire.BytesType) + h = protowire.AppendString(h, "req_011Cf1JivhJrXDq9ycq7cEtH") + f7Bytes = protowire.AppendTag(f7Bytes, 8, protowire.BytesType) + f7Bytes = protowire.AppendBytes(f7Bytes, h) + + usage := parseDevinUsageField(f7Bytes) + if usage == nil { + t.Fatal("expected non-nil usage") + } + if usage.RequestID != "req_011Cf1JivhJrXDq9ycq7cEtH" { + t.Errorf("RequestID = %q, want req_011Cf1JivhJrXDq9ycq7cEtH", usage.RequestID) + } + if usage.PromptTokens != 4 { + t.Errorf("PromptTokens = %d, want 4", usage.PromptTokens) + } + if usage.CompletionTokens != 109 { + t.Errorf("CompletionTokens = %d, want 109", usage.CompletionTokens) + } + if usage.CachedTokens != 577 { + t.Errorf("CachedTokens = %d, want 577", usage.CachedTokens) + } +} + +func TestParseDevinResponseDimensionGroups(t *testing.T) { + buildMetric := func(key string, val float32) []byte { + // Dimension submessage (Tag 4 of Metric) + var dim []byte + dim = protowire.AppendTag(dim, 2, protowire.Fixed32Type) + dim = protowire.AppendFixed32(dim, math.Float32bits(val)) + + // Metric submessage (Tag 2 of Group) + var metric []byte + metric = protowire.AppendTag(metric, 4, protowire.BytesType) + metric = protowire.AppendBytes(metric, dim) + metric = protowire.AppendTag(metric, 5, protowire.BytesType) + metric = protowire.AppendString(metric, key) + return metric + } + + // Build Group (Tag 28) + var group []byte + group = protowire.AppendTag(group, 1, protowire.BytesType) + group = protowire.AppendString(group, "Token Usage") + + group = protowire.AppendTag(group, 2, protowire.BytesType) + group = protowire.AppendBytes(group, buildMetric("input_tokens", 575.0)) + + group = protowire.AppendTag(group, 2, protowire.BytesType) + group = protowire.AppendBytes(group, buildMetric("output_tokens", 5.0)) + + group = protowire.AppendTag(group, 2, protowire.BytesType) + group = protowire.AppendBytes(group, buildMetric("cached_input_tokens", 128.0)) + + // Envelope Tag 28 + var root []byte + root = protowire.AppendTag(root, 28, protowire.BytesType) + root = protowire.AppendBytes(root, group) + + promptTokens, completionTokens, cachedTokens, found := ParseDevinResponseDimensionGroups(root) + if !found { + t.Fatal("expected found = true") + } + if promptTokens != 575 { + t.Errorf("promptTokens = %d, want 575", promptTokens) + } + if completionTokens != 5 { + t.Errorf("completionTokens = %d, want 5", completionTokens) + } + if cachedTokens != 128 { + t.Errorf("cachedTokens = %d, want 128", cachedTokens) + } + + // Verify inner group directly (as extracted by ParseDevinFrame case 28) + p2, c2, ca2, found2 := ParseDevinResponseDimensionGroups(group) + if !found2 || p2 != 575 || c2 != 5 || ca2 != 128 { + t.Errorf("inner group ParseDevinResponseDimensionGroups = (%d,%d,%d,%t), want (575,5,128,true)", p2, c2, ca2, found2) + } + + // Verify multi-group where unrelated group precedes Token Usage + var latencyGroup []byte + latencyGroup = protowire.AppendTag(latencyGroup, 1, protowire.BytesType) + latencyGroup = protowire.AppendString(latencyGroup, "Latency Metrics") + + p3, c3, ca3, found3 := ParseDevinResponseDimensionGroups(latencyGroup, group) + if !found3 || p3 != 575 || c3 != 5 || ca3 != 128 { + t.Errorf("multi-group ParseDevinResponseDimensionGroups = (%d,%d,%d,%t), want (575,5,128,true)", p3, c3, ca3, found3) + } +} + +func TestParseDevinResponseDimensionGroups_UnrelatedGroup(t *testing.T) { + var group []byte + group = protowire.AppendTag(group, 1, protowire.BytesType) + group = protowire.AppendString(group, "Latency Metrics") + + var root []byte + root = protowire.AppendTag(root, 28, protowire.BytesType) + root = protowire.AppendBytes(root, group) + + promptTokens, completionTokens, cachedTokens, found := ParseDevinResponseDimensionGroups(root) + if found { + t.Errorf("expected found = false for unrelated group, got true with prompt=%d, comp=%d, cached=%d", promptTokens, completionTokens, cachedTokens) + } +} diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index 6a393f8d4..7b0269943 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -298,6 +298,50 @@ func TestApplyOAuthModelAlias_PerAuthAliasSkipsAPIKey(t *testing.T) { } } +func TestApplyOAuthModelAlias_Devin(t *testing.T) { + t.Parallel() + + aliases := map[string][]internalconfig.OAuthModelAlias{ + "devin": { + { + Name: "devin/claude-fable-5-1", + Alias: "fable-5-1", + Fork: true, + ForceMapping: true, + }, + }, + } + + mgr := NewManager(nil, nil, nil) + mgr.SetConfig(&internalconfig.Config{}) + mgr.SetOAuthModelAlias(aliases) + + auth := &Auth{ID: "devin-auth", Provider: "devin", Attributes: map[string]string{"auth_kind": "oauth"}} + + resolvedModel := mgr.applyOAuthModelAlias(auth, "fable-5-1") + if resolvedModel != "devin/claude-fable-5-1" { + t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "devin/claude-fable-5-1") + } + + // Suffix preservation with Devin thinking effort + suffixed := mgr.applyOAuthModelAlias(auth, "fable-5-1(max)") + if suffixed != "devin/claude-fable-5-1(max)" { + t.Errorf("applyOAuthModelAlias() suffixed model = %q, want %q", suffixed, "devin/claude-fable-5-1(max)") + } + + // Force mapping result + result := mgr.applyOAuthModelAliasWithResult(auth, "fable-5-1(max)") + if result.UpstreamModel != "devin/claude-fable-5-1(max)" { + t.Errorf("UpstreamModel = %q, want %q", result.UpstreamModel, "devin/claude-fable-5-1(max)") + } + if !result.ForceMapping { + t.Errorf("ForceMapping = false, want true") + } + if result.OriginalAlias != "fable-5-1" { + t.Errorf("OriginalAlias = %q, want %q", result.OriginalAlias, "fable-5-1") + } +} + func TestApplyOAuthModelAlias_PluginProvider(t *testing.T) { t.Parallel()