Add Docker Compose Lingma bootstrap support
Package the proxy for Docker Compose deployments and add Lingma bootstrap, session restore, and runtime status support for containerized remote usage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
LINGMA_PROXY_PORT=8095
|
||||||
|
LINGMA_REMOTE_BASE_URL=https://lingma.alibabacloud.com
|
||||||
|
LINGMA_REMOTE_AUTH_FILE=/secrets/credentials.json
|
||||||
|
|
||||||
|
LINGMA_BOOTSTRAP_ENABLED=true
|
||||||
|
LINGMA_SOURCE_TYPE=marketplace
|
||||||
|
LINGMA_VSIX_URL=
|
||||||
|
LINGMA_MARKETPLACE_PUBLISHER=Alibaba-Cloud
|
||||||
|
LINGMA_MARKETPLACE_EXTENSION=tongyi-lingma
|
||||||
|
LINGMA_BIN=/app/data/bin/Lingma
|
||||||
|
LINGMA_BOOTSTRAP_OUTPUT_DIR=/app/data/bin/release
|
||||||
|
LINGMA_BOOTSTRAP_ALWAYS=true
|
||||||
|
LINGMA_FORCE_REFRESH=false
|
||||||
|
|
||||||
|
LINGMA_WORK_DIR=/app/data/.lingma/vscode/sharedClientCache
|
||||||
|
LINGMA_SESSION_BUNDLE=
|
||||||
|
LINGMA_SESSION_BUNDLE_FILE=/secrets/session.bundle
|
||||||
|
|
||||||
|
LINGMA_PROXY_BACKEND=remote
|
||||||
|
LINGMA_PROXY_SESSION_MODE=auto
|
||||||
|
LINGMA_PROXY_MODEL=kmodel
|
||||||
|
LINGMA_PROXY_TIMEOUT_SECONDS=0
|
||||||
|
LINGMA_REMOTE_FALLBACK_ENABLED=true
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM golang:1.23.6 AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/lingma-proxy ./cmd/lingma-ipc-proxy
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/lingma-proxy /usr/local/bin/lingma-proxy
|
||||||
|
EXPOSE 8095
|
||||||
|
CMD ["lingma-proxy", "--host", "0.0.0.0", "--port", "8095", "--backend", "remote"]
|
||||||
@@ -40,6 +40,18 @@ type fileConfig struct {
|
|||||||
TimeoutSeconds int `json:"timeout"`
|
TimeoutSeconds int `json:"timeout"`
|
||||||
RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"`
|
RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"`
|
||||||
RemoteFallbackModels []string `json:"remote_fallback_models"`
|
RemoteFallbackModels []string `json:"remote_fallback_models"`
|
||||||
|
LingmaBootstrapEnabled *bool `json:"lingma_bootstrap_enabled"`
|
||||||
|
LingmaSourceType string `json:"lingma_source_type"`
|
||||||
|
LingmaVSIXURL string `json:"lingma_vsix_url"`
|
||||||
|
LingmaMarketplacePublisher string `json:"lingma_marketplace_publisher"`
|
||||||
|
LingmaMarketplaceExtension string `json:"lingma_marketplace_extension"`
|
||||||
|
LingmaBootstrapOutputDir string `json:"lingma_bootstrap_output_dir"`
|
||||||
|
LingmaBinaryPath string `json:"lingma_binary_path"`
|
||||||
|
LingmaBootstrapAlways *bool `json:"lingma_bootstrap_always"`
|
||||||
|
LingmaForceRefresh *bool `json:"lingma_force_refresh"`
|
||||||
|
LingmaWorkDir string `json:"lingma_work_dir"`
|
||||||
|
LingmaSessionBundle string `json:"lingma_session_bundle"`
|
||||||
|
LingmaSessionBundleFile string `json:"lingma_session_bundle_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -47,6 +59,9 @@ func main() {
|
|||||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
|
||||||
svc := service.New(cfg)
|
svc := service.New(cfg)
|
||||||
|
if err := svc.PrepareRuntime(); err != nil {
|
||||||
|
log.Fatalf("prepare runtime: %v", err)
|
||||||
|
}
|
||||||
warmupCtx, warmupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
warmupCtx, warmupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
if err := svc.Warmup(warmupCtx); err != nil {
|
if err := svc.Warmup(warmupCtx); err != nil {
|
||||||
log.Printf("warmup failed: %v", err)
|
log.Printf("warmup failed: %v", err)
|
||||||
@@ -103,6 +118,10 @@ func loadConfig() (service.Config, string) {
|
|||||||
Timeout: 0,
|
Timeout: 0,
|
||||||
RemoteFallbackEnabled: true,
|
RemoteFallbackEnabled: true,
|
||||||
RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
|
RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
|
||||||
|
LingmaBootstrapEnabled: false,
|
||||||
|
LingmaSourceType: "marketplace",
|
||||||
|
LingmaBootstrapAlways: true,
|
||||||
|
LingmaForceRefresh: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath, configLoaded := resolveConfigPath()
|
configPath, configLoaded := resolveConfigPath()
|
||||||
@@ -134,6 +153,18 @@ func loadConfig() (service.Config, string) {
|
|||||||
remoteFallbackEnabled := flag.Bool("remote-fallback", cfg.RemoteFallbackEnabled, "Enable remote timeout/5xx fallback to the next available model")
|
remoteFallbackEnabled := flag.Bool("remote-fallback", cfg.RemoteFallbackEnabled, "Enable remote timeout/5xx fallback to the next available model")
|
||||||
remoteFallbackModels := flag.String("remote-fallback-models", strings.Join(cfg.RemoteFallbackModels, ","), "Comma-separated remote fallback model IDs")
|
remoteFallbackModels := flag.String("remote-fallback-models", strings.Join(cfg.RemoteFallbackModels, ","), "Comma-separated remote fallback model IDs")
|
||||||
sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse")
|
sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse")
|
||||||
|
lingmaBootstrap := flag.Bool("lingma-bootstrap", cfg.LingmaBootstrapEnabled, "Download/extract Lingma runtime assets before startup")
|
||||||
|
lingmaSourceType := flag.String("lingma-source-type", cfg.LingmaSourceType, "Lingma bootstrap source: marketplace or vsix")
|
||||||
|
lingmaVSIXURL := flag.String("lingma-vsix-url", cfg.LingmaVSIXURL, "Lingma VSIX URL used when bootstrap source is vsix or marketplace fallback")
|
||||||
|
lingmaMarketplacePublisher := flag.String("lingma-marketplace-publisher", cfg.LingmaMarketplacePublisher, "VS Code marketplace publisher for Lingma bootstrap")
|
||||||
|
lingmaMarketplaceExtension := flag.String("lingma-marketplace-extension", cfg.LingmaMarketplaceExtension, "VS Code marketplace extension name for Lingma bootstrap")
|
||||||
|
lingmaBootstrapOutputDir := flag.String("lingma-bootstrap-output-dir", cfg.LingmaBootstrapOutputDir, "Lingma bootstrap release output directory")
|
||||||
|
lingmaBinaryPath := flag.String("lingma-binary-path", cfg.LingmaBinaryPath, "Lingma binary output path")
|
||||||
|
lingmaBootstrapAlways := flag.Bool("lingma-bootstrap-always", cfg.LingmaBootstrapAlways, "Re-check bootstrap source at startup")
|
||||||
|
lingmaForceRefresh := flag.Bool("lingma-force-refresh", cfg.LingmaForceRefresh, "Force refresh Lingma bootstrap assets")
|
||||||
|
lingmaWorkDir := flag.String("lingma-work-dir", cfg.LingmaWorkDir, "Lingma work/cache directory used for restored session bundles")
|
||||||
|
lingmaSessionBundle := flag.String("lingma-session-bundle", cfg.LingmaSessionBundle, "Base64 tar.gz Lingma session bundle to restore before startup")
|
||||||
|
lingmaSessionBundleFile := flag.String("lingma-session-bundle-file", cfg.LingmaSessionBundleFile, "File containing a base64 tar.gz Lingma session bundle")
|
||||||
config := flag.String("config", valueOr(configPath, filepath.Join(currentDir(), "lingma-proxy.json")), "Path to JSON config file")
|
config := flag.String("config", valueOr(configPath, filepath.Join(currentDir(), "lingma-proxy.json")), "Path to JSON config file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -159,6 +190,18 @@ func loadConfig() (service.Config, string) {
|
|||||||
cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second
|
cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second
|
||||||
cfg.RemoteFallbackEnabled = *remoteFallbackEnabled
|
cfg.RemoteFallbackEnabled = *remoteFallbackEnabled
|
||||||
cfg.RemoteFallbackModels = splitCSV(*remoteFallbackModels)
|
cfg.RemoteFallbackModels = splitCSV(*remoteFallbackModels)
|
||||||
|
cfg.LingmaBootstrapEnabled = *lingmaBootstrap
|
||||||
|
cfg.LingmaSourceType = strings.TrimSpace(*lingmaSourceType)
|
||||||
|
cfg.LingmaVSIXURL = strings.TrimSpace(*lingmaVSIXURL)
|
||||||
|
cfg.LingmaMarketplacePublisher = strings.TrimSpace(*lingmaMarketplacePublisher)
|
||||||
|
cfg.LingmaMarketplaceExtension = strings.TrimSpace(*lingmaMarketplaceExtension)
|
||||||
|
cfg.LingmaBootstrapOutputDir = strings.TrimSpace(*lingmaBootstrapOutputDir)
|
||||||
|
cfg.LingmaBinaryPath = strings.TrimSpace(*lingmaBinaryPath)
|
||||||
|
cfg.LingmaBootstrapAlways = *lingmaBootstrapAlways
|
||||||
|
cfg.LingmaForceRefresh = *lingmaForceRefresh
|
||||||
|
cfg.LingmaWorkDir = strings.TrimSpace(*lingmaWorkDir)
|
||||||
|
cfg.LingmaSessionBundle = strings.TrimSpace(*lingmaSessionBundle)
|
||||||
|
cfg.LingmaSessionBundleFile = strings.TrimSpace(*lingmaSessionBundleFile)
|
||||||
|
|
||||||
if configLoaded {
|
if configLoaded {
|
||||||
configPath = finalConfigPath
|
configPath = finalConfigPath
|
||||||
@@ -252,6 +295,42 @@ func overlayFileConfig(dst *service.Config, src fileConfig) {
|
|||||||
if len(src.RemoteFallbackModels) > 0 {
|
if len(src.RemoteFallbackModels) > 0 {
|
||||||
dst.RemoteFallbackModels = cleanStringSlice(src.RemoteFallbackModels)
|
dst.RemoteFallbackModels = cleanStringSlice(src.RemoteFallbackModels)
|
||||||
}
|
}
|
||||||
|
if src.LingmaBootstrapEnabled != nil {
|
||||||
|
dst.LingmaBootstrapEnabled = *src.LingmaBootstrapEnabled
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaSourceType) != "" {
|
||||||
|
dst.LingmaSourceType = strings.TrimSpace(src.LingmaSourceType)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaVSIXURL) != "" {
|
||||||
|
dst.LingmaVSIXURL = strings.TrimSpace(src.LingmaVSIXURL)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaMarketplacePublisher) != "" {
|
||||||
|
dst.LingmaMarketplacePublisher = strings.TrimSpace(src.LingmaMarketplacePublisher)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaMarketplaceExtension) != "" {
|
||||||
|
dst.LingmaMarketplaceExtension = strings.TrimSpace(src.LingmaMarketplaceExtension)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaBootstrapOutputDir) != "" {
|
||||||
|
dst.LingmaBootstrapOutputDir = strings.TrimSpace(src.LingmaBootstrapOutputDir)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaBinaryPath) != "" {
|
||||||
|
dst.LingmaBinaryPath = strings.TrimSpace(src.LingmaBinaryPath)
|
||||||
|
}
|
||||||
|
if src.LingmaBootstrapAlways != nil {
|
||||||
|
dst.LingmaBootstrapAlways = *src.LingmaBootstrapAlways
|
||||||
|
}
|
||||||
|
if src.LingmaForceRefresh != nil {
|
||||||
|
dst.LingmaForceRefresh = *src.LingmaForceRefresh
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaWorkDir) != "" {
|
||||||
|
dst.LingmaWorkDir = strings.TrimSpace(src.LingmaWorkDir)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaSessionBundle) != "" {
|
||||||
|
dst.LingmaSessionBundle = strings.TrimSpace(src.LingmaSessionBundle)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.LingmaSessionBundleFile) != "" {
|
||||||
|
dst.LingmaSessionBundleFile = strings.TrimSpace(src.LingmaSessionBundleFile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func overlayEnvConfig(dst *service.Config) {
|
func overlayEnvConfig(dst *service.Config) {
|
||||||
@@ -309,6 +388,42 @@ func overlayEnvConfig(dst *service.Config) {
|
|||||||
if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_FALLBACK_MODELS")); value != "" {
|
if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_FALLBACK_MODELS")); value != "" {
|
||||||
dst.RemoteFallbackModels = splitCSV(value)
|
dst.RemoteFallbackModels = splitCSV(value)
|
||||||
}
|
}
|
||||||
|
if value, ok := envBool("LINGMA_BOOTSTRAP_ENABLED"); ok {
|
||||||
|
dst.LingmaBootstrapEnabled = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_SOURCE_TYPE")); value != "" {
|
||||||
|
dst.LingmaSourceType = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_VSIX_URL")); value != "" {
|
||||||
|
dst.LingmaVSIXURL = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_MARKETPLACE_PUBLISHER")); value != "" {
|
||||||
|
dst.LingmaMarketplacePublisher = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_MARKETPLACE_EXTENSION")); value != "" {
|
||||||
|
dst.LingmaMarketplaceExtension = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_BOOTSTRAP_OUTPUT_DIR")); value != "" {
|
||||||
|
dst.LingmaBootstrapOutputDir = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_BIN")); value != "" {
|
||||||
|
dst.LingmaBinaryPath = value
|
||||||
|
}
|
||||||
|
if value, ok := envBool("LINGMA_BOOTSTRAP_ALWAYS"); ok {
|
||||||
|
dst.LingmaBootstrapAlways = value
|
||||||
|
}
|
||||||
|
if value, ok := envBool("LINGMA_FORCE_REFRESH"); ok {
|
||||||
|
dst.LingmaForceRefresh = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_WORK_DIR")); value != "" {
|
||||||
|
dst.LingmaWorkDir = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_SESSION_BUNDLE")); value != "" {
|
||||||
|
dst.LingmaSessionBundle = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_SESSION_BUNDLE_FILE")); value != "" {
|
||||||
|
dst.LingmaSessionBundleFile = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSessionMode(value string) service.SessionMode {
|
func parseSessionMode(value string) service.SessionMode {
|
||||||
|
|||||||
@@ -20,5 +20,18 @@
|
|||||||
"shell_type": "powershell",
|
"shell_type": "powershell",
|
||||||
"current_file_path": "",
|
"current_file_path": "",
|
||||||
"pipe": "",
|
"pipe": "",
|
||||||
"websocket_url": ""
|
"websocket_url": "",
|
||||||
|
"remote_auth_file": "/secrets/credentials.json",
|
||||||
|
"lingma_bootstrap_enabled": true,
|
||||||
|
"lingma_source_type": "marketplace",
|
||||||
|
"lingma_vsix_url": "",
|
||||||
|
"lingma_marketplace_publisher": "Alibaba-Cloud",
|
||||||
|
"lingma_marketplace_extension": "tongyi-lingma",
|
||||||
|
"lingma_binary_path": "/app/data/bin/Lingma",
|
||||||
|
"lingma_bootstrap_output_dir": "/app/data/bin/release",
|
||||||
|
"lingma_bootstrap_always": true,
|
||||||
|
"lingma_force_refresh": false,
|
||||||
|
"lingma_work_dir": "/app/data/.lingma/vscode/sharedClientCache",
|
||||||
|
"lingma_session_bundle": "",
|
||||||
|
"lingma_session_bundle_file": "/secrets/session.bundle"
|
||||||
}
|
}
|
||||||
|
|||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
lingma-proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
container_name: lingma-proxy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${LINGMA_PROXY_PORT:-8095}:8095"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./secrets:/secrets:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"wget -q -O - http://127.0.0.1:8095/runtime/status | grep -q '\"ok\":true'"
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
476
internal/bootstrap/lingma.go
Normal file
476
internal/bootstrap/lingma.go
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool
|
||||||
|
SourceType string
|
||||||
|
VSIXURL string
|
||||||
|
MarketplacePublisher string
|
||||||
|
MarketplaceExtension string
|
||||||
|
OutputDir string
|
||||||
|
BinaryPath string
|
||||||
|
AlwaysRefresh bool
|
||||||
|
ForceRefresh bool
|
||||||
|
HTTPTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
BinaryPath string `json:"binary_path,omitempty"`
|
||||||
|
ReleaseDir string `json:"release_dir,omitempty"`
|
||||||
|
MarkerPath string `json:"marker_path,omitempty"`
|
||||||
|
Downloaded bool `json:"downloaded"`
|
||||||
|
ReusedExisting bool `json:"reused_existing"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type marker struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
DownloadedAt int64 `json:"downloaded_at"`
|
||||||
|
NestedZip string `json:"nested_zip"`
|
||||||
|
Member string `json:"member"`
|
||||||
|
ReleaseRoot string `json:"release_root"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
Extension string `json:"extension,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Ensure(cfg Config) (Result, error) {
|
||||||
|
result := Result{Enabled: cfg.Enabled}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
result.Message = "bootstrap disabled"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
cfg = normalize(cfg)
|
||||||
|
if cfg.BinaryPath == "" {
|
||||||
|
return result, fmt.Errorf("bootstrap binary path is required")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg.BinaryPath), 0o755); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedURL := strings.TrimSpace(cfg.VSIXURL)
|
||||||
|
version := ""
|
||||||
|
if cfg.SourceType == "marketplace" {
|
||||||
|
url, ver, err := queryMarketplaceLatestVSIX(cfg)
|
||||||
|
if err == nil {
|
||||||
|
resolvedURL = url
|
||||||
|
version = ver
|
||||||
|
} else if resolvedURL == "" {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolvedURL == "" {
|
||||||
|
return result, fmt.Errorf("bootstrap VSIX URL is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Source = cfg.SourceType
|
||||||
|
result.URL = resolvedURL
|
||||||
|
result.Version = version
|
||||||
|
result.BinaryPath = cfg.BinaryPath
|
||||||
|
result.MarkerPath = filepath.Join(filepath.Dir(cfg.BinaryPath), ".lingma-bootstrap.json")
|
||||||
|
|
||||||
|
oldMarker := readMarker(result.MarkerPath)
|
||||||
|
releaseDir := releaseDirForOutput(cfg.OutputDir, oldMarker.ReleaseRoot)
|
||||||
|
result.ReleaseDir = releaseDir
|
||||||
|
ready := hasRequiredAssets(releaseDir)
|
||||||
|
|
||||||
|
if fileExists(cfg.BinaryPath) && ready && !cfg.ForceRefresh {
|
||||||
|
if !cfg.AlwaysRefresh || (version != "" && oldMarker.Version == version) {
|
||||||
|
if err := os.Chmod(cfg.BinaryPath, 0o755); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.ReusedExisting = true
|
||||||
|
result.Message = "reused existing Lingma binary"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vsixBytes, err := downloadVSIX(resolvedURL, cfg.HTTPTimeout)
|
||||||
|
if err != nil {
|
||||||
|
if fileExists(cfg.BinaryPath) {
|
||||||
|
result.ReusedExisting = true
|
||||||
|
result.Message = fmt.Sprintf("download failed, reused existing Lingma: %v", err)
|
||||||
|
return result, os.Chmod(cfg.BinaryPath, 0o755)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.Downloaded = true
|
||||||
|
|
||||||
|
nestedName, nestedBytes, err := extractNestedZip(vsixBytes)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
memberName, binaryBytes, releaseRoot, releaseFiles, err := extractRelease(nestedBytes)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
releaseDir = releaseDirForOutput(cfg.OutputDir, releaseRoot)
|
||||||
|
result.ReleaseDir = releaseDir
|
||||||
|
if err := os.RemoveAll(releaseDir); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := writeReleaseFiles(releaseDir, releaseRoot, releaseFiles); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cfg.BinaryPath, binaryBytes, 0o755); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if !hasRequiredAssets(releaseDir) {
|
||||||
|
return result, fmt.Errorf("extension assets missing after extraction under %s", releaseDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
mk := marker{
|
||||||
|
Source: cfg.SourceType,
|
||||||
|
URL: resolvedURL,
|
||||||
|
Version: version,
|
||||||
|
DownloadedAt: time.Now().Unix(),
|
||||||
|
NestedZip: nestedName,
|
||||||
|
Member: memberName,
|
||||||
|
ReleaseRoot: releaseRoot,
|
||||||
|
Size: len(binaryBytes),
|
||||||
|
Publisher: cfg.MarketplacePublisher,
|
||||||
|
Extension: cfg.MarketplaceExtension,
|
||||||
|
}
|
||||||
|
if err := writeMarker(result.MarkerPath, mk); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.Message = fmt.Sprintf("downloaded Lingma %s", versionOrUnknown(version))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type marketplaceResponse struct {
|
||||||
|
Results []struct {
|
||||||
|
Extensions []struct {
|
||||||
|
Versions []struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Files []struct {
|
||||||
|
AssetType string `json:"assetType"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"files"`
|
||||||
|
} `json:"versions"`
|
||||||
|
} `json:"extensions"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zipEntry struct {
|
||||||
|
Name string
|
||||||
|
Data []byte
|
||||||
|
Mode os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(cfg Config) Config {
|
||||||
|
cfg.SourceType = strings.ToLower(strings.TrimSpace(cfg.SourceType))
|
||||||
|
if cfg.SourceType == "" {
|
||||||
|
cfg.SourceType = "marketplace"
|
||||||
|
}
|
||||||
|
if cfg.OutputDir == "" {
|
||||||
|
cfg.OutputDir = filepath.Join(filepath.Dir(cfg.BinaryPath), "release")
|
||||||
|
}
|
||||||
|
if cfg.HTTPTimeout <= 0 {
|
||||||
|
cfg.HTTPTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.MarketplacePublisher) == "" {
|
||||||
|
cfg.MarketplacePublisher = "Alibaba-Cloud"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.MarketplaceExtension) == "" {
|
||||||
|
cfg.MarketplaceExtension = "tongyi-lingma"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.VSIXURL) == "" {
|
||||||
|
cfg.VSIXURL = "https://tongyi-code.oss-cn-hangzhou.aliyuncs.com/vscode/tongyi-lingma-latest.vsix"
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryMarketplaceLatestVSIX(cfg Config) (string, string, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"filters": []any{map[string]any{
|
||||||
|
"criteria": []any{
|
||||||
|
map[string]any{"filterType": 7, "value": cfg.MarketplacePublisher + "." + cfg.MarketplaceExtension},
|
||||||
|
map[string]any{"filterType": 8, "value": "Microsoft.VisualStudio.Code"},
|
||||||
|
},
|
||||||
|
"pageNumber": 1,
|
||||||
|
"pageSize": 1,
|
||||||
|
"sortBy": 0,
|
||||||
|
"sortOrder": 0,
|
||||||
|
}},
|
||||||
|
"assetTypes": []any{},
|
||||||
|
"flags": 950,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("accept", "application/json;api-version=3.0-preview.1")
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
req.Header.Set("x-market-client-id", "VSCode 1.115.0")
|
||||||
|
client := &http.Client{Timeout: cfg.HTTPTimeout}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return "", "", fmt.Errorf("marketplace query status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var parsed marketplaceResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if len(parsed.Results) == 0 || len(parsed.Results[0].Extensions) == 0 || len(parsed.Results[0].Extensions[0].Versions) == 0 {
|
||||||
|
return "", "", fmt.Errorf("no extension found from marketplace")
|
||||||
|
}
|
||||||
|
version := parsed.Results[0].Extensions[0].Versions[0].Version
|
||||||
|
for _, file := range parsed.Results[0].Extensions[0].Versions[0].Files {
|
||||||
|
if file.AssetType == "Microsoft.VisualStudio.Services.VSIXPackage" && strings.TrimSpace(file.Source) != "" {
|
||||||
|
return file.Source, version, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if version == "" {
|
||||||
|
return "", "", fmt.Errorf("no version/vsix url found from marketplace")
|
||||||
|
}
|
||||||
|
fallback := fmt.Sprintf("https://marketplace.visualstudio.com/_apis/public/gallery/publishers/%s/vsextensions/%s/%s/vspackage", cfg.MarketplacePublisher, cfg.MarketplaceExtension, version)
|
||||||
|
return fallback, version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadVSIX(url string, timeout time.Duration) ([]byte, error) {
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download VSIX: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("download VSIX status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractNestedZip(vsix []byte) (string, []byte, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(vsix), int64(len(vsix)))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
candidates := make([]*zip.File, 0)
|
||||||
|
for _, file := range reader.File {
|
||||||
|
name := file.Name
|
||||||
|
if strings.HasPrefix(name, "extension/dist/bin/") && strings.HasSuffix(name, ".zip") && strings.Contains(name, "lingma-") {
|
||||||
|
candidates = append(candidates, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", nil, fmt.Errorf("no lingma-*.zip found in VSIX")
|
||||||
|
}
|
||||||
|
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name })
|
||||||
|
picked := candidates[len(candidates)-1]
|
||||||
|
rc, err := picked.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return picked.Name, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRelease(inner []byte) (string, []byte, string, []zipEntry, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(inner), int64(len(inner)))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", nil, err
|
||||||
|
}
|
||||||
|
binaryPath := pickLingmaBinaryPath(reader.File)
|
||||||
|
if binaryPath == "" {
|
||||||
|
return "", nil, "", nil, fmt.Errorf("Lingma binary not found inside nested zip")
|
||||||
|
}
|
||||||
|
releaseRoot := inferReleaseRoot(binaryPath)
|
||||||
|
entries := make([]zipEntry, 0, len(reader.File))
|
||||||
|
var binaryBytes []byte
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if releaseRoot != "" && !strings.HasPrefix(file.Name, releaseRoot+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", nil, err
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, zipEntry{Name: file.Name, Data: data, Mode: file.Mode()})
|
||||||
|
if file.Name == binaryPath {
|
||||||
|
binaryBytes = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(binaryBytes) == 0 {
|
||||||
|
return "", nil, "", nil, fmt.Errorf("Lingma binary bytes missing from nested zip")
|
||||||
|
}
|
||||||
|
return binaryPath, binaryBytes, releaseRoot, entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickLingmaBinaryPath(files []*zip.File) string {
|
||||||
|
preferredSuffix := platformBinarySuffix()
|
||||||
|
for _, file := range files {
|
||||||
|
if strings.HasSuffix(file.Name, preferredSuffix) {
|
||||||
|
return file.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if strings.HasSuffix(file.Name, "/Lingma") || file.Name == "Lingma" {
|
||||||
|
return file.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformBinarySuffix() string {
|
||||||
|
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
|
||||||
|
return "x86_64_linux/Lingma"
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
|
||||||
|
return "arm64_linux/Lingma"
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||||
|
return "arm64_darwin/Lingma"
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
return "x86_64_darwin/Lingma"
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" && runtime.GOARCH == "arm64" {
|
||||||
|
return "arm64_windows/Lingma"
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "x86_64_windows/Lingma"
|
||||||
|
}
|
||||||
|
return "/Lingma"
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferReleaseRoot(memberPath string) string {
|
||||||
|
parts := strings.Split(memberPath, "/")
|
||||||
|
platforms := []string{"x86_64_linux", "arm64_linux", "x86_64_darwin", "arm64_darwin", "x86_64_windows", "arm64_windows"}
|
||||||
|
for _, platform := range platforms {
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == platform && i > 0 {
|
||||||
|
return strings.Join(parts[:i], "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) > 1 {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReleaseFiles(releaseDir string, releaseRoot string, files []zipEntry) error {
|
||||||
|
prefix := ""
|
||||||
|
if releaseRoot != "" {
|
||||||
|
prefix = releaseRoot + "/"
|
||||||
|
}
|
||||||
|
for _, entry := range files {
|
||||||
|
rel := entry.Name
|
||||||
|
if prefix != "" {
|
||||||
|
rel = strings.TrimPrefix(rel, prefix)
|
||||||
|
}
|
||||||
|
if rel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dest := filepath.Join(releaseDir, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := entry.Mode
|
||||||
|
if mode == 0 {
|
||||||
|
mode = 0o644
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(rel, "/Lingma") || filepath.Base(rel) == "Lingma" {
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, entry.Data, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseDirForOutput(outputDir string, releaseRoot string) string {
|
||||||
|
name := strings.TrimSpace(releaseRoot)
|
||||||
|
if name == "" {
|
||||||
|
return outputDir
|
||||||
|
}
|
||||||
|
return filepath.Join(outputDir, filepath.FromSlash(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRequiredAssets(releaseDir string) bool {
|
||||||
|
info, err := os.Stat(filepath.Join(releaseDir, "extension", "main.js"))
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMarker(path string) marker {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return marker{}
|
||||||
|
}
|
||||||
|
var mk marker
|
||||||
|
if err := json.Unmarshal(body, &mk); err != nil {
|
||||||
|
return marker{}
|
||||||
|
}
|
||||||
|
return mk
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMarker(path string, mk marker) error {
|
||||||
|
body, err := json.MarshalIndent(mk, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, body, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionOrUnknown(version string) string {
|
||||||
|
if strings.TrimSpace(version) == "" {
|
||||||
|
return "unknown version"
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
||||||
@@ -116,6 +116,8 @@ func NewServer(addr string, svc *service.Service) *Server {
|
|||||||
mux.HandleFunc("/api/tags", s.handleOllamaTags)
|
mux.HandleFunc("/api/tags", s.handleOllamaTags)
|
||||||
mux.HandleFunc("/v1/props", s.handleModelProps)
|
mux.HandleFunc("/v1/props", s.handleModelProps)
|
||||||
mux.HandleFunc("/props", s.handleModelProps)
|
mux.HandleFunc("/props", s.handleModelProps)
|
||||||
|
mux.HandleFunc("/v1/runtime/status", s.handleRuntimeStatus)
|
||||||
|
mux.HandleFunc("/runtime/status", s.handleRuntimeStatus)
|
||||||
mux.HandleFunc("/version", s.handleVersion)
|
mux.HandleFunc("/version", s.handleVersion)
|
||||||
mux.HandleFunc("/v1/messages/count_tokens", s.handleAnthropicCountTokens)
|
mux.HandleFunc("/v1/messages/count_tokens", s.handleAnthropicCountTokens)
|
||||||
mux.HandleFunc("/v1/messages", s.handleAnthropicMessages)
|
mux.HandleFunc("/v1/messages", s.handleAnthropicMessages)
|
||||||
@@ -184,6 +186,27 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRuntimeStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeOpenAIError(w, http.StatusMethodNotAllowed, "invalid_request_error", "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := s.svc.State()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": state.Connected,
|
||||||
|
"service": "lingma-proxy",
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDebugRequests(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDebugRequests(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ type Credential struct {
|
|||||||
TokenExpireTime int64
|
TokenExpireTime int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CredentialStatus struct {
|
||||||
|
Loaded bool `json:"loaded"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
UserIDMasked string `json:"user_id_masked,omitempty"`
|
||||||
|
MachineMasked string `json:"machine_id_masked,omitempty"`
|
||||||
|
ExpireAt string `json:"expire_at,omitempty"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
type storedCredentialFile struct {
|
type storedCredentialFile struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
TokenExpireTime string `json:"token_expire_time"`
|
TokenExpireTime string `json:"token_expire_time"`
|
||||||
@@ -44,6 +53,24 @@ func LoadCredential(authFile string) (Credential, error) {
|
|||||||
return importLingmaCacheCredential()
|
return importLingmaCacheCredential()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoadCredentialStatus(authFile string) (CredentialStatus, error) {
|
||||||
|
cred, err := LoadCredential(authFile)
|
||||||
|
if err != nil {
|
||||||
|
return CredentialStatus{}, err
|
||||||
|
}
|
||||||
|
status := CredentialStatus{
|
||||||
|
Loaded: true,
|
||||||
|
Source: cred.Source,
|
||||||
|
UserIDMasked: maskTail(cred.UserID),
|
||||||
|
MachineMasked: maskTail(cred.MachineID),
|
||||||
|
Expired: IsExpired(cred, 0),
|
||||||
|
}
|
||||||
|
if cred.TokenExpireTime > 0 {
|
||||||
|
status.ExpireAt = time.UnixMilli(cred.TokenExpireTime).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadCredentialFile(path string) (Credential, error) {
|
func loadCredentialFile(path string) (Credential, error) {
|
||||||
body, err := os.ReadFile(path)
|
body, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -359,6 +386,17 @@ func MachineOSHeader() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maskTail(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= 6 {
|
||||||
|
return strings.Repeat("*", len(value))
|
||||||
|
}
|
||||||
|
return value[:3] + strings.Repeat("*", len(value)-6) + value[len(value)-3:]
|
||||||
|
}
|
||||||
|
|
||||||
func uniquePathStrings(values []string) []string {
|
func uniquePathStrings(values []string) []string {
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
out := make([]string, 0, len(values))
|
out := make([]string, 0, len(values))
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"lingma-ipc-proxy/internal/bootstrap"
|
||||||
"lingma-ipc-proxy/internal/lingmaipc"
|
"lingma-ipc-proxy/internal/lingmaipc"
|
||||||
"lingma-ipc-proxy/internal/remote"
|
"lingma-ipc-proxy/internal/remote"
|
||||||
|
"lingma-ipc-proxy/internal/sessionbundle"
|
||||||
"lingma-ipc-proxy/internal/toolemulation"
|
"lingma-ipc-proxy/internal/toolemulation"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +55,18 @@ type Config struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
RemoteFallbackEnabled bool
|
RemoteFallbackEnabled bool
|
||||||
RemoteFallbackModels []string
|
RemoteFallbackModels []string
|
||||||
|
LingmaBootstrapEnabled bool
|
||||||
|
LingmaSourceType string
|
||||||
|
LingmaVSIXURL string
|
||||||
|
LingmaMarketplacePublisher string
|
||||||
|
LingmaMarketplaceExtension string
|
||||||
|
LingmaBootstrapOutputDir string
|
||||||
|
LingmaBinaryPath string
|
||||||
|
LingmaBootstrapAlways bool
|
||||||
|
LingmaForceRefresh bool
|
||||||
|
LingmaWorkDir string
|
||||||
|
LingmaSessionBundle string
|
||||||
|
LingmaSessionBundleFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
@@ -133,6 +147,9 @@ type State struct {
|
|||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
StickySessionID string `json:"sticky_session_id,omitempty"`
|
StickySessionID string `json:"sticky_session_id,omitempty"`
|
||||||
SessionMode SessionMode `json:"session_mode"`
|
SessionMode SessionMode `json:"session_mode"`
|
||||||
|
Bootstrap bootstrap.Result `json:"bootstrap,omitempty"`
|
||||||
|
SessionBundle sessionbundle.Result `json:"session_bundle,omitempty"`
|
||||||
|
RemoteAuth *remote.CredentialStatus `json:"remote_auth,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -146,6 +163,8 @@ type Service struct {
|
|||||||
stickyModelID string
|
stickyModelID string
|
||||||
modelMap map[string]string // official name -> internal id
|
modelMap map[string]string // official name -> internal id
|
||||||
remoteClient *remote.Client
|
remoteClient *remote.Client
|
||||||
|
bootstrapState bootstrap.Result
|
||||||
|
sessionState sessionbundle.Result
|
||||||
}
|
}
|
||||||
|
|
||||||
type promptRunResult struct {
|
type promptRunResult struct {
|
||||||
@@ -184,6 +203,24 @@ func New(cfg Config) *Service {
|
|||||||
if cfg.SessionMode == "" {
|
if cfg.SessionMode == "" {
|
||||||
cfg.SessionMode = SessionModeAuto
|
cfg.SessionMode = SessionModeAuto
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.LingmaSourceType) == "" {
|
||||||
|
cfg.LingmaSourceType = "marketplace"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.LingmaMarketplacePublisher) == "" {
|
||||||
|
cfg.LingmaMarketplacePublisher = "Alibaba-Cloud"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.LingmaMarketplaceExtension) == "" {
|
||||||
|
cfg.LingmaMarketplaceExtension = "tongyi-lingma"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.LingmaBinaryPath) == "" {
|
||||||
|
cfg.LingmaBinaryPath = filepath.Join(os.TempDir(), "lingma-proxy", "bin", "Lingma")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.LingmaBootstrapOutputDir) == "" {
|
||||||
|
cfg.LingmaBootstrapOutputDir = filepath.Join(filepath.Dir(cfg.LingmaBinaryPath), "release")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.LingmaWorkDir) == "" {
|
||||||
|
cfg.LingmaWorkDir = filepath.Join(filepath.Dir(filepath.Dir(cfg.LingmaBinaryPath)), ".lingma", "vscode", "sharedClientCache")
|
||||||
|
}
|
||||||
return &Service{cfg: cfg}
|
return &Service{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +247,44 @@ func (s *Service) DefaultModel() string {
|
|||||||
return strings.TrimSpace(s.cfg.Model)
|
return strings.TrimSpace(s.cfg.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) PrepareRuntime() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
cfg := s.cfg
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
sessionState, err := sessionbundle.Restore(cfg.LingmaWorkDir, cfg.LingmaSessionBundle, cfg.LingmaSessionBundleFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sessionState.Restored {
|
||||||
|
if err := os.Setenv("LINGMA_CACHE_DIR", cfg.LingmaWorkDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapState, err := bootstrap.Ensure(bootstrap.Config{
|
||||||
|
Enabled: cfg.LingmaBootstrapEnabled,
|
||||||
|
SourceType: cfg.LingmaSourceType,
|
||||||
|
VSIXURL: cfg.LingmaVSIXURL,
|
||||||
|
MarketplacePublisher: cfg.LingmaMarketplacePublisher,
|
||||||
|
MarketplaceExtension: cfg.LingmaMarketplaceExtension,
|
||||||
|
OutputDir: cfg.LingmaBootstrapOutputDir,
|
||||||
|
BinaryPath: cfg.LingmaBinaryPath,
|
||||||
|
AlwaysRefresh: cfg.LingmaBootstrapAlways,
|
||||||
|
ForceRefresh: cfg.LingmaForceRefresh,
|
||||||
|
HTTPTimeout: 30 * time.Second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessionState = sessionState
|
||||||
|
s.bootstrapState = bootstrapState
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Warmup(ctx context.Context) error {
|
func (s *Service) Warmup(ctx context.Context) error {
|
||||||
if s.backend() == BackendRemote {
|
if s.backend() == BackendRemote {
|
||||||
return s.remoteClientLocked().Warmup(ctx)
|
return s.remoteClientLocked().Warmup(ctx)
|
||||||
@@ -234,22 +309,26 @@ func contextWithOptionalTimeout(parent context.Context, timeout time.Duration) (
|
|||||||
func (s *Service) State() State {
|
func (s *Service) State() State {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
state := State{
|
||||||
|
SessionMode: s.cfg.SessionMode,
|
||||||
|
Bootstrap: s.bootstrapState,
|
||||||
|
SessionBundle: s.sessionState,
|
||||||
|
}
|
||||||
if s.cfg.Backend == BackendRemote {
|
if s.cfg.Backend == BackendRemote {
|
||||||
return State{
|
state.Endpoint = remote.ResolveBaseURL(s.cfg.RemoteBaseURL)
|
||||||
Endpoint: remote.ResolveBaseURL(s.cfg.RemoteBaseURL),
|
state.Transport = "remote"
|
||||||
Transport: "remote",
|
if status, err := remote.LoadCredentialStatus(s.cfg.RemoteAuthFile); err == nil {
|
||||||
Connected: s.remoteClient != nil,
|
state.RemoteAuth = &status
|
||||||
SessionMode: s.cfg.SessionMode,
|
state.Connected = status.Loaded && !status.Expired
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
return State{
|
state.PipePath = s.pipePath
|
||||||
PipePath: s.pipePath,
|
state.Endpoint = s.endpoint
|
||||||
Endpoint: s.endpoint,
|
state.Transport = string(s.transport)
|
||||||
Transport: string(s.transport),
|
state.Connected = s.client != nil
|
||||||
Connected: s.client != nil,
|
state.StickySessionID = s.stickySessionID
|
||||||
StickySessionID: s.stickySessionID,
|
return state
|
||||||
SessionMode: s.cfg.SessionMode,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListModels(ctx context.Context) ([]Model, error) {
|
func (s *Service) ListModels(ctx context.Context) ([]Model, error) {
|
||||||
|
|||||||
193
internal/sessionbundle/bundle.go
Normal file
193
internal/sessionbundle/bundle.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package sessionbundle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxBundleBytes = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
var bundleFiles = map[string]struct{}{
|
||||||
|
"cache/id": {},
|
||||||
|
"cache/user": {},
|
||||||
|
"cache/quota": {},
|
||||||
|
"cache/config.json": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Restored bool `json:"restored"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
WorkDir string `json:"work_dir,omitempty"`
|
||||||
|
Files []string `json:"files,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Restore(workDir string, inline string, filePath string) (Result, error) {
|
||||||
|
result := Result{
|
||||||
|
Configured: strings.TrimSpace(inline) != "" || strings.TrimSpace(filePath) != "",
|
||||||
|
WorkDir: strings.TrimSpace(workDir),
|
||||||
|
}
|
||||||
|
if !result.Configured {
|
||||||
|
result.Message = "session bundle not configured"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if result.WorkDir == "" {
|
||||||
|
return result, fmt.Errorf("session bundle restore requires a work dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle, source, err := resolveBundle(inline, filePath)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
raw, err := DecodeBundle(bundle)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
files, err := ApplyBundleToWorkDir(result.WorkDir, raw)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.Restored = true
|
||||||
|
result.Source = source
|
||||||
|
result.Files = files
|
||||||
|
result.Message = fmt.Sprintf("restored %d files", len(files))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeBundle(b64 string) ([]byte, error) {
|
||||||
|
b64 = strings.TrimSpace(b64)
|
||||||
|
if b64 == "" {
|
||||||
|
return nil, fmt.Errorf("empty bundle")
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base64: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) > maxBundleBytes {
|
||||||
|
return nil, fmt.Errorf("bundle too large: %d bytes", len(raw))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyBundleToWorkDir(workDir string, raw []byte) ([]string, error) {
|
||||||
|
if len(raw) > maxBundleBytes {
|
||||||
|
return nil, fmt.Errorf("bundle too large: %d bytes", len(raw))
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open bundle gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
reader := tar.NewReader(gz)
|
||||||
|
restored := make([]string, 0, len(bundleFiles))
|
||||||
|
var total int64
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read bundle tar: %w", err)
|
||||||
|
}
|
||||||
|
if !isSafeMember(header) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += header.Size
|
||||||
|
if total > maxBundleBytes {
|
||||||
|
return nil, fmt.Errorf("bundle expanded too large: %d bytes", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(workDir, filepath.FromSlash(header.Name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileModeFor(header.Name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(file, reader); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
restored = append(restored, header.Name)
|
||||||
|
}
|
||||||
|
return restored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBundle(inline string, filePath string) (string, string, error) {
|
||||||
|
if value := strings.TrimSpace(inline); value != "" {
|
||||||
|
return value, "inline", nil
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(filePath)
|
||||||
|
if path == "" {
|
||||||
|
return "", "", fmt.Errorf("session bundle file path is empty")
|
||||||
|
}
|
||||||
|
expanded := expandHome(path)
|
||||||
|
body, err := os.ReadFile(expanded)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("read bundle file: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(body)), filepath.Base(expanded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeMember(header *tar.Header) bool {
|
||||||
|
if header == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := bundleFiles[header.Name]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if header.Name == "" || strings.HasPrefix(header.Name, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(header.Name, "/") {
|
||||||
|
if part == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileModeFor(name string) os.FileMode {
|
||||||
|
if strings.HasSuffix(name, "/user") {
|
||||||
|
return 0o600
|
||||||
|
}
|
||||||
|
return 0o644
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHome(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" || path[0] != '~' {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if path == "~" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if len(path) > 1 && (path[1] == '/' || path[1] == '\\') {
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user