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
140 changes: 97 additions & 43 deletions internal/auth/codearts/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,46 +22,18 @@ func SignRequest(req *http.Request, body []byte, ak, sk, securityToken string) {
req.Header.Set("X-Security-Token", securityToken)
}

method := req.Method
path := req.URL.Path
if path == "" {
path = "/"
}
if !strings.HasSuffix(path, "/") {
path += "/"
}

canonicalQuery := canonicalQueryString(req.URL.Query())

lowerMap := make(map[string]string)
for k, v := range req.Header {
if len(v) > 0 {
lowerMap[strings.ToLower(k)] = v[0]
}
}

signedHeaderKeys := make([]string, 0, len(lowerMap))
for k := range lowerMap {
signedHeaderKeys = append(signedHeaderKeys, k)
}
sort.Strings(signedHeaderKeys)

var canonicalHeaders strings.Builder
for _, key := range signedHeaderKeys {
val := lowerMap[key]
canonicalHeaders.WriteString(key)
canonicalHeaders.WriteString(":")
canonicalHeaders.WriteString(strings.TrimSpace(val))
canonicalHeaders.WriteString("\n")
}
signedHeaderKeys := extractSignedHeaders(req.Header)

canonicalURI := buildCanonicalURI(req.URL.Path)
canonicalQuery := buildCanonicalQueryString(req.URL.Query())
canonicalHdrs := buildCanonicalHeaders(req, signedHeaderKeys)
signedHeadersStr := strings.Join(signedHeaderKeys, ";")

bodyHash := sha256Hex(body)

canonicalReq := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
method, path, canonicalQuery,
canonicalHeaders.String(), signedHeadersStr, bodyHash)
req.Method, canonicalURI, canonicalQuery,
canonicalHdrs, signedHeadersStr, bodyHash)

stringToSign := fmt.Sprintf("SDK-HMAC-SHA256\n%s\n%s",
timeStr, sha256Hex([]byte(canonicalReq)))
Expand All @@ -73,18 +45,33 @@ func SignRequest(req *http.Request, body []byte, ak, sk, securityToken string) {
req.Header.Set("Authorization", authHeader)
}

func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
func extractSignedHeaders(headers http.Header) []string {
var sh []string
for key := range headers {
lower := strings.ToLower(key)
if strings.HasPrefix(lower, "content-type") || strings.Contains(lower, "_") {
continue
}
sh = append(sh, lower)
}
sort.Strings(sh)
return sh
}

func hmacSHA256Hex(key, data []byte) string {
h := hmac.New(sha256.New, key)
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
func buildCanonicalURI(rawPath string) string {
parts := strings.Split(rawPath, "/")
var encoded []string
for _, p := range parts {
encoded = append(encoded, sdkEscape(p))
}
path := strings.Join(encoded, "/")
if len(path) == 0 || path[len(path)-1] != '/' {
path = path + "/"
}
return path
}

func canonicalQueryString(query url.Values) string {
func buildCanonicalQueryString(query url.Values) string {
if len(query) == 0 {
return ""
}
Expand All @@ -98,8 +85,75 @@ func canonicalQueryString(query url.Values) string {
vals := query[k]
sort.Strings(vals)
for _, v := range vals {
parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(v))
parts = append(parts, sdkEscape(k)+"="+sdkEscape(v))
}
}
return strings.Join(parts, "&")
}

func buildCanonicalHeaders(req *http.Request, signedHeaderKeys []string) string {
headerMap := make(map[string][]string)
for k, v := range req.Header {
lower := strings.ToLower(k)
if _, ok := headerMap[lower]; !ok {
headerMap[lower] = make([]string, 0)
}
headerMap[lower] = append(headerMap[lower], v...)
}

var lines []string
for _, key := range signedHeaderKeys {
values := headerMap[key]
if key == "host" {
values = []string{req.URL.Host}
}
sort.Strings(values)
for _, v := range values {
lines = append(lines, key+":"+strings.TrimSpace(v))
}
}
return fmt.Sprintf("%s\n", strings.Join(lines, "\n"))
}

func sdkEscape(s string) string {
hexCount := 0
for i := 0; i < len(s); i++ {
c := s[i]
if shouldEscape(c) {
hexCount++
}
}
if hexCount == 0 {
return s
}
t := make([]byte, len(s)+2*hexCount)
j := 0
for i := 0; i < len(s); i++ {
c := s[i]
if shouldEscape(c) {
t[j] = '%'
t[j+1] = "0123456789ABCDEF"[c>>4]
t[j+2] = "0123456789ABCDEF"[c&15]
j += 3
} else {
t[j] = s[i]
j++
}
}
return string(t)
}

func shouldEscape(c byte) bool {
return !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '~' || c == '.')
}

func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}

func hmacSHA256Hex(key, data []byte) string {
h := hmac.New(sha256.New, key)
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
67 changes: 57 additions & 10 deletions internal/runtime/executor/codearts_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ func (e *CodeArtsExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.
req.Header.Set("X-Snap-Traceid", traceID)

codearts.SignRequest(req, bodyBytes, ak, sk, securityToken)

log.Debugf("codearts: signing request url=%s, body_len=%d, ak=%s, headers=%v",
req.URL.String(), len(bodyBytes), ak[:min(4, len(ak))]+"...", req.Header)
return nil
}

Expand Down Expand Up @@ -130,6 +133,8 @@ func (e *CodeArtsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
}
defer httpResp.Body.Close()

log.Debugf("codearts: Execute response status=%d, content_type=%s", httpResp.StatusCode, httpResp.Header.Get("Content-Type"))

if httpResp.StatusCode != 200 {
body, _ := io.ReadAll(httpResp.Body)
return resp, statusErr{
Expand Down Expand Up @@ -228,12 +233,16 @@ func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
if httpResp.StatusCode != 200 {
body, _ := io.ReadAll(httpResp.Body)
httpResp.Body.Close()
log.Debugf("codearts: non-200 response status=%d, body=%s", httpResp.StatusCode, string(body))
return nil, statusErr{
code: httpResp.StatusCode,
msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)),
}
}

log.Debugf("codearts: stream response status=%d, content_type=%s, content_length=%d",
httpResp.StatusCode, httpResp.Header.Get("Content-Type"), httpResp.ContentLength)

chunks := make(chan cliproxyexecutor.StreamChunk, 64)

go func() {
Expand All @@ -244,26 +253,34 @@ func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
to := sdktranslator.FromString("codearts")
var streamParam any
var totalPromptTokens, totalCompletionTokens int64
var lineCount int
var dataLineCount int
var firstNonEmptyLine string

scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
lineCount++
if strings.HasPrefix(line, ":heartbeat") || line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
if firstNonEmptyLine == "" {
firstNonEmptyLine = line
}
var data string
if strings.HasPrefix(line, "data: ") {
data = strings.TrimPrefix(line, "data: ")
} else if strings.HasPrefix(line, "data:") {
data = strings.TrimPrefix(line, "data:")
} else {
log.Debugf("codearts: unexpected SSE line %d: %q", lineCount, line)
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
if data == "[DONE]" || (gjson.Get(data, "text").String() == "[DONE]") {
break
}

openAIChunk := convertCodeArtsSSEToOpenAI(data, req.Model)
if openAIChunk == nil {
continue
}
dataLineCount++

if pt := gjson.Get(data, "prompt_tokens").Int(); pt > 0 {
totalPromptTokens = pt
Expand All @@ -272,6 +289,11 @@ func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
totalCompletionTokens = ct
}

openAIChunk := convertCodeArtsSSEToOpenAI(data, req.Model)
if openAIChunk == nil {
continue
}

translatedChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, req.Payload, openAIChunk, &streamParam)
for _, tc := range translatedChunks {
if len(tc) > 0 {
Expand All @@ -280,6 +302,10 @@ func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
}

if dataLineCount == 0 {
log.Warnf("codearts: stream ended with no data lines (total_lines=%d, first_non_empty=%q)", lineCount, firstNonEmptyLine)
}

if err := scanner.Err(); err != nil {
log.Warnf("codearts: stream scanner error: %v", err)
chunks <- cliproxyexecutor.StreamChunk{Err: err}
Expand Down Expand Up @@ -516,7 +542,28 @@ func convertCodeArtsSSEToOpenAI(data string, model string) []byte {
reasoningContent := delta.Get("reasoning_content").String()

if content == "" && reasoningContent == "" {
return nil
role := delta.Get("role").String()
if role == "" {
return nil
}
deltaMap := map[string]interface{}{"role": role}
chunk := map[string]interface{}{
"id": "chatcmpl-codearts",
"object": "chat.completion.chunk",
"created": time.Now().Unix(),
"model": model,
"choices": []map[string]interface{}{
{
"index": 0,
"delta": deltaMap,
},
},
}
result, err := json.Marshal(chunk)
if err != nil {
return nil
}
return result
}

deltaMap := make(map[string]interface{})
Expand Down Expand Up @@ -545,7 +592,7 @@ func convertCodeArtsSSEToOpenAI(data string, model string) []byte {
return nil
}

return append([]byte("data: "), result...)
return result
}

// buildOpenAINonStreamResponse builds a complete OpenAI non-stream response.
Expand Down
2 changes: 2 additions & 0 deletions sdk/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
log "github.com/sirupsen/logrus"
"golang.org/x/net/context"
)

Expand Down Expand Up @@ -797,6 +798,7 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string
baseModel := strings.TrimSpace(parsed.ModelName)

providers = util.GetProviderName(baseModel)
log.Debugf("getRequestDetails: modelName=%q, resolvedModelName=%q, baseModel=%q, providers=%v", modelName, resolvedModelName, baseModel, providers)
// Fallback: if baseModel has no provider but differs from resolvedModelName,
// try using the full model name. This handles edge cases where custom models
// may be registered with their full suffixed name (e.g., "my-model(8192)").
Expand Down
Loading