mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-07 08:09:48 +08:00
Merge pull request #5806 from router-for-me/feat/devin-followups
fix(devin): wire protocol submessage decoding, usage fallbacks, model uniqueness, and APICall body token replacement
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user