Add experimental Lingma remote backend

This commit is contained in:
lutc5
2026-04-30 12:09:51 +08:00
parent 1c188fcf17
commit 2bcb0a6715
15 changed files with 1543 additions and 37 deletions

464
internal/remote/client.go Normal file
View File

@@ -0,0 +1,464 @@
package remote
import (
"bufio"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
DefaultBaseURL = "https://lingma.alibabacloud.com"
chatPath = "/algo/api/v2/service/pro/sse/agent_chat_generation"
chatQuery = "?FetchKeys=llm_model_result&AgentId=agent_common"
modelListPath = "/algo/api/v2/model/list"
)
type Config struct {
BaseURL string
AuthFile string
CosyVersion string
Timeout time.Duration
}
type Client struct {
cfg Config
client *http.Client
}
type Model struct {
Key string `json:"key"`
DisplayName string `json:"display_name"`
Model string `json:"model"`
Enable bool `json:"enable"`
}
type ChatRequest struct {
Model string
Prompt string
Stream bool
Temperature *float64
}
type ChatResult struct {
Text string
InputTokens int
OutputTokens int
RequestID string
CredentialSrc string
}
type StreamEvent struct {
Delta string
}
func New(cfg Config) *Client {
if cfg.BaseURL == "" {
cfg.BaseURL = ResolveBaseURL("")
}
if cfg.CosyVersion == "" {
cfg.CosyVersion = "2.11.2"
}
if cfg.Timeout <= 0 {
cfg.Timeout = 120 * time.Second
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}}
}
func ResolveBaseURL(explicit string) string {
if strings.TrimSpace(explicit) != "" {
return strings.TrimRight(strings.TrimSpace(explicit), "/")
}
if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_BASE_URL")); value != "" {
return strings.TrimRight(value, "/")
}
for _, path := range candidateConfigFiles() {
if value := readBaseURLHint(path); value != "" {
return strings.TrimRight(value, "/")
}
}
return DefaultBaseURL
}
func (c *Client) Warmup(ctx context.Context) error {
_, err := LoadCredential(c.cfg.AuthFile)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
_, err = c.ListModels(ctx)
return err
}
func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
cred, err := LoadCredential(c.cfg.AuthFile)
if err != nil {
return nil, err
}
headers, err := c.headers(cred, modelListPath, "")
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.BaseURL+modelListPath, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("remote model list status %d: %s", resp.StatusCode, truncate(string(body), 500))
}
var payload struct {
Chat []Model `json:"chat"`
Inline []Model `json:"inline"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
return append(payload.Chat, payload.Inline...), nil
}
func (c *Client) Chat(ctx context.Context, request ChatRequest, onDelta func(string)) (*ChatResult, error) {
cred, err := LoadCredential(c.cfg.AuthFile)
if err != nil {
return nil, err
}
requestID := newHexID()
body, err := c.buildBody(requestID, request)
if err != nil {
return nil, err
}
headers, err := c.headers(cred, chatPath, body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.BaseURL+chatPath+chatQuery, strings.NewReader(body))
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote chat status %d: %s", resp.StatusCode, truncate(string(respBody), 1000))
}
var builder strings.Builder
if err := scanSSE(resp.Body, func(event sseEvent) error {
if event.Done {
return nil
}
if event.Content == "" {
return nil
}
builder.WriteString(event.Content)
if onDelta != nil {
onDelta(event.Content)
}
return nil
}); err != nil {
return nil, err
}
text := builder.String()
return &ChatResult{
Text: text,
InputTokens: estimateTokens(request.Prompt),
OutputTokens: estimateTokens(text),
RequestID: requestID,
CredentialSrc: cred.Source,
}, nil
}
func (c *Client) buildBody(requestID string, request ChatRequest) (string, error) {
temperature := 0.1
if request.Temperature != nil {
temperature = *request.Temperature
}
model := strings.TrimSpace(request.Model)
if strings.EqualFold(model, "auto") {
model = ""
}
payload := map[string]any{
"request_id": requestID,
"request_set_id": "",
"chat_record_id": requestID,
"stream": true,
"image_urls": nil,
"is_reply": false,
"is_retry": false,
"session_id": "",
"code_language": "",
"source": 0,
"version": "3",
"chat_prompt": "",
"parameters": map[string]float64{"temperature": temperature},
"aliyun_user_type": "personal_standard",
"agent_id": "agent_common",
"task_id": "question_refine",
"model_config": map[string]any{
"key": model,
"display_name": "",
"model": model,
"format": "",
"is_vl": false,
"is_reasoning": false,
"api_key": "",
"url": "",
"source": "",
"enable": false,
},
"messages": []map[string]any{{
"role": "user",
"content": request.Prompt,
"response_meta": map[string]any{
"id": "",
"usage": map[string]int{
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
},
"reasoning_content_signature": "",
}},
"business": map[string]any{
"product": "jb_plugin",
"version": c.cfg.CosyVersion,
"type": "memory",
"id": newUUID(),
"begin_at": time.Now().UnixMilli(),
"stage": "start",
"name": "memory_intent_recognition_" + requestID,
},
}
body, err := json.Marshal(payload)
return string(body), err
}
func (c *Client) headers(cred Credential, path string, body string) (map[string]string, error) {
if err := validateCredential(cred); err != nil {
return nil, err
}
date := strconv.FormatInt(time.Now().Unix(), 10)
authPayload := map[string]string{
"cosyVersion": c.cfg.CosyVersion,
"ideVersion": "",
"info": cred.EncryptUserInfo,
"requestId": newUUID(),
"version": "v1",
}
authPayloadBytes, err := json.Marshal(authPayload)
if err != nil {
return nil, err
}
payloadBase64 := base64.StdEncoding.EncodeToString(authPayloadBytes)
preimage := strings.Join([]string{
payloadBase64,
cred.CosyKey,
date,
body,
normalizePath(path),
}, "\n")
signature := md5.Sum([]byte(preimage))
return map[string]string{
"Authorization": fmt.Sprintf("Bearer COSY.%s.%x", payloadBase64, signature),
"Content-Type": "application/json",
"Appcode": "cosy",
"Cosy-Date": date,
"Cosy-Key": cred.CosyKey,
"Cosy-Machineid": cred.MachineID,
"Cosy-User": cred.UserID,
"Cosy-Clientip": "198.18.0.1",
"Cosy-Clienttype": "2",
"Cosy-Machineos": "x86_64_windows",
"Cosy-Machinetoken": "",
"Cosy-Machinetype": "",
"Cosy-Version": c.cfg.CosyVersion,
"Login-Version": "v2",
"User-Agent": "lingma-ipc-proxy/remote",
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
}, nil
}
func normalizePath(path string) string {
return strings.TrimPrefix(path, "/algo")
}
type outerSSE struct {
Body string `json:"body"`
StatusCode int `json:"statusCodeValue"`
}
type innerSSE struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
type sseEvent struct {
Content string
Done bool
}
func scanSSE(reader io.Reader, onEvent func(sseEvent) error) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
return onEvent(sseEvent{Done: true})
}
event, ok, err := parseSSEPayload(payload)
if err != nil {
return err
}
if !ok {
continue
}
if err := onEvent(event); err != nil {
return err
}
}
return scanner.Err()
}
func parseSSEPayload(payload string) (sseEvent, bool, error) {
var outer outerSSE
if err := json.Unmarshal([]byte(payload), &outer); err != nil {
return sseEvent{}, false, err
}
if outer.StatusCode >= 400 {
return sseEvent{}, false, fmt.Errorf("remote sse status %d", outer.StatusCode)
}
if outer.Body == "" {
return sseEvent{}, false, nil
}
if outer.Body == "[DONE]" {
return sseEvent{Done: true}, true, nil
}
var inner innerSSE
if err := json.Unmarshal([]byte(outer.Body), &inner); err != nil {
return sseEvent{}, false, err
}
var builder strings.Builder
for _, choice := range inner.Choices {
builder.WriteString(choice.Delta.Content)
}
return sseEvent{Content: builder.String()}, true, nil
}
func candidateConfigFiles() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
return []string{
filepath.Join(home, ".lingma", "extension", "server", "config.json"),
filepath.Join(home, ".lingma", "extension", "local", "config.json"),
filepath.Join(home, ".lingma", "bin", "config.json"),
filepath.Join(home, ".config", "lingma-ipc-proxy", "config.json"),
}
}
func readBaseURLHint(path string) string {
body, err := os.ReadFile(path)
if err != nil {
return ""
}
var value any
if err := json.Unmarshal(body, &value); err != nil {
text := string(body)
if strings.Contains(text, "lingma.alibabacloud.com") {
return DefaultBaseURL
}
return ""
}
return findBaseURL(value)
}
func findBaseURL(value any) string {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
lower := strings.ToLower(key)
if strings.Contains(lower, "base") || strings.Contains(lower, "domain") || strings.Contains(lower, "url") {
if text, ok := item.(string); ok && strings.HasPrefix(strings.TrimSpace(text), "http") && strings.Contains(text, "lingma") {
return strings.TrimSpace(text)
}
}
if nested := findBaseURL(item); nested != "" {
return nested
}
}
case []any:
for _, item := range typed {
if nested := findBaseURL(item); nested != "" {
return nested
}
}
}
return ""
}
func estimateTokens(text string) int {
text = strings.TrimSpace(text)
if text == "" {
return 0
}
return len([]rune(text)) / 4
}
func truncate(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max] + "... [truncated]"
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return path
}
func valueOr(value string, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
var hexCounter uint64

View File

@@ -0,0 +1,205 @@
package remote
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Credential struct {
CosyKey string
EncryptUserInfo string
UserID string
MachineID string
Source string
TokenExpireTime int64
}
type storedCredentialFile struct {
Source string `json:"source"`
TokenExpireTime string `json:"token_expire_time"`
Auth struct {
CosyKey string `json:"cosy_key"`
EncryptUserInfo string `json:"encrypt_user_info"`
UserID string `json:"user_id"`
MachineID string `json:"machine_id"`
} `json:"auth"`
}
func LoadCredential(authFile string) (Credential, error) {
if path := strings.TrimSpace(authFile); path != "" {
return loadCredentialFile(expandHome(path))
}
return importLingmaCacheCredential()
}
func loadCredentialFile(path string) (Credential, error) {
body, err := os.ReadFile(path)
if err != nil {
return Credential{}, fmt.Errorf("read remote auth file: %w", err)
}
var stored storedCredentialFile
if err := json.Unmarshal(body, &stored); err != nil {
return Credential{}, fmt.Errorf("parse remote auth file: %w", err)
}
cred := Credential{
CosyKey: stored.Auth.CosyKey,
EncryptUserInfo: stored.Auth.EncryptUserInfo,
UserID: stored.Auth.UserID,
MachineID: stored.Auth.MachineID,
Source: valueOr(stored.Source, path),
TokenExpireTime: parseExpire(stored.TokenExpireTime),
}
return cred, validateCredential(cred)
}
func importLingmaCacheCredential() (Credential, error) {
home, err := os.UserHomeDir()
if err != nil {
return Credential{}, err
}
lingmaDir := filepath.Join(home, ".lingma")
machineID, err := loadMachineID(lingmaDir)
if err != nil {
return Credential{}, err
}
encrypted, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "user"))
if err != nil {
return Credential{}, fmt.Errorf("read ~/.lingma/cache/user: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encrypted)))
if err != nil {
return Credential{}, fmt.Errorf("decode ~/.lingma/cache/user: %w", err)
}
plaintext, err := decryptCacheUser(machineID, ciphertext)
if err != nil {
return Credential{}, err
}
var payload struct {
Key string `json:"key"`
EncryptUserInfo string `json:"encrypt_user_info"`
UserID string `json:"uid"`
ExpireTime any `json:"expire_time"`
}
if err := json.Unmarshal(plaintext, &payload); err != nil {
return Credential{}, fmt.Errorf("parse ~/.lingma/cache/user: %w", err)
}
cred := Credential{
CosyKey: payload.Key,
EncryptUserInfo: payload.EncryptUserInfo,
UserID: payload.UserID,
MachineID: machineID,
Source: "~/.lingma/cache/user",
TokenExpireTime: parseExpireAny(payload.ExpireTime),
}
return cred, validateCredential(cred)
}
func loadMachineID(lingmaDir string) (string, error) {
if body, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "id")); err == nil {
if value := strings.TrimSpace(string(body)); value != "" {
return value, nil
}
}
logBody, err := os.ReadFile(filepath.Join(lingmaDir, "logs", "lingma.log"))
if err != nil {
return "", fmt.Errorf("remote credential requires ~/.lingma/cache/id or lingma.log machine id: %w", err)
}
markers := []string{"using machine id from file:", "machine id:"}
text := string(logBody)
for _, marker := range markers {
index := strings.LastIndex(strings.ToLower(text), marker)
if index < 0 {
continue
}
line := text[index+len(marker):]
if newline := strings.IndexByte(line, '\n'); newline >= 0 {
line = line[:newline]
}
if value := strings.TrimSpace(line); value != "" {
return value, nil
}
}
return "", errors.New("machine id not found in ~/.lingma cache")
}
func decryptCacheUser(machineID string, ciphertext []byte) ([]byte, error) {
if len(machineID) < aes.BlockSize {
return nil, errors.New("machine id too short for cache decryption")
}
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return nil, errors.New("invalid cache/user ciphertext size")
}
key := []byte(machineID[:aes.BlockSize])
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, key).CryptBlocks(plaintext, ciphertext)
return unpadPKCS7(plaintext)
}
func unpadPKCS7(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("empty plaintext")
}
padLen := int(data[len(data)-1])
if padLen <= 0 || padLen > aes.BlockSize || padLen > len(data) {
return nil, errors.New("invalid cache/user padding")
}
for _, b := range data[len(data)-padLen:] {
if int(b) != padLen {
return nil, errors.New("invalid cache/user padding bytes")
}
}
return data[:len(data)-padLen], nil
}
func validateCredential(cred Credential) error {
if strings.TrimSpace(cred.CosyKey) == "" {
return errors.New("remote credential missing cosy_key")
}
if strings.TrimSpace(cred.EncryptUserInfo) == "" {
return errors.New("remote credential missing encrypt_user_info")
}
if strings.TrimSpace(cred.UserID) == "" {
return errors.New("remote credential missing user_id")
}
if strings.TrimSpace(cred.MachineID) == "" {
return errors.New("remote credential missing machine_id")
}
return nil
}
func parseExpire(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}
func parseExpireAny(value any) int64 {
switch typed := value.(type) {
case string:
return parseExpire(typed)
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
default:
return 0
}
}
func IsExpired(cred Credential, margin time.Duration) bool {
return cred.TokenExpireTime > 0 && time.Now().Add(margin).UnixMilli() > cred.TokenExpireTime
}

28
internal/remote/id.go Normal file
View File

@@ -0,0 +1,28 @@
package remote
import (
"crypto/rand"
"encoding/hex"
"fmt"
"sync/atomic"
"time"
)
func newUUID() string {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
data[6] = (data[6] & 0x0f) | 0x40
data[8] = (data[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", data[0:4], data[4:6], data[6:8], data[8:10], data[10:16])
}
func newHexID() string {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
seq := atomic.AddUint64(&hexCounter, 1)
return fmt.Sprintf("fallback%x%x", time.Now().UnixNano(), seq)
}
return hex.EncodeToString(data[:])
}