Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/codearts-ot-session-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- CodeArts chat now sends WorkBuddy tip `28527f4` / hub #723 session headers after signing: account-stable `x-ot-session-id` and `user-session-id`, plus a fresh `x-ot-trace-id` per request (kept out of SignedHeaders), so Huawei no longer treats every call as a new concurrent session.

### 中文

- 码道(CodeArts)聊天在签名之后补发 WorkBuddy tip `28527f4` / hub #723 会话头:账号级稳定的 `x-ot-session-id` 与 `user-session-id`,以及每请求新的 `x-ot-trace-id`(不进入 SignedHeaders),避免华为云把每次请求都当成新建并发会话。
2 changes: 2 additions & 0 deletions internal/providers/codearts/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ func (c *Client) sendChat(ctx context.Context, accountID string, credential Cred
req.Header.Set(HeaderMaasType, MaasBenefit)
}
signRequest(req, body, credential.Sign())
// Tip headers after sign so they stay out of SignedHeaders (hub #723 / tip 28527f4).
applyOTSessionHeaders(req.Header, credential.UserID)
if id, ok := payloadChatID(body); ok && id != "" {
req.Header.Set("Chat-Id", id)
req.Header.Set("Session-Id", id)
Expand Down
159 changes: 159 additions & 0 deletions internal/providers/codearts/ot_session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package codearts

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

"github.com/caigee-cmd/cli2api/internal/translate"
)

func TestOTSessionIDStablePerUser(t *testing.T) {
a := otSessionID("user-1")
b := otSessionID("user-1")
c := otSessionID("user-2")
if a == "" || len(a) != 32 {
t.Fatalf("otSessionID length = %d value=%q", len(a), a)
}
for _, ch := range a {
if !strings.ContainsRune("0123456789abcdef", ch) {
t.Fatalf("non-hex char in %q", a)
}
}
if a != b {
t.Fatalf("same user must be stable: %q vs %q", a, b)
}
if a == c {
t.Fatalf("different users must differ: %q", a)
}
if a != "6c6b39c57d8cffff7601d294f2ad931e" {
t.Fatalf("must match WorkBuddy tip seed, got %q", a)
}
}

func TestApplyOTSessionHeadersAfterSignStayOutOfSignedHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://example.test/api/v2/chat/completions", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
body := []byte(`{"model":"GLM-5.2"}`)
signRequest(req, body, SignCredential{AccessKeyID: "AK", SecretAccessKey: "SK", SecurityToken: "STS"})
authBefore := req.Header.Get("Authorization")
if !strings.Contains(authBefore, "SignedHeaders=") {
t.Fatalf("missing SignedHeaders in %q", authBefore)
}
applyOTSessionHeaders(req.Header, "user-1")
authAfter := req.Header.Get("Authorization")
if authAfter != authBefore {
t.Fatal("Authorization must not change after tip headers are applied")
}
session := req.Header.Get("x-ot-session-id")
if session == "" || session != req.Header.Get("user-session-id") {
t.Fatalf("session headers = %q / %q", session, req.Header.Get("user-session-id"))
}
if req.Header.Get("x-ot-trace-id") == "" {
t.Fatal("x-ot-trace-id missing")
}
signed := signedHeadersList(authAfter)
for _, name := range []string{"x-ot-session-id", "user-session-id", "x-ot-trace-id"} {
for _, listed := range signed {
if listed == name {
t.Fatalf("%s must not appear in SignedHeaders=%v", name, signed)
}
}
}
}

func TestChatOTSessionHeadersStableAndTraceDiffers(t *testing.T) {
var mu sync.Mutex
var captured []http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != chatPath {
http.NotFound(w, r)
return
}
mu.Lock()
captured = append(captured, r.Header.Clone())
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
writeSSE(t, w, map[string]any{"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": "ok"}}}})
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer server.Close()
client, _ := newTestClient(t, server)

req := translate.ChatRequest{
Model: "GLM-5.2",
Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}},
}
if _, err := client.ChatNonStream(context.Background(), "acct-1", req); err != nil {
t.Fatalf("first chat: %v", err)
}
if _, err := client.ChatNonStream(context.Background(), "acct-1", req); err != nil {
t.Fatalf("second chat: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(captured) != 2 {
t.Fatalf("captured %d chat requests", len(captured))
}
first, second := captured[0], captured[1]
session := first.Get("x-ot-session-id")
if session == "" {
t.Fatal("x-ot-session-id missing on chat")
}
if session != first.Get("user-session-id") {
t.Fatalf("user-session-id=%q want %q", first.Get("user-session-id"), session)
}
if session != second.Get("x-ot-session-id") || session != second.Get("user-session-id") {
t.Fatalf("session ids not stable across requests: %q/%q vs %q/%q",
session, first.Get("user-session-id"), second.Get("x-ot-session-id"), second.Get("user-session-id"))
}
if want := otSessionID("user-1"); session != want {
t.Fatalf("session=%q want derived %q", session, want)
}
trace1, trace2 := first.Get("x-ot-trace-id"), second.Get("x-ot-trace-id")
if trace1 == "" || trace2 == "" || trace1 == trace2 {
t.Fatalf("trace ids must differ per request: %q vs %q", trace1, trace2)
}
for i, h := range captured {
signed := signedHeadersList(h.Get("Authorization"))
for _, name := range []string{"x-ot-session-id", "user-session-id", "x-ot-trace-id"} {
for _, listed := range signed {
if listed == name {
t.Fatalf("request %d: %s listed in SignedHeaders=%v", i+1, name, signed)
}
}
if h.Get(name) == "" {
t.Fatalf("request %d: missing %s", i+1, name)
}
}
}
}

func signedHeadersList(authorization string) []string {
const marker = "SignedHeaders="
idx := strings.Index(authorization, marker)
if idx < 0 {
return nil
}
rest := authorization[idx+len(marker):]
if end := strings.Index(rest, ","); end >= 0 {
rest = rest[:end]
}
parts := strings.Split(rest, ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(strings.ToLower(p))
if p != "" {
out = append(out, p)
}
}
return out
}
18 changes: 18 additions & 0 deletions internal/providers/codearts/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,21 @@ func hmacHex(key, msg string) string {
_, _ = mac.Write([]byte(msg))
return hex.EncodeToString(mac.Sum(nil))
}

// otSessionID returns the account-stable OpenThrottle session id used by the
// Huawei gateway to count concurrent sessions. Same seed as WorkBuddy tip
// 28527f4 / hub #723: sha256("codearts-ot-session:"+userID) hex, first 32.
func otSessionID(userID string) string {
sum := sha256.Sum256([]byte("codearts-ot-session:" + userID))
return hex.EncodeToString(sum[:16])
}

// applyOTSessionHeaders sets x-ot-session-id / user-session-id (account-stable)
// and a fresh x-ot-trace-id. Call only AFTER signRequest so these names never
// enter SignedHeaders (gateway rejects a signature that lists them).
func applyOTSessionHeaders(header http.Header, userID string) {
session := otSessionID(userID)
header.Set("x-ot-session-id", session)
header.Set("user-session-id", session)
header.Set("x-ot-trace-id", randomHex(16))
}
Loading