Improve remote endpoint detection

This commit is contained in:
lutc5
2026-04-30 12:57:47 +08:00
parent 2bcb0a6715
commit e88856e1fc
13 changed files with 628 additions and 50 deletions

View File

@@ -1,8 +1,8 @@
# Lingma IPC Proxy
# Lingma Proxy
[English](./README.md) | [简体中文](./README.zh-CN.md)
Lingma IPC Proxy exposes Tongyi Lingma's local IDE plugin capability as standard **OpenAI-compatible** and **Anthropic-compatible** HTTP APIs. It can be used as a CLI proxy service or as a cross-platform desktop app for macOS and Windows.
Lingma Proxy exposes Tongyi Lingma as standard **OpenAI-compatible** and **Anthropic-compatible** HTTP APIs. It can use either the local IDE plugin IPC channel or an experimental remote API backend, and ships as both a CLI proxy service and a cross-platform desktop app for macOS and Windows.
The project is designed for tools such as Claude Code, Cline, Continue, OpenCode, custom agents, and any client that can talk to OpenAI or Anthropic style APIs.
@@ -13,7 +13,7 @@ The proxy now supports two backend modes:
## Current Version
The current desktop line is `v1.4.0`.
The current desktop line is `v1.4.1`.
Release builds are produced by GitHub Actions for:
@@ -156,9 +156,9 @@ flowchart LR
| Platform | Default transport | Detection |
| --- | --- | --- |
| macOS | WebSocket | reads Lingma `SharedClientCache` files under user application support paths |
| Windows | Named Pipe / WebSocket | scans Lingma named pipes and shared cache hints |
| Linux | WebSocket | manual `--ws-url` is recommended |
| macOS | WebSocket | reads Lingma `SharedClientCache` files under user application support paths and `~/.lingma` fallbacks |
| Windows | Named Pipe / WebSocket | scans Lingma named pipes plus `%APPDATA%`, `%LOCALAPPDATA%`, `%ProgramData%`, and `%USERPROFILE%\.lingma` shared cache hints |
| Linux | WebSocket | reads `~/.lingma` / XDG hints when present; manual `--ws-url` is still recommended |
If auto detection fails, set the path manually in the desktop Settings page or pass CLI flags:
@@ -193,6 +193,9 @@ By default it reads the local Lingma login cache in read-only mode:
~/.lingma/cache/user
~/.lingma/cache/id
~/.lingma/logs/lingma.log
%APPDATA%\Lingma\cache\user
%LOCALAPPDATA%\Lingma\cache\user
XDG config/state Lingma cache paths when present
```
You can also pass an explicit credential file:
@@ -222,7 +225,8 @@ Credential file format:
Notes:
- Remote mode does not write or migrate login state. It only reads the local Lingma cache or the credential file you provide.
- If your Lingma plugin uses a dedicated domain, set `--remote-base-url`, `LINGMA_REMOTE_BASE_URL`, or the JSON config field explicitly.
- If your Lingma plugin uses a dedicated domain, remote mode first uses `--remote-base-url`, `LINGMA_REMOTE_BASE_URL`, or the JSON config field. If those are empty, it scans Lingma's local logs on macOS, Windows, and Linux for endpoint hints such as `endpoint config:` and marketplace service URLs.
- The desktop Settings page shows the resolved remote domain and detection source without exposing tokens.
- `/v1/models` in remote mode returns remote API model keys, which may not match the IPC plugin display IDs such as `MiniMax-M2.7` or `Kimi-K2.6`.
- Local validation passed `/health`, `/v1/models`, OpenAI streaming/non-streaming chat, and Claude Code Anthropic + Bash tool use. Claude Code full tool runs are much slower than simple OpenAI requests because the client sends a large context and performs a second tool-result turn.
- This mode is inspired by the remote API and credential-signing research in [ZipperCode/lingma2api](https://github.com/ZipperCode/lingma2api), integrated here as a switchable backend under the existing OpenAI / Anthropic / desktop app architecture.

View File

@@ -1,8 +1,8 @@
# Lingma IPC Proxy
# Lingma Proxy
[English](./README.md) | [简体中文](./README.zh-CN.md)
**Lingma IPC Proxy** 是一个通义灵码 IDE 插件 API 适配层。它把 Lingma 插件的本地私有 IPC / WebSocket 能力转换成标准 **OpenAI 兼容接口****Anthropic 兼容接口**,让 Claude Code、Cline、Continue、OpenCode、自研 Agent 等第三方客户端可以直接调用 Lingma 后端模型。
**Lingma Proxy** 是一个通义灵码 API 适配层。它既可以把 Lingma 插件的本地私有 IPC / WebSocket 能力转换成标准 **OpenAI 兼容接口****Anthropic 兼容接口**也可以使用实验性的远端 API 模式直接调用 Lingma 远端接口,让 Claude Code、Cline、Continue、OpenCode、自研 Agent 等第三方客户端可以直接调用 Lingma 后端模型。
项目同时提供两种使用方式:
@@ -16,7 +16,7 @@
## 当前版本
当前桌面端版本线:`v1.4.0`
当前桌面端版本线:`v1.4.1`
GitHub Actions 会在 Release 中产出:
@@ -216,9 +216,9 @@ flowchart LR
| 平台 | 优先传输 | 探测方式 |
| --- | --- | --- |
| macOS | WebSocket | 扫描用户目录下 Lingma `SharedClientCache` 配置 |
| Windows | Named Pipe / WebSocket | 扫描 Lingma 命名管道共享缓存信息 |
| Linux | WebSocket | 建议手动指定 `--ws-url` |
| macOS | WebSocket | 扫描 Lingma `SharedClientCache``~/.lingma` 等用户目录 |
| Windows | Named Pipe / WebSocket | 扫描 Lingma 命名管道,以及 `%APPDATA%``%LOCALAPPDATA%``%ProgramData%``%USERPROFILE%\.lingma` 下的共享缓存信息 |
| Linux | WebSocket | 尝试读取 `~/.lingma` / XDG 目录,仍建议必要时手动指定 `--ws-url` |
如果自动探测失败,桌面端会提供兜底说明。可以在设置里手动填写:
@@ -259,6 +259,9 @@ lingma-ipc-proxy --backend remote --port 8095
~/.lingma/cache/user
~/.lingma/cache/id
~/.lingma/logs/lingma.log
%APPDATA%\Lingma\cache\user
%LOCALAPPDATA%\Lingma\cache\user
存在时也会尝试 XDG 配置 / 状态目录
```
也可以指定显式凭据文件:
@@ -288,7 +291,8 @@ lingma-ipc-proxy \
说明:
- 远端模式不会写入或迁移你的登录态,只会读取本机 Lingma 缓存或你指定的凭据文件。
- 如果 Lingma 插件配置过专属域名,可以通过 `--remote-base-url``LINGMA_REMOTE_BASE_URL` 或配置文件显式指定
- 如果 Lingma 插件配置过专属域名,远端模式会优先使用 `--remote-base-url``LINGMA_REMOTE_BASE_URL` 或配置文件;这些为空时,会扫描 macOS、Windows、Linux 上 Lingma 本地日志里的 `endpoint config:`、Marketplace service URL 等线索
- 桌面端设置页会展示当前解析到的远端域名和来源,但不会展示 token / key 明文。
- 远端模式的 `/v1/models` 返回的是远端接口模型 key不一定等同于 IPC 插件模式里看到的 `MiniMax-M2.7``Kimi-K2.6` 等展示名。
- 当前本机实测:`/health``/v1/models`、OpenAI 流式 / 非流式、Claude Code Anthropic + Bash 工具调用均可用Claude Code 完整工具链耗时明显高于简单 OpenAI 请求。
- 该模式参考了 [ZipperCode/lingma2api](https://github.com/ZipperCode/lingma2api) 对 Lingma 远端接口、签名和登录态结构的探索,本仓库将其作为可切换后端集成到现有 OpenAI / Anthropic / 桌面 App 架构中。

View File

@@ -15,6 +15,7 @@ import (
"lingma-ipc-proxy/internal/httpapi"
"lingma-ipc-proxy/internal/lingmaipc"
"lingma-ipc-proxy/internal/remote"
"lingma-ipc-proxy/internal/service"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -58,11 +59,32 @@ type ModelInfo struct {
type ProxyStatus struct {
Running bool `json:"running"`
Addr string `json:"addr"`
Backend string `json:"backend"`
Models int `json:"models"`
Model string `json:"model,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
}
// DetectionInfo exposes non-sensitive resolved connection details for the UI.
type DetectionInfo struct {
ListenURL string `json:"listenUrl"`
Backend string `json:"backend"`
BackendLabel string `json:"backendLabel"`
IPCSuccess bool `json:"ipcSuccess"`
IPCTransport string `json:"ipcTransport,omitempty"`
IPCEndpoint string `json:"ipcEndpoint,omitempty"`
IPCError string `json:"ipcError,omitempty"`
RemoteBaseURL string `json:"remoteBaseUrl"`
RemoteBaseURLSource string `json:"remoteBaseUrlSource,omitempty"`
RemoteCredentialSuccess bool `json:"remoteCredentialSuccess"`
RemoteCredentialSource string `json:"remoteCredentialSource,omitempty"`
RemoteUserID string `json:"remoteUserId,omitempty"`
RemoteMachineID string `json:"remoteMachineId,omitempty"`
RemoteTokenExpireAt string `json:"remoteTokenExpireAt,omitempty"`
RemoteTokenExpired bool `json:"remoteTokenExpired"`
RemoteCredentialError string `json:"remoteCredentialError,omitempty"`
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
@@ -201,6 +223,7 @@ func (a *App) GetStatus() ProxyStatus {
return ProxyStatus{
Running: a.running,
Addr: a.addr,
Backend: string(a.cfg.Backend),
Models: len(a.models),
Model: a.cfg.Model,
StartedAt: startedAt,
@@ -217,6 +240,54 @@ func (a *App) GetConfig() service.Config {
return cfg
}
// GetDetectionInfo returns resolved IPC/remote details without exposing tokens.
func (a *App) GetDetectionInfo() DetectionInfo {
a.mu.RLock()
cfg := a.cfg
addr := a.addr
a.mu.RUnlock()
if strings.TrimSpace(addr) == "" {
addr = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
}
baseURL := remote.ResolveBaseURLWithSource(cfg.RemoteBaseURL)
info := DetectionInfo{
ListenURL: "http://" + addr,
Backend: string(cfg.Backend),
BackendLabel: backendLabel(cfg.Backend),
RemoteBaseURL: baseURL.URL,
RemoteBaseURLSource: baseURL.Source,
}
if opts, err := lingmaipc.ResolveDialOptions(cfg.Transport, cfg.Pipe, cfg.WebSocketURL); err == nil {
info.IPCSuccess = true
info.IPCTransport = string(opts.Transport)
switch opts.Transport {
case lingmaipc.TransportPipe:
info.IPCEndpoint = opts.PipePath
case lingmaipc.TransportWebSocket:
info.IPCEndpoint = opts.WebSocketURL
}
} else {
info.IPCError = err.Error()
}
if cred, err := remote.LoadCredential(cfg.RemoteAuthFile); err == nil {
info.RemoteCredentialSuccess = true
info.RemoteCredentialSource = cred.Source
info.RemoteUserID = maskIdentifier(cred.UserID)
info.RemoteMachineID = maskIdentifier(cred.MachineID)
info.RemoteTokenExpired = remote.IsExpired(cred, 0)
if cred.TokenExpireTime > 0 {
info.RemoteTokenExpireAt = time.UnixMilli(cred.TokenExpireTime).Format(time.RFC3339)
}
} else {
info.RemoteCredentialError = err.Error()
}
return info
}
// UpdateConfig updates the configuration, saves to file, and restarts the proxy if running.
// Frontend sends Timeout in seconds; we convert to time.Duration.
func (a *App) UpdateConfig(cfg service.Config) error {
@@ -607,6 +678,27 @@ func defaultConfig() service.Config {
return cfg
}
func backendLabel(backend service.BackendMode) string {
switch backend {
case service.BackendRemote:
return "远端 API"
default:
return "IPC 插件"
}
}
func maskIdentifier(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= 8 {
return string(runes[:1]) + "***"
}
return string(runes[:4]) + "..." + string(runes[len(runes)-4:])
}
func configSearchPaths() []string {
var paths []string
// 1. Executable directory (for dev / portable mode)

View File

@@ -222,7 +222,7 @@ onUnmounted(() => {
<span class="status-dot" :class="{ running: status.running }"></span>
<div>
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
<small>v1.4.0</small>
<small>v1.4.1</small>
</div>
</div>
</aside>

View File

@@ -1266,6 +1266,83 @@ button:disabled {
font-size: 12px;
}
.detect-card {
display: grid;
gap: 10px;
margin-top: 14px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.54);
}
:root[data-theme="dark"] .detect-card {
background: rgba(15, 23, 42, 0.52);
}
.detect-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detect-title strong {
color: var(--text);
font-size: 13px;
}
.detect-title button {
min-height: 28px;
padding: 0 10px;
color: var(--text);
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface-strong);
cursor: pointer;
}
.detect-card dl {
display: grid;
gap: 8px;
margin: 0;
}
.detect-card dl > div {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.detect-card dt {
color: var(--muted);
font-size: 12px;
font-weight: 680;
}
.detect-card dd {
min-width: 0;
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.muted-inline {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.warn-text {
color: var(--danger-text) !important;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -1,10 +1,11 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { GetConfig, UpdateConfig } from '../../wailsjs/go/main/App.js'
import { GetConfig, GetDetectionInfo, UpdateConfig } from '../../wailsjs/go/main/App.js'
const emit = defineEmits(['log', 'status-refresh'])
const config = ref({})
const detection = ref(null)
const saving = ref(false)
const openSelect = ref('')
@@ -47,20 +48,31 @@ function toggleSelect(field) {
function chooseOption(field, value) {
config.value[field] = value
openSelect.value = ''
refreshDetection()
}
onMounted(async () => {
try {
config.value = await GetConfig()
await refreshDetection()
} catch (e) {
emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
}
})
async function refreshDetection() {
try {
detection.value = await GetDetectionInfo()
} catch (e) {
emit('log', 'warn', '探测信息加载失败:' + (e.message || String(e)))
}
}
async function save() {
saving.value = true
try {
await UpdateConfig(config.value)
await refreshDetection()
emit('log', 'info', '配置已保存,代理已按需重启')
emit('status-refresh')
} catch (e) {
@@ -169,6 +181,50 @@ async function save() {
<strong>自动探测失败时</strong>
<span>IPC 模式先确认 VS Code / Lingma 插件已启动并登录远端 API 模式会优先读取认证文件留空时只读 <code>~/.lingma/cache/user</code></span>
</div>
<div v-if="detection" class="detect-card">
<div class="detect-title">
<strong>当前解析结果</strong>
<button type="button" @click="refreshDetection">刷新</button>
</div>
<dl>
<div>
<dt>监听地址</dt>
<dd>{{ detection.listenUrl || '未启动' }}</dd>
</div>
<div>
<dt>当前后端</dt>
<dd>{{ detection.backendLabel || detection.backend }}</dd>
</div>
<div>
<dt>IPC 地址</dt>
<dd v-if="detection.ipcSuccess">{{ detection.ipcTransport }} · {{ detection.ipcEndpoint }}</dd>
<dd v-else class="warn-text">{{ detection.ipcError || '未探测到' }}</dd>
</div>
<div>
<dt>远端域名</dt>
<dd>
{{ detection.remoteBaseUrl }}
<span v-if="detection.remoteBaseUrlSource" class="muted-inline">来自 {{ detection.remoteBaseUrlSource }}</span>
</dd>
</div>
<div>
<dt>登录态来源</dt>
<dd v-if="detection.remoteCredentialSuccess">{{ detection.remoteCredentialSource }}</dd>
<dd v-else class="warn-text">{{ detection.remoteCredentialError || '未探测到' }}</dd>
</div>
<div v-if="detection.remoteCredentialSuccess">
<dt>账号 / 机器</dt>
<dd>{{ detection.remoteUserId || '未知用户' }} · {{ detection.remoteMachineId || '未知机器' }}</dd>
</div>
<div v-if="detection.remoteCredentialSuccess">
<dt>登录态有效期</dt>
<dd :class="{ 'warn-text': detection.remoteTokenExpired }">
{{ detection.remoteTokenExpireAt || '未提供' }}
<span v-if="detection.remoteTokenExpired">已过期</span>
</dd>
</div>
</dl>
</div>
</div>
<div class="glass-panel">

View File

@@ -9,6 +9,8 @@ export function ClearRequests():Promise<void>;
export function GetConfig():Promise<service.Config>;
export function GetDetectionInfo():Promise<main.DetectionInfo>;
export function GetModels():Promise<Array<main.ModelInfo>>;
export function GetRequests():Promise<Array<main.RequestRecord>>;

View File

@@ -14,6 +14,10 @@ export function GetConfig() {
return window['go']['main']['App']['GetConfig']();
}
export function GetDetectionInfo() {
return window['go']['main']['App']['GetDetectionInfo']();
}
export function GetModels() {
return window['go']['main']['App']['GetModels']();
}

View File

@@ -1,5 +1,47 @@
export namespace main {
export class DetectionInfo {
listenUrl: string;
backend: string;
backendLabel: string;
ipcSuccess: boolean;
ipcTransport?: string;
ipcEndpoint?: string;
ipcError?: string;
remoteBaseUrl: string;
remoteBaseUrlSource?: string;
remoteCredentialSuccess: boolean;
remoteCredentialSource?: string;
remoteUserId?: string;
remoteMachineId?: string;
remoteTokenExpireAt?: string;
remoteTokenExpired: boolean;
remoteCredentialError?: string;
static createFrom(source: any = {}) {
return new DetectionInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.listenUrl = source["listenUrl"];
this.backend = source["backend"];
this.backendLabel = source["backendLabel"];
this.ipcSuccess = source["ipcSuccess"];
this.ipcTransport = source["ipcTransport"];
this.ipcEndpoint = source["ipcEndpoint"];
this.ipcError = source["ipcError"];
this.remoteBaseUrl = source["remoteBaseUrl"];
this.remoteBaseUrlSource = source["remoteBaseUrlSource"];
this.remoteCredentialSuccess = source["remoteCredentialSuccess"];
this.remoteCredentialSource = source["remoteCredentialSource"];
this.remoteUserId = source["remoteUserId"];
this.remoteMachineId = source["remoteMachineId"];
this.remoteTokenExpireAt = source["remoteTokenExpireAt"];
this.remoteTokenExpired = source["remoteTokenExpired"];
this.remoteCredentialError = source["remoteCredentialError"];
}
}
export class ModelInfo {
id: string;
name: string;
@@ -17,6 +59,7 @@ export namespace main {
export class ProxyStatus {
running: boolean;
addr: string;
backend: string;
models: number;
model?: string;
startedAt?: string;
@@ -29,6 +72,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.addr = source["addr"];
this.backend = source["backend"];
this.models = source["models"];
this.model = source["model"];
this.startedAt = source["startedAt"];

View File

@@ -194,18 +194,36 @@ func resolveSharedClientInfo() (sharedClientInfo, error) {
}
func defaultSharedClientInfoPaths() []string {
bases := make([]string, 0, 2)
if appData := strings.TrimSpace(os.Getenv("APPDATA")); appData != "" {
bases = append(bases, appData)
if explicit := strings.TrimSpace(os.Getenv("LINGMA_SHARED_CLIENT_INFO")); explicit != "" {
return []string{explicit}
}
bases := make([]string, 0, 8)
if userConfigDir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(userConfigDir) != "" {
bases = append(bases, userConfigDir)
}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
bases = append(bases,
filepath.Join(home, ".lingma", "vscode"),
filepath.Join(home, ".lingma"),
)
}
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
bases = append(bases, value)
}
}
seen := make(map[string]struct{})
paths := make([]string, 0, len(bases)*2)
for _, base := range bases {
cacheDir := filepath.Join(base, "Lingma", "SharedClientCache")
for _, base := range uniquePathStrings(bases) {
cacheDirs := []string{
filepath.Join(base, "Lingma", "SharedClientCache"),
filepath.Join(base, "Lingma", "sharedClientCache"),
filepath.Join(base, "SharedClientCache"),
filepath.Join(base, "sharedClientCache"),
}
for _, cacheDir := range cacheDirs {
for _, name := range []string{".info.json", ".info"} {
path := filepath.Join(cacheDir, name)
if _, ok := seen[path]; ok {
@@ -215,9 +233,29 @@ func defaultSharedClientInfoPaths() []string {
paths = append(paths, path)
}
}
}
return paths
}
func uniquePathStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
cleaned := filepath.Clean(value)
key := strings.ToLower(cleaned)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, cleaned)
}
return out
}
func resolveSharedClientInfoFromPaths(paths []string) (sharedClientInfo, error) {
var parseErrors []string
foundFile := false

View File

@@ -9,8 +9,10 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -35,6 +37,11 @@ type Client struct {
client *http.Client
}
type BaseURLHint struct {
URL string
Source string
}
type Model struct {
Key string `json:"key"`
DisplayName string `json:"display_name"`
@@ -76,18 +83,22 @@ func New(cfg Config) *Client {
}
func ResolveBaseURL(explicit string) string {
return ResolveBaseURLWithSource(explicit).URL
}
func ResolveBaseURLWithSource(explicit string) BaseURLHint {
if strings.TrimSpace(explicit) != "" {
return strings.TrimRight(strings.TrimSpace(explicit), "/")
return BaseURLHint{URL: strings.TrimRight(strings.TrimSpace(explicit), "/"), Source: "explicit config"}
}
if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_BASE_URL")); value != "" {
return strings.TrimRight(value, "/")
return BaseURLHint{URL: strings.TrimRight(value, "/"), Source: "LINGMA_REMOTE_BASE_URL"}
}
for _, path := range candidateConfigFiles() {
if value := readBaseURLHint(path); value != "" {
return strings.TrimRight(value, "/")
return BaseURLHint{URL: strings.TrimRight(value, "/"), Source: path}
}
}
return DefaultBaseURL
return BaseURLHint{URL: DefaultBaseURL, Source: "default"}
}
func (c *Client) Warmup(ctx context.Context) error {
@@ -292,7 +303,7 @@ func (c *Client) headers(cred Credential, path string, body string) (map[string]
"Cosy-User": cred.UserID,
"Cosy-Clientip": "198.18.0.1",
"Cosy-Clienttype": "2",
"Cosy-Machineos": "x86_64_windows",
"Cosy-Machineos": MachineOSHeader(),
"Cosy-Machinetoken": "",
"Cosy-Machinetype": "",
"Cosy-Version": c.cfg.CosyVersion,
@@ -381,12 +392,20 @@ func candidateConfigFiles() []string {
if err != nil {
return nil
}
return []string{
paths := []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"),
filepath.Join(home, ".lingma", "logs", "lingma.log"),
filepath.Join(home, ".lingma", "logs", "lingma-extension.log"),
filepath.Join(home, ".lingma", "vscode", "sharedClientCache", "logs", "lingma.log"),
filepath.Join(home, ".lingma", "vscode", "sharedClientCache", "logs", "lingma-extension.log"),
}
for _, root := range lingmaLogRoots(home) {
paths = append(paths, recentLingmaAppLogs(root)...)
}
return paths
}
func readBaseURLHint(path string) string {
@@ -396,13 +415,12 @@ func readBaseURLHint(path string) string {
}
var value any
if err := json.Unmarshal(body, &value); err != nil {
text := string(body)
if strings.Contains(text, "lingma.alibabacloud.com") {
return DefaultBaseURL
return extractBaseURLFromText(string(body))
}
return ""
if value := findBaseURL(value); value != "" {
return value
}
return findBaseURL(value)
return extractBaseURLFromText(string(body))
}
func findBaseURL(value any) string {
@@ -429,6 +447,146 @@ func findBaseURL(value any) string {
return ""
}
func lingmaLogRoots(home string) []string {
roots := []string{
filepath.Join(home, ".lingma", "logs"),
filepath.Join(home, ".lingma", "vscode", "sharedClientCache", "logs"),
filepath.Join(home, "Library", "Application Support", "Lingma", "logs"),
}
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
roots = append(roots,
filepath.Join(value, "Lingma", "logs"),
filepath.Join(value, "Code", "User", "globalStorage", "alibaba-cloud.tongyi-lingma", "logs"),
)
}
}
if value := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); value != "" {
roots = append(roots, filepath.Join(value, "Lingma", "logs"))
}
if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" {
roots = append(roots, filepath.Join(value, "Lingma", "logs"))
}
roots = append(roots,
filepath.Join(home, ".config", "Lingma", "logs"),
filepath.Join(home, ".local", "state", "Lingma", "logs"),
)
return uniqueStrings(roots)
}
func recentLingmaAppLogs(root string) []string {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
type logDir struct {
path string
modTime int64
}
dirs := make([]logDir, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
dirs = append(dirs, logDir{path: filepath.Join(root, entry.Name()), modTime: info.ModTime().UnixNano()})
}
sort.Slice(dirs, func(i, j int) bool { return dirs[i].modTime > dirs[j].modTime })
if len(dirs) > 5 {
dirs = dirs[:5]
}
paths := make([]string, 0, len(dirs)*4)
for _, dir := range dirs {
_ = filepath.WalkDir(dir.path, func(path string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return nil
}
name := entry.Name()
lowerName := strings.ToLower(name)
if lowerName == "renderer.log" ||
lowerName == "sharedprocess.log" ||
lowerName == "main.log" ||
strings.HasSuffix(name, "Lingma.log") ||
strings.Contains(lowerName, "lingma") && strings.HasSuffix(lowerName, ".log") {
paths = append(paths, path)
}
return nil
})
}
return paths
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func extractBaseURLFromText(text string) string {
for _, marker := range []string{
"endpoint config:",
"Using service url:",
"Download asset from:",
"https://ai-lingma",
"https://lingma",
} {
if value := extractBaseURLAfterMarker(text, marker); value != "" {
return value
}
}
return ""
}
func extractBaseURLAfterMarker(text, marker string) string {
lowerText := strings.ToLower(text)
lowerMarker := strings.ToLower(marker)
index := strings.LastIndex(lowerText, lowerMarker)
if index < 0 {
return ""
}
tail := text[index+len(marker):]
if strings.HasPrefix(lowerMarker, "https://") {
tail = marker + tail
}
for _, field := range strings.Fields(tail) {
field = strings.Trim(field, `"'<>),]}`)
if value := normalizeRemoteBaseURLHint(field); value != "" {
return value
}
}
return ""
}
func normalizeRemoteBaseURLHint(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ""
}
host := strings.ToLower(parsed.Host)
if !strings.Contains(host, "lingma") && !strings.Contains(host, "rdc.aliyuncs.com") {
return ""
}
return parsed.Scheme + "://" + parsed.Host
}
func estimateTokens(text string) int {
text = strings.TrimSpace(text)
if text == "" {

View File

@@ -0,0 +1,19 @@
package remote
import "testing"
func TestExtractBaseURLFromEndpointLog(t *testing.T) {
got := extractBaseURLFromText(`2026-04-10 INFO Update endpoint success. endpoint config: https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com`)
want := "https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestExtractBaseURLFromMarketplaceLog(t *testing.T) {
got := extractBaseURLFromText(`2026-04-30 [info] [Marketplace] Using service url: https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com/marketplace/_apis/public/gallery`)
want := "https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@@ -62,22 +63,33 @@ func loadCredentialFile(path string) (Credential, error) {
}
func importLingmaCacheCredential() (Credential, error) {
home, err := os.UserHomeDir()
if err != nil {
return Credential{}, err
var attempts []string
for _, lingmaDir := range candidateLingmaCacheDirs() {
cred, err := importLingmaCacheCredentialFromDir(lingmaDir)
if err == nil {
return cred, nil
}
lingmaDir := filepath.Join(home, ".lingma")
attempts = append(attempts, fmt.Sprintf("%s: %v", lingmaDir, err))
}
if len(attempts) == 0 {
return Credential{}, errors.New("no Lingma cache directory candidate was found")
}
return Credential{}, fmt.Errorf("load Lingma login cache: %s", strings.Join(attempts, "; "))
}
func importLingmaCacheCredentialFromDir(lingmaDir string) (Credential, error) {
machineID, err := loadMachineID(lingmaDir)
if err != nil {
return Credential{}, err
}
encrypted, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "user"))
userPath := filepath.Join(lingmaDir, "cache", "user")
encrypted, err := os.ReadFile(userPath)
if err != nil {
return Credential{}, fmt.Errorf("read ~/.lingma/cache/user: %w", err)
return Credential{}, fmt.Errorf("read %s: %w", userPath, err)
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encrypted)))
if err != nil {
return Credential{}, fmt.Errorf("decode ~/.lingma/cache/user: %w", err)
return Credential{}, fmt.Errorf("decode %s: %w", userPath, err)
}
plaintext, err := decryptCacheUser(machineID, ciphertext)
if err != nil {
@@ -90,19 +102,46 @@ func importLingmaCacheCredential() (Credential, error) {
ExpireTime any `json:"expire_time"`
}
if err := json.Unmarshal(plaintext, &payload); err != nil {
return Credential{}, fmt.Errorf("parse ~/.lingma/cache/user: %w", err)
return Credential{}, fmt.Errorf("parse %s: %w", userPath, err)
}
cred := Credential{
CosyKey: payload.Key,
EncryptUserInfo: payload.EncryptUserInfo,
UserID: payload.UserID,
MachineID: machineID,
Source: "~/.lingma/cache/user",
Source: userPath,
TokenExpireTime: parseExpireAny(payload.ExpireTime),
}
return cred, validateCredential(cred)
}
func candidateLingmaCacheDirs() []string {
if explicit := strings.TrimSpace(os.Getenv("LINGMA_CACHE_DIR")); explicit != "" {
return []string{expandHome(explicit)}
}
var dirs []string
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
dirs = append(dirs,
filepath.Join(home, ".lingma"),
filepath.Join(home, ".config", "Lingma"),
filepath.Join(home, ".local", "share", "Lingma"),
)
}
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
dirs = append(dirs,
filepath.Join(value, "Lingma"),
filepath.Join(value, "lingma"),
)
}
}
if value := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); value != "" {
dirs = append(dirs, filepath.Join(value, "Lingma"))
}
return uniquePathStrings(dirs)
}
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 != "" {
@@ -111,7 +150,7 @@ func loadMachineID(lingmaDir string) (string, error) {
}
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)
return "", fmt.Errorf("remote credential requires cache/id or lingma.log machine id: %w", err)
}
markers := []string{"using machine id from file:", "machine id:"}
text := string(logBody)
@@ -128,7 +167,7 @@ func loadMachineID(lingmaDir string) (string, error) {
return value, nil
}
}
return "", errors.New("machine id not found in ~/.lingma cache")
return "", errors.New("machine id not found in Lingma cache")
}
func decryptCacheUser(machineID string, ciphertext []byte) ([]byte, error) {
@@ -203,3 +242,44 @@ func parseExpireAny(value any) int64 {
func IsExpired(cred Credential, margin time.Duration) bool {
return cred.TokenExpireTime > 0 && time.Now().Add(margin).UnixMilli() > cred.TokenExpireTime
}
func MachineOSHeader() string {
switch runtime.GOOS {
case "darwin":
if runtime.GOARCH == "arm64" {
return "arm64_darwin"
}
return "x86_64_darwin"
case "windows":
if runtime.GOARCH == "arm64" {
return "arm64_windows"
}
return "x86_64_windows"
case "linux":
if runtime.GOARCH == "arm64" {
return "arm64_linux"
}
return "x86_64_linux"
default:
return runtime.GOARCH + "_" + runtime.GOOS
}
}
func uniquePathStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
cleaned := filepath.Clean(value)
key := strings.ToLower(cleaned)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, cleaned)
}
return out
}