local plugin = {
name = "channels",
version = "1.0.0",
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
author = "llmsproxy",
}
plugin.K = {
CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com",
CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect",
HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html",
CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains",
CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3",
QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000,
QODER_REFRESH_HOUR_UTC8 = 10,
QODER_MACHINE_ENVIRONMENT = "3",
STORE_NAME = ".channels-store.json",
B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
B64_LOOKUP = {},
SESSION_TTL = 900,
MODELS_DEV_URL = "https://models.dev/api.json",
MODELS_DEV_TTL = 6 * 3600,
EXPIRING_WINDOW_DAYS_DEFAULT = 15,
BALANCE_CACHE_TTL = 60,
DEAD_MODEL_TTL_DAYS = 30,
REMOTE_CATALOG_COOLDOWN = 30,
INJECT_PLATFORM_LIMIT = 32,
ZCODE_ORIGIN = "https://zcode.z.ai",
QODER_REGION_BASE = {
intl = "https://openapi.qoder.sh",
cn = "https://openapi.qoder.com.cn",
},
QODER_REGION_AUTH = {
intl = { auth = "https://qoder.com", client_id = "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb" },
cn = { auth = "https://qoder.cn", client_id = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa" },
},
BUDDY_REGION_HOST = {
intl = "www.workbuddy.ai",
cn = "copilot.tencent.com",
},
GEMINI_DEFAULT_PROJECT = "aicode-consumers",
GEMINI_UPSTREAM_USER_AGENT = "antigravity",
CLI_PREFIX = "You are ZCode, an interactive coding agent",
QODER_QUEUE_CODE = "10605",
}
function plugin.adaptersDir()
local pd = __llmsproxy_plugin_dir
if type(pd) ~= "string" or pd == "" then
pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or ""
end
if type(pd) ~= "string" or pd == "" then return nil end
local base = pd:gsub("/+$", "")
local parent = base:match("^(.*)/[^/]+$")
if not parent then return nil end
return parent .. "/adapters"
end
plugin.kernel = {
"add_source", "remove_source", "list_sources",
"probe_all", "reset_health", "status",
}
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__trae_catalog_probe", "__trae_headers_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
local qoderMachineIdentity, rfc3986Escape, newToken
local lobsteraiClientVersion
local expiringWindowDays
local fetchAccountJSON
local pickAccountForSource, accountBalanceRaw, balanceTier
local traeCheckinHeaders, traeFixNickname, traeIdeHeaders
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
local PLATFORMS = {
{
id = "trae",
name = "TRAE(字节)",
vendor = "bytedance",
flow = "callback",
authorize = {
base = "https://www.trae.cn/authorization",
params = {
login_version = "1", auth_from = "solo", login_channel = "native_ide",
plugin_version = "2.3.62834", auth_type = "local",
client_id = "en1oxy7wnw8j9n", redirect = "0",
auth_callback_url = "{redirect_uri}",
login_trace_id = "{trace_id}",
machine_id = "{machine_id}", device_id = "{device_id}",
x_device_id = "{device_id}", x_machine_id = "{machine_id}",
x_device_brand = "PC", x_device_type = "PC", x_os_version = "1.0",
x_app_version = "0.1.52", x_app_type = "stable",
},
},
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
models_remote = {
endpoints = { {
url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param",
method = "POST",
body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",'
.. '"builder_v3","builder","chat","inline_chat","git_ai",'
.. '"custom_agent_generation","utils","code_reviewer","code_review_summary",'
.. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",'
.. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",'
.. '"system_diagnosis"],"agent_type":"",'
.. '"current_config_info":{"config_name":"","is_custom_model":false},'
.. '"mode_type":0,"access_type":0,"ab_force_vids":"",'
.. '"ab_autotest_advanced_mode":0,"show_custom_model":true}',
sign = "trae_solo",
} },
auth = "none",
parse = "trae_batch",
admit_functions = { "solo_work_lite" },
},
base_url = "https://trae-api-cn.mchost.guru",
adapter = "channels-trae",
source_meta_from_credential = true,
models = {
"DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430",
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
"kimi-k2.6", "minimax-m3", "qwen-3.7-plus", "sagitta", "aquila",
},
models_fallback_no_supplement = true,
models_hidden = {
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
},
models_unavailable = {
"custom_model_gemini", "custom_model_placeholder", "custom_model_1M_text",
"custom_model_1M", "custom_model_kimi", "custom_model_claude",
"custom_model_gpt-5", "custom_model_no-fc", "custom_model_deepseek_chat",
"custom_model_deepseek_reasoner", "custom_model_deepseek_v4",
},
checkin = {
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim",
method = "POST", body = "{}",
auth = "cloud-ide-jwt",
},
checkin_status = {
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status",
method = "POST", body = "{}",
status_fn = function(rec)
return {
active = rec.enable == true,
todayCheckedIn = rec.checked_in == true,
dailyCredit = tonumber(rec.credits) or 0,
}
end,
},
balance = {
url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage",
method = "POST", body = '{"require_usage":true,"req_source":2}',
total_fn = function(rec)
local sum = 0
local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {}
if type(list) ~= "table" then return 0 end
for _, p in ipairs(list) do
local base = p.entitlement_base_info or p.entitlementBaseInfo
local quota = (type(base) == "table" and (base.quota or base.Quota)) or nil
local lim, amt = 0, 0
if type(quota) == "table" then
lim = tonumber(quota.credits_limit) or tonumber(quota.creditsLimit) or 0
end
if lim == 0 then
lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0
end
local usage = p.usage or p.Usage
if type(usage) == "table" then
amt = tonumber(usage.credits_amount) or tonumber(usage.creditsAmount) or 0
end
if amt == 0 then
amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0
end
if lim > 0 then sum = sum + math.max(0, lim - amt) end
end
if sum == 0 then
local us = rec.usage_summary or rec.usageSummary
if type(us) == "table" then
local tot = tonumber(us.total_amount) or tonumber(us.totalAmount) or 0
local used = tonumber(us.consumed_amount) or tonumber(us.consumedAmount) or 0
if tot > 0 then sum = math.max(0, tot - used) end
end
end
return sum
end,
},
callback = {
mode = "trae",
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
client_id = "en1oxy7wnw8j9n",
},
},
{
id = "minimax",
name = "MiniMax",
vendor = "minimax",
flow = "device-poll",
device = {
code_url = "https://account.minimax.cn/oauth2/device/code",
code_body = "client_id=mcode-public&scope=agent.default&audience=agent-backend"
.. "&code_challenge={challenge}&code_challenge_method=S256",
token_url = "https://account.minimax.cn/oauth2/token",
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
.. "&device_code={device_code}&client_id=mcode-public&code_verifier={verifier}",
interval = 5,
slow_down_guard = true,
},
hosts = { "account.minimax.cn", "agent.minimax.cn" },
base_url = "https://agent.minimax.cn",
endpoint = "/mavis/api/v1/llm/v1/messages",
models_remote = {
endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
adapter = "anthropic",
models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7" },
checkin = {
url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim",
method = "POST", body = "{}",
},
checkin_status = {
url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/status",
method = "GET",
},
balance = {
url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details",
method = "GET",
total_fn = function(rec)
if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end
local sum = 0
for _, d in ipairs(rec.details) do
sum = sum + (tonumber(d.remaining_amount) or 0)
end
return sum
end,
},
},
{
id = "cline",
name = "Cline",
vendor = "cline",
flow = "device-poll",
device = {
code_url = "https://api.workos.com/user_management/authorize/device",
code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR",
token_url = "https://api.workos.com/user_management/authenticate",
token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
.. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR",
interval = 5,
judge = "workos",
slow_down_step = 1,
},
register = {
url = "https://api.cline.bot/api/v1/auth/register",
method = "POST",
body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}',
refresh = {
style = "cline",
url = "https://api.cline.bot/api/v1/auth/refresh",
},
},
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
base_url = "https://api.cline.bot",
endpoint = "/api/v1/chat/completions",
models_remote = {
endpoints = {
{ url = "https://api.cline.bot/api/v1/ai/cline/recommended-models", method = "GET", url_auth = false },
{ url = "https://api.cline.bot/api/v1/models", method = "GET" },
},
auth = "bearer",
parse = "cline_merge",
},
adapter = "channels-cline",
free_prefix = "cline-free/",
models = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
models_free = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
credit_unit = "dollars",
balance = {
url = "https://api.cline.bot/api/v1/users/me/plan/usage-limits",
method = "GET",
total_fn = function(rec)
local d = rec.data or rec
local limits = d.limits or {}
if type(limits) ~= "table" then return 0 end
local minRemain
for _, l in ipairs(limits) do
local used = tonumber(l.percentUsed or l.percent_used) or 0
local rem = 100 - used
if not minRemain or rem < minRemain then minRemain = rem end
end
return minRemain or 0
end,
},
},
{
id = "buddy",
name = "CodeBuddy(腾讯)",
vendor = "tencent",
flow = "state-poll",
state = {
state_url = "https://{buddyhost}/v2/plugin/auth/state?platform={platform}",
token_url = "https://{buddyhost}/v2/plugin/auth/token",
account_url = "https://{buddyhost}/v2/plugin/login/account",
code_not_ready = 11217,
interval = 1,
},
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
base_url = "https://copilot.tencent.com",
endpoint = "/v2/chat/completions",
models_remote = {
endpoints = { { url = "https://copilot.tencent.com/v3/config", method = "GET" } },
auth = "bearer",
headers_fn = true,
parse = "buddy_config",
},
adapter = "buddy",
headers_fn = function(accessToken, cred)
local region = cred and tostring(cred.region or "") or ""
local isIntl = region == "intl" or (region == "" and false)
if region == "" then
isIntl = cred and cred.domain == "www.workbuddy.ai" or false
end
local h = {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Domain"] = isIntl and "www.workbuddy.ai" or "copilot.tencent.com",
["X-Product"] = "SaaS",
["X-Product-Code"] = isIntl and "workbuddy" or "codebuddy",
["User-Agent"] = isIntl and "WorkBuddyIDE/5.5.2" or "CodeBuddyIDE/1.106.1",
}
local uid = cred and (cred.user_id or cred.uid)
if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end
local eid = cred and cred.enterprise_id
if type(eid) == "string" and eid ~= "" then
h["X-Enterprise-Id"] = eid
h["X-Tenant-Id"] = eid
end
return h
end,
models = {
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview",
"glm-5.3", "glm-5.3-flash", "glm-5.2",
},
checkin_status = {
url = "https://copilot.tencent.com/v2/billing/meter/checkin-activity-status",
method = "POST", body = "{}",
status_fn = function(rec)
local d = rec.data or rec
return {
active = d.active == true,
todayCheckedIn = d.todayCheckedIn == true or d.today_checked_in == true,
streakDays = tonumber(d.streakDays or d.streak_days) or 0,
todayCredit = tonumber(d.todayCredit or d.today_credit) or 0,
}
end,
},
balance = {
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
method = "POST", body = "{}",
tier_fn = function(rec)
local data = rec.data
if type(data) ~= "table" then return nil end
local resp = data.Response
if type(resp) ~= "table" then return nil end
local inner = resp.Data
if type(inner) ~= "table" then return nil end
local accounts = inner.Accounts
if type(accounts) ~= "table" then return nil end
local now = os.time and os.time() or 0
local window = expiringWindowDays() * 86400
local expiring, permanent = 0, 0
for _, pkg in ipairs(accounts) do
if type(pkg) == "table" then
local active = true
local st = pkg.Status
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
local ded = tonumber(pkg.DeductionEndTime)
local endSec = nil
if ded and ded > 0 then
endSec = math.floor(ded / 1000)
if now >= endSec then active = false end
end
local remain = tonumber(pkg.CycleCapacityRemain) or 0
if active and remain > 0 then
if endSec and (endSec - now) < window then
expiring = expiring + remain
else
permanent = permanent + remain
end
end
end
end
return { expiring_balance = expiring, permanent_balance = permanent }
end,
total_fn = function(rec)
local data = rec.data
if type(data) ~= "table" then return nil end
local resp = data.Response
if type(resp) ~= "table" then return nil end
local inner = resp.Data
if type(inner) ~= "table" then return nil end
local accounts = inner.Accounts
if type(accounts) ~= "table" then return nil end
local now = os.time and os.time() or 0
local sum = 0
for _, pkg in ipairs(accounts) do
if type(pkg) == "table" then
local active = true
local st = pkg.Status
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
local function past(t)
if type(t) ~= "string" or t == "" then return false end
local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)")
if Y then
local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
return ts and now >= ts
end
return false
end
if past(pkg.ExpiredTime) then active = false end
local ded = tonumber(pkg.DeductionEndTime)
if ded and ded > 0 then
if now >= math.floor(ded / 1000) then active = false end
end
if active then
sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0)
end
end
end
return math.floor(sum * 100 + 0.5) / 100
end,
},
},
{
id = "gemini",
name = "Gemini(Cloud Code)",
vendor = "google",
flow = "callback",
login_url = "https://accounts.google.com/o/oauth2/v2/auth",
authorize = {
base = "https://accounts.google.com/o/oauth2/v2/auth",
params = {
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
redirect_uri = "http://localhost:{port}/oauth-callback",
response_type = "code",
scope = "openid https://www.googleapis.com/auth/cloud-platform "
.. "https://www.googleapis.com/auth/userinfo.email "
.. "https://www.googleapis.com/auth/userinfo.profile "
.. "https://www.googleapis.com/auth/cclog "
.. "https://www.googleapis.com/auth/experimentsandconfigs",
access_type = "offline",
prompt = "consent",
include_granted_scopes = "true",
},
},
callback = {
mode = "code",
token_url = "https://oauth2.googleapis.com/token",
token_body = "grant_type=authorization_code&code={code}"
.. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
.. "&client_secret=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"
.. "&redirect_uri=http://localhost:{port}/oauth-callback",
},
refresh = {
style = "google",
url = "https://oauth2.googleapis.com/token",
client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret = "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",
},
hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" },
base_url = "https://cloudcode-pa.googleapis.com",
adapter = "channels-gemini",
endpoint = "/v1internal:generateContent",
endpoint_stream = "/v1internal:streamGenerateContent?alt=sse",
models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" },
},
{
id = "lobsterai",
name = "LobsterAI(有道)",
vendor = "youdao",
flow = "callback",
login_url = "https://lobsterai.youdao.com/portal#/login",
authorize = {
hash = "https://lobsterai.youdao.com/portal#/login",
params = {
source = "electron",
redirect_uri = "{redirect_uri}",
},
},
callback = {
mode = "code",
token_url = "https://lobsterai-server.youdao.com/api/auth/exchange",
token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"{now}","uuid":"{uuid}","version":"2026.9.4"}',
},
callback_headers = {
["User-Agent"] = "LobsterAI/0.1.0",
},
hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com",
"api-overmind.youdao.com" },
client_version = {
url = "https://api-overmind.youdao.com/openapi/get/luna/hardware/lobsterai/prod/update",
path = "data.value.version",
fallback = "2026.9.4",
},
base_url = "https://lobsterai-server.youdao.com",
endpoint = "/api/proxy/v1/chat/completions",
models_remote = {
endpoints = { { url = "https://lobsterai-server.youdao.com/api/models/available", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
adapter = "openai",
models = {
"deepseek-v4-flash", "deepseek-v4-pro", "MiniMax-M3", "MiniMax-M2.7",
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus-2026-04-20",
"kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5",
"doubao-seed-2-1-pro-260628", "doubao-seed-2-1-turbo-260628",
"doubao-seed-2-0-code-preview-260215", "glm-5.2", "glm-5.1",
"glm-5v-turbo", "glm-5",
},
balance = {
url = "https://lobsterai-server.youdao.com/api/user/profile-summary",
method = "GET",
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.availableCredits or d.available_credits or d.credits or d.balance) or 0
end,
},
},
{
id = "opencode",
name = "OpenCode Zen",
vendor = "opencode",
flow = "paste",
base_url = "https://opencode.ai/zen",
endpoint = "/v1/chat/completions",
models_remote = {
endpoints = { { url = "https://opencode.ai/zen/v1/models", method = "GET",
headers = { ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
["x-opencode-client"] = "cli" } } },
auth = "none",
parse = "id_list",
},
adapter = "openai",
capability_key = "opencode",
free_source = "capability",
models = {
"big-pickle", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
"nemotron-3.5-lightning-free", "glm-5.3-flash-free",
"minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
"deepseek-v4-flash", "deepseek-v4.1-flash",
"glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max",
},
models_free = {
"big-pickle", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
"nemotron-3.5-lightning-free",
"glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
},
models_unavailable = { "jev-1.13-free", "beam-free" },
},
{
id = "codearts",
name = "CodeArts(华为)",
vendor = "huawei",
flow = "callback",
authorize = {
base = "https://codearts.huaweicloud.com/portal/authorize",
callback_path = "/oauth/callback",
min_port = 10000,
},
hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com",
"snap-access.cn-north-4.myhuaweicloud.com",
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com",
"opengw.developer.huaweicloud.com" },
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
adapter = "codearts",
retry_policy = {
on_status = { 400, 429 },
on_body_contains = { "TM.00001041", "TPM limit", "并发会话数已达上限" },
delay = "10s",
max_attempts = 18,
},
credential_shape = "full",
models_remote = {
endpoints = {
{ url = "https://opengw.developer.huaweicloud.com/api/v1/gateway/config",
method = "GET", sign = "codearts", parse = "codearts_gateway" },
{ url = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
method = "GET", sign = "codearts", parse = "codearts_builtin",
headers = { ["Content-Type"] = "application/json",
["Agent-Type"] = "PromptCenter",
["X-Language"] = "zh-cn" } },
},
auth = "none",
parse = "codearts_builtin",
},
models = { "CodeArts-Pangu", "deepseek-v4-flash", "GLM-5.3", "GLM-5.2" },
checkin = {
url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
method = "GET",
},
claim_fn_name = "claimCodeArts",
},
{
id = "qoder",
name = "Qoder",
vendor = "qoder",
flow = "device-poll",
device = {
code_url = "{auth}/device/selectAccounts",
code_method = "GET_QUERY",
auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}"
.. "&machine_id={machine_id}&client_id={client_id}",
token_url = "https://{openapi}/api/v1/deviceToken/poll",
token_method = "GET_QUERY",
poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256",
judge = "pending404",
interval = 2,
},
base_url = "https://api2-v2.qoder.sh",
endpoint = "/model/v1/chat/completions",
adapter = "channels-qoder",
models = {
"auto", "ultimate", "performance", "efficient", "smodel", "cmodel",
"qmodel_38max", "qfmodel", "qmodel_latest", "qmodel",
"kmodel_latest", "kmodel", "gmodel", "gfmodel",
"dmodel", "dfmodel", "mmodel",
"q37fmodel", "gm51model",
},
headers_fn = function(accessToken, cred)
local h = {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Cosy-ClientType"] = "10",
}
local id = qoderMachineIdentity()
if id then
h["Cosy-MachineToken"] = id.token
h["Cosy-MachineType"] = id.type
end
return h
end,
balance = {
url = "https://openapi.qoder.sh/sash/api/v2/me/usage",
method = "GET",
total_fn = function(rec)
local d = rec.data or rec
local u = (d.userQuota or {}).remaining or 0
local a = (d.addOnQuota or {}).remaining or 0
return (tonumber(u) or 0) + (tonumber(a) or 0)
end,
},
},
{
id = "zcode",
name = "ZCode",
vendor = "zcode",
flow = "init-poll",
initpoll = {
init_url = "https://zcode.z.ai/api/v1/oauth/cli/init",
poll_url = "https://zcode.z.ai/api/v1/oauth/cli/poll/{flow_id}",
provider = "bigmodel",
app_version = "3.14.3",
origin = "https://zcode.z.ai",
interval = 2,
},
base_url = "https://zcode.z.ai",
models_remote = {
endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET",
query = "app_version=1.0.0&platform=unknown" } },
auth = "none",
parse = "zcode_configs",
},
endpoint = "/api/v1/zcode-plan/anthropic/v1/messages",
adapter = "channels-zcode",
balance = {
url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance",
method = "GET",
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.balance or d.remaining or d.credits or d.total)
end,
},
models = { "GLM-5.3", "GLM-5.3-Flash" },
},
{
id = "loomy",
name = "Loomy",
vendor = "loomy",
flow = "wechat-poll",
wechat = {
auth_url = "https://open.weixin.qq.com/connect/qrconnect"
.. "?appid=wx18d60be432287cf8&scope=snsapi_login&response_type=code"
.. "&redirect_uri=https%3A%2F%2Floomy.xunfei.cn%2Foauth%2Fwechat%2Fcallback"
.. "&state={state}#wechat_redirect",
qr_url = "https://open.weixin.qq.com/connect/qrcode/{uuid}",
poll_url = "https://long.open.weixin.qq.com/connect/l/qrconnect",
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
.. " (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
account_base = "https://account.xfinfr.com",
bind_path = "/login/thirdAccount/bind/auth",
access_key_id = "2thryby66wxi53sk",
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
app_id = "GM3LOOMY",
},
base_url = "https://loomyad.xunfei.cn/api/v1",
endpoint = "/chat/completions",
adapter = "openai",
models = {
"deepseek-v4-flash-0731", "MiniMax-M3", "Kimi-k2.6", "qwen-3.8-max",
"GLM-5.3-Flash", "qwen3.8-flash", "spark-x", "mimo-v2.5",
},
},
{
id = "raccoon",
name = "Raccoon(商汤小浣熊)",
vendor = "sensetime",
flow = "qr-poll",
qr = {
api_base = "https://xiaohuanxiong.com",
auth_prefix = "/api/web/auth/v1",
poll_path = "/login_with_qrcode_code",
create_path = "/api/iam/v1/auth/mp/qrcode",
iam_poll_path = "/api/iam/v1/auth/mp/qrcode/login",
scan_page = "/login",
login_page = "/login/mp",
app_name = "商汤小浣熊官网",
source = "platform",
interval = 2,
},
base_url = "https://xiaohuanxiong.com",
endpoint = "/api/web/llm/v2/chat/completions",
adapter = "openai",
models = {
"sn-sensenova-6-8-flash", "sn-sensenova-6-8-flash-lite",
"sn-glm-5-3", "sn-kimi-k3", "sn-glm-5-3-flash",
"sn-deepseek-v4-1-flash",
},
balance = {
url = "https://xiaohuanxiong.com/api/web/points/v1/balance",
method = "GET",
headers_fn = function(accessToken, cred)
return {
["Authorization"] = "Bearer " .. accessToken,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Org-Code"] = (cred and cred.office_identity) or "",
["X-Raccoon-Language"] = "zh",
}
end,
total_fn = function(rec)
local d = rec.data or rec
return tonumber(d.available_points or d.availablePoints or d.points)
end,
},
onboarding = { reward_points = 3000 },
},
}
local function codeartsDateStamp()
return os.date("!%Y%m%dT%H%M%SZ")
end
local function codeartsSignedRequest(method, url, cred, body)
local ak = cred and (cred.access_key_id or cred.access_key) or ""
local sk = cred and (cred.secret_access_key or cred.secret_key) or ""
local st = cred and (cred.security_token or "") or ""
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
local host = url:match("^https?://([^/]+)")
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
local query = url:match("%?([^#]*)$") or ""
local payload = body or ""
local payloadHash = sha256_hex(payload)
local dateStamp = codeartsDateStamp()
local h = {
["host"] = host,
["x-sdk-date"] = dateStamp,
["x-sdk-content-sha256"] = payloadHash,
["x-security-token"] = st,
}
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
local keys = {}
for k in pairs(h) do keys[#keys + 1] = k end
table.sort(keys)
local lines, signedNames = {}, {}
for _, k in ipairs(keys) do
lines[#lines + 1] = k .. ":" .. h[k]
signedNames[#signedNames + 1] = k
end
local signedHeaders = table.concat(signedNames, ";")
local canonical = table.concat({
method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash,
}, "\n")
local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical)
local signature = hmac_sha256_hex(sk, stringToSign)
local out = {}
for _, k in ipairs(keys) do
if k ~= "host" then out[k] = h[k] end
end
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
.. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature
out["Agent-Type"] = "PromptCenter"
out["X-Language"] = "zh-cn"
return { url = url, method = method:upper(), headers = out, body = payload,
canonical = canonical, string_to_sign = stringToSign,
signed_headers = signedHeaders }
end
local function codeartsAccountInfo(cred)
local req, err = codeartsSignedRequest("GET", plugin.K.CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
if not req then return nil, err end
local resp, herr = __llmsproxy_http(req)
if herr or not resp or resp.status ~= 200 then
return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status))
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end
local d = rec.data or rec
local metrics = d.metrics or {}
local total = nil
for _, m in ipairs(metrics) do
if type(m) == "table" and m.name == "usageTotalPackageCredit" then
total = tonumber(m.value) or tonumber(m.credit) or total
end
end
local billing = d.billingMode or d.billing_mode or d.chargingMode or ""
return {
isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil,
isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil,
total = total,
raw = d,
}, nil
end
local function codeartsClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end
local info, ierr = codeartsAccountInfo(cred)
if not info then return nil, ierr end
if not info.isCreditPackage then
return { done = true, kind = "inactive",
message = info.isTokenPackage and "Token 计费账户,不在积分活动范围"
or "非积分计费账户,不在积分活动范围" }
end
local areq = codeartsSignedRequest("GET",
plugin.K.CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
if not areq then return nil, "活动列表请求构造失败" end
local aresp, aerr = __llmsproxy_http(areq)
if aerr or not aresp or aresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) }
end
local aok, arec = pcall(json.decode, aresp.body or "")
local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil
if type(items) ~= "table" then
return { done = true, kind = "failed", message = "活动列表响应缺少 items" }
end
local act = nil
for _, it in ipairs(items) do
if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end
end
if not act then
return { done = true, kind = "inactive", message = "未找到每日签到活动" }
end
if act.claimable ~= true then
local st = tostring(act.status or "")
local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED"
return { done = true, kind = claimed and "already-claimed" or "inactive",
message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") }
end
local campaignId = act.campaignId or act.campaign_id or ""
if campaignId == "" then
return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" }
end
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
local creq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
if not creq then return nil, "claim 请求构造失败" end
local cresp, cerr = __llmsproxy_http(creq)
if cerr or not cresp or cresp.status ~= 200 then
return { done = true, kind = "failed",
message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) }
end
local msg = "已领取"
local cok, crec = pcall(json.decode, cresp.body or "")
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
if type(benefit) == "table" and benefit.id then
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
local freq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
if freq then
local fresp, ferr = __llmsproxy_http(freq)
if ferr or not fresp or fresp.status ~= 200 then
msg = "已领取(确认步骤失败,可能停在待确认)"
end
end
end
return { done = true, kind = "success", message = msg }
end
local function hexToBin(h)
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
end
local function b64url(s)
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
end
local function b64urlRaw(bin)
local e = base64_encode(bin)
return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
end
local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local b64rev = {}
for i = 1, #b64chars do b64rev[b64chars:sub(i, i)] = i - 1 end
local function b64urlDecode(s)
s = tostring(s or ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
local out, buf, nbits = {}, 0, 0
for i = 1, #s do
local v = b64rev[s:sub(i, i)]
if v then
buf = buf * 64 + v
nbits = nbits + 6
while nbits >= 8 do
nbits = nbits - 8
out[#out + 1] = string.char(math.floor(buf / (2 ^ nbits)) % 256)
buf = buf % (2 ^ nbits)
end
end
end
return table.concat(out)
end
local function derLen(n)
if n < 128 then return string.char(n) end
local bytes = {}
while n > 0 do
table.insert(bytes, 1, string.char(n % 256))
n = math.floor(n / 256)
end
return string.char(0x80 + #bytes) .. table.concat(bytes)
end
local function derTLV(tag, value)
return tag .. derLen(#value) .. value
end
local function codeartsKeyFromJwk(jwk)
local d = b64urlDecode(jwk.d)
local x = b64urlDecode(jwk.x)
local y = b64urlDecode(jwk.y)
if #d ~= 32 or #x ~= 32 or #y ~= 32 then
return nil, "DPoP 私钥 JWK 字段长度异常(d=" .. #d .. " x=" .. #x .. " y=" .. #y .. ")"
end
local oid = "\6\8*\134H\206=\3\1\7"
local pub = "\4" .. x .. y
local body =
derTLV("\2", "\1") ..
derTLV("\4", d) ..
derTLV("\160", oid) ..
derTLV("\161", derTLV("\3", "\0" .. pub))
local der = derTLV("\48", body)
local b64 = base64_encode(der)
local lines = { "-----BEGIN EC PRIVATE KEY-----" }
for i = 1, #b64, 64 do lines[#lines + 1] = b64:sub(i, i + 63) end
lines[#lines + 1] = "-----END EC PRIVATE KEY-----"
return {
pem = table.concat(lines, "\n") .. "\n",
public = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y) },
private = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y),
d = b64urlRaw(d) },
}, nil
end
local CODEARTS = {
portal_authorize = "https://codearts.huaweicloud.com/portal/authorize",
sts_token = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens",
client_id = "codearts-agent",
redirect_path = "/oauth/callback",
login_plugin_name = "snap_AIIDE",
login_plugin_version = "5.2.0",
oauth_theme = "2",
oauth_locale = "zh-cn",
}
local function codeartsDpopJws(key, htm, htu)
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
local payload = json.encode({ htm = htm, htu = htu,
iat = (os.time and os.time() or 0),
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
local pem = dir .. "/k.pem"
local dh = hexToBin((key.private.d:gsub("-", "+"):gsub("_", "/") ..
string.rep("=", (4 - #key.private.d % 4) % 4)))
os.execute("rm -rf " .. dir)
return nil, "内部错误:DPoP 签名需与密钥同时生成"
end
local function codeartsNewDpop()
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
local pem = dir .. "/k.pem"
os.execute("openssl ecparam -name prime256v1 -genkey -noout -out " .. pem .. " 2>/dev/null")
local f = io.open(pem, "r")
if not f then os.execute("rm -rf " .. dir) return nil, "openssl EC 生成失败" end
local pemTxt = f:read("*a")
f:close()
local dh = io.popen("openssl asn1parse -in " .. pem
.. " 2>/dev/null | grep -m1 'OCTET STRING' | awk -F': ' '{print $NF}'")
local dHex = dh and (dh:read("*a") or ""):gsub("%s", "") or ""
if dh then dh:close() end
local ph = io.popen("openssl ec -in " .. pem
.. " -text -noout 2>/dev/null | awk '/^pub:/{f=1;next} /^ASN1/{f=0} f' | tr -d ' :' | tr -d '\n'")
local pubHex = ph and (ph:read("*a") or ""):gsub("%s", "") or ""
if ph then ph:close() end
if #pubHex < 130 then
local pf = io.popen("openssl ec -in " .. pem
.. " -pubout -outform DER 2>/dev/null | tail -c 65 | od -An -tx1 | tr -d ' \n'")
pubHex = pf and (pf:read("*a") or ""):gsub("%s", "") or ""
if pf then pf:close() end
end
if #dHex < 64 or #pubHex < 130 then
os.execute("rm -rf " .. dir)
return nil, "无法解析 EC 密钥(d=" .. #dHex .. " pub=" .. #pubHex .. ")"
end
local x = pubHex:sub(3, 66)
local y = pubHex:sub(67, 130)
local key = {
pem = pemTxt,
public = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)), y = b64urlRaw(hexToBin(y)) },
private = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)),
y = b64urlRaw(hexToBin(y)), d = b64urlRaw(hexToBin(dHex:sub(-64))) },
}
os.execute("rm -rf " .. dir)
return key, nil
end
local function codeartsDerToRawSig(der)
local i = 1
if der:byte(i) ~= 0x30 then return nil end
i = i + 1
local l = der:byte(i)
if l and l > 0x80 then i = i + (l - 0x80) end
i = i + 1
if der:byte(i) ~= 0x02 then return nil end
i = i + 1
local rlen = der:byte(i); i = i + 1
local r = der:sub(i, i + rlen - 1); i = i + rlen
if der:byte(i) ~= 0x02 then return nil end
i = i + 1
local slen = der:byte(i); i = i + 1
local s = der:sub(i, i + slen - 1)
local function pad(v)
while #v > 32 and v:byte(1) == 0 do v = v:sub(2) end
if #v < 32 then v = string.rep("\0", 32 - #v) .. v end
return v
end
return pad(r) .. pad(s)
end
local function codeartsDpopSign(key, htm, htu)
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
local payload = json.encode({ htm = htm, htu = htu,
iat = (os.time and os.time() or 0),
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
local dir = "/tmp/llmsproxy-dpopsig-" .. tostring(math.random(1e12))
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
local pemPath, inPath = dir .. "/k.pem", dir .. "/in"
local pf = io.open(pemPath, "w")
if not pf then os.execute("rm -rf " .. dir) return nil, "无法写临时 PEM" end
pf:write(key.pem)
pf:close()
local inf = io.open(inPath, "w")
if not inf then os.execute("rm -rf " .. dir) return nil, "无法写签名输入" end
inf:write(signingInput)
inf:close()
local h = io.popen("openssl dgst -sha256 -sign " .. pemPath .. " " .. inPath
.. " 2>/dev/null | od -An -tx1 | tr -d ' \n'")
local derHex = h and (h:read("*a") or ""):gsub("%s", "") or ""
if h then h:close() end
os.execute("rm -rf " .. dir)
if derHex == "" then return nil, "openssl 签名失败" end
local raw = codeartsDerToRawSig(hexToBin(derHex))
if not raw then return nil, "DER→raw 转换失败" end
return signingInput .. "." .. b64urlRaw(raw), nil
end
local function codeartsRequestToken(body, key)
local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS.sts_token)
if not dpop then return nil, derr end
local parts = {}
for k, v in pairs(body) do
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
end
local resp, err = __llmsproxy_http({
url = CODEARTS.sts_token, method = "POST",
headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" },
body = table.concat(parts, "&"),
})
if err then return nil, "network error: " .. tostring(err) end
if not resp then return nil, "无响应" end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = nil end
if resp.status ~= 200 or type(rec) ~= "table" or type(rec.credentials) ~= "table" then
local code = tostring(rec and rec.error_code or "")
local emsg = tostring(rec and (rec.error_msg or rec.error) or "")
local terminal = (rec and rec.error == "invalid_grant")
or code:find("ExpiredRefreshToken", 1, true) ~= nil
return nil, "token 请求失败: HTTP " .. tostring(resp.status) .. " " .. code .. " " .. emsg,
terminal
end
return rec, nil
end
local function codeartsCredentialFrom(token, pkce, key)
local c = token.credentials or {}
return {
access_key_id = c.access_key_id or "",
secret_access_key = c.secret_access_key or "",
security_token = c.security_token or "",
expires_at = c.expiration or "",
refresh_token = token.refresh_token,
code_verifier = pkce.verifier,
dpop_private_key_jwk = key.private,
}
end
local function refreshCodeArts(plat, cred)
local rtoken = cred.refresh_token
local verifier = cred.code_verifier
if type(rtoken) ~= "string" or rtoken == "" then
return nil, "无 refresh_token,需重新登录"
end
if type(verifier) ~= "string" or verifier == "" then
return nil, "无 code_verifier,需重新登录"
end
local jwk = cred.dpop_private_key_jwk
if type(jwk) ~= "table" then return nil, "无 DPoP 私钥,需重新登录" end
local key, kerr = codeartsKeyFromJwk(jwk)
if not key then return nil, tostring(kerr) end
local token, terr = codeartsRequestToken({
client_id = CODEARTS.client_id,
code_verifier = verifier,
grant_type = "refresh_token",
refresh_token = rtoken,
}, key)
if not token then return nil, tostring(terr) end
if type(token.refresh_token) ~= "string" or token.refresh_token == "" then
token.refresh_token = rtoken
end
return codeartsCredentialFrom(token, { verifier = verifier }, key)
end
local function buildCodeartsAuthorizeUrl(port, pkce, ticketId)
return CODEARTS.portal_authorize
.. "?theme=" .. CODEARTS.oauth_theme
.. "&locale=" .. CODEARTS.oauth_locale
.. "&uri_scheme=" .. CODEARTS.client_id
.. "&client_id=" .. CODEARTS.client_id
.. "&port=" .. tostring(port)
.. "&code_challenge=" .. pkce.challenge
.. "&code_challenge_method=SHA-256"
.. "&ticket_id=" .. rfc3986Escape(ticketId)
.. "&plugin-name=" .. CODEARTS.login_plugin_name
.. "&plugin-version=" .. CODEARTS.login_plugin_version
end
local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
local url = CODEARTS_CREDENTIAL_ENDPOINT .. "?ticket_id=" .. rfc3986Escape(ticketId)
.. "&secret=" .. rfc3986Escape(secret)
for _ = 1, 120 do
local resp = __llmsproxy_http({
url = url, method = "GET",
headers = { ["Content-Type"] = "application/json;charset=UTF-8",
["plugin-name"] = pluginName or plugin.K.CODEARTS_TICKET_PLUGIN_NAME,
["plugin-version"] = pluginVersion or plugin.K.CODEARTS_TICKET_PLUGIN_VERSION },
})
if resp and resp.status == 200 then
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
local c = rec.credential
if type(c) == "table" and c.access and (c.securitytoken or c.securityToken) then
return { access_key_id = c.access, secret_access_key = c.secret or "",
security_token = c.securitytoken or c.securityToken,
expires_at = c.expires_at or c.expiresAt or "",
domain_id = rec.domain_id or "", user_id = rec.user_id or "",
user_name = rec.user_name or "" }
end
local r = rec.result
if type(r) == "table" and r.accessKeyId and r.securityToken then
return { access_key_id = r.accessKeyId, secret_access_key = r.secretAccessKey or "",
security_token = r.securityToken,
expires_at = r.expiration or r.expiresAt or "" }
end
end
end
os.execute("sleep 1")
end
return nil, "CodeArts 登录超时"
end
local CODEARTS_CREDENTIAL_ENDPOINT =
"https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket"
local function codeartsPkcePair()
local h = io.popen("openssl rand 48 2>/dev/null | base64 -w0")
local raw = h and h:read("*a") or ""
if h then h:close() end
raw = (raw:gsub("%s", ""))
local verifier = (raw:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
if #verifier < 43 then
return nil, "无法生成 PKCE verifier"
end
local challenge = b64urlRaw(hexToBin(sha256_hex(verifier)))
return { verifier = verifier, challenge = challenge }
end
QODER_SASH_BASE = "https://openapi.qoder.sh"
local function qoderRegionBase(cred)
local region = type(cred) == "table" and tostring(cred.region or "") or ""
if region == "" then region = "intl" end
if QODER_SASH_BASE ~= "https://openapi.qoder.sh" then
return QODER_SASH_BASE
end
return plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE
end
local function qoderCampaignRefreshed(nowMs)
nowMs = nowMs or ((os.time and os.time() or 0) * 1000)
local utc8 = os.date("!*t", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
return (utc8.hour or 0) >= plugin.K.QODER_REFRESH_HOUR_UTC8
end
local function qReadNum(src, key)
if type(src) ~= "table" then return nil end
local v = src[key]
if type(v) == "number" then return v end
if type(v) == "string" and v ~= "" then return tonumber(v) end
return nil
end
local function qReadStr(src, key)
if type(src) ~= "table" then return nil end
local v = src[key]
if type(v) == "string" and v ~= "" then return v end
return nil
end
local function qoderParseCampaigns(body)
if type(body) ~= "table" or type(body.campaigns) ~= "table" then
if type(body) ~= "table" then return nil end
end
local out = { campaigns = {}, claimable = body.claimable == true, showCampaign = body.showCampaign == true }
for _, it in ipairs(body.campaigns or {}) do
local id = qReadStr(it, "campaignId")
if id then
out.campaigns[#out.campaigns + 1] = {
campaignId = id,
actionType = qReadStr(it, "actionType"),
claimStatus = qReadStr(it, "claimStatus"),
amount = qReadNum(it.benefit, "amount"),
}
end
end
return out
end
local function qoderLoadCampaigns(plat, cred)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
if not hdrs then
hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" }
end
local resp, err = __llmsproxy_http({
url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs,
})
if err then return nil, "活动列表查询失败: " .. tostring(err) end
if not resp or resp.status ~= 200 then
return nil, "活动列表查询失败: HTTP " .. tostring(resp and resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "活动列表响应无法解析" end
return qoderParseCampaigns(rec), nil
end
local function qoderUsageRaw(plat, cred)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
local resp = nil
if hdrs then
resp, _ = __llmsproxy_http({ url = qoderRegionBase(cred) .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs })
end
if not resp or resp.status ~= 200 then return nil end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil end
return rec
end
local function qoderClaimCampaign(plat, cred, campaignId)
local hdrs = nil
if plat.headers_fn then
local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred)
if okh and type(h) == "table" then hdrs = h end
end
if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end
hdrs["Content-Type"] = "application/json"
local resp, err = __llmsproxy_http({
url = qoderRegionBase(cred) .. "/sash/api/v1/me/campaigns/"
.. rfc3986Escape(campaignId) .. "/claim",
method = "POST", headers = hdrs, body = "",
})
if err then return { kind = "failed", message = tostring(err) } end
if not resp or resp.status ~= 200 then
return { kind = "failed", message = "领取失败: HTTP " .. tostring(resp and resp.status) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return { kind = "failed", message = "领取响应无法解析" } end
if rec.replayed == true then return { kind = "already-claimed", message = "今天已领取" } end
local st = rec.status
if st ~= nil and st ~= "CLAIMED" then
return { kind = "failed", message = "领取未成功(status=" .. tostring(st) .. ")" }
end
local amount = qReadNum(rec, "amount") or qReadNum(rec.benefit, "amount") or 0
return { kind = "claimed", amount = amount }
end
local function qoderClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then
if type(tok) == "string" and tok ~= "" then
cred = { access_token = tok }
else
return nil, "缺少凭据对象"
end
end
local parsed, lerr = qoderLoadCampaigns(plat, cred)
if not parsed then return { done = true, kind = "failed", message = lerr } end
local refreshed = qoderCampaignRefreshed()
local targets = {}
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMABLE" then
targets[#targets + 1] = c
end
end
if #targets == 0 then
local claimedBefore = false
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMED" then claimedBefore = true break end
end
if claimedBefore then
if not refreshed then
return { done = true, kind = "inactive", covers_today = false,
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
end
return { done = true, kind = "already-claimed", covers_today = true,
message = "今天已领取" }
end
local usage = qoderUsageRaw(plat, cred)
local hasBenefit = false
for _, c in ipairs(parsed.campaigns) do
if c.actionType == "CLAIM_BENEFIT" then hasBenefit = true break end
end
local addOnMissing = false
if type(usage) == "table" then
local d = type(usage.data) == "table" and usage.data or usage
addOnMissing = (d.addOnQuota == nil)
end
if (not hasBenefit) and addOnMissing then
return { done = true, kind = "inactive", action_required = true,
message = "该账号尚未在 Qoder 侧开通每日领取(每日 100 Credits)。"
.. "请先用 Qoder 官方客户端登录一次该账号,开通后再回来领取。" }
end
return { done = true, kind = "inactive", message = "当前没有可领取的活动" }
end
local total, firstErr, anyClaimed, allReplayed = 0, nil, false, true
for _, t in ipairs(targets) do
local r = qoderClaimCampaign(plat, cred, t.campaignId)
if r.kind == "claimed" then
anyClaimed = true
allReplayed = false
total = total + (tonumber(r.amount) or 0)
elseif r.kind == "already-claimed" then
else
allReplayed = false
if not firstErr then firstErr = r.message end
end
end
if anyClaimed then
return { done = true, kind = "success", message = "已领取 " .. tostring(total) .. " Credits" }
end
if allReplayed then
return { done = true, kind = "already-claimed", message = "今天已领取" }
end
return { done = true, kind = "failed", message = firstErr or "领取失败" }
end
local function qoderMachineCache()
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
return plugin.state.qoderMachine
end
local function qoderParseMachineFile(path)
local f = io.open(path, "r")
if not f then return nil end
local raw = f:read("*a")
f:close()
local ok, rec = pcall(json.decode, raw or "")
if not ok or type(rec) ~= "table" then return nil end
local token, typ = rec.token, rec.type
if type(token) ~= "string" or token == "" then return nil end
if type(typ) ~= "string" or typ == "" then return nil end
return { token = token, type = typ }
end
local function qoderMachineCandidatePaths()
local out = {}
local override = os.getenv and os.getenv("QODER_MACHINE_TOKEN_PATH")
if type(override) == "string" and override ~= "" then return { override } end
local appdata = os.getenv and os.getenv("APPDATA")
if type(appdata) == "string" and appdata ~= "" then
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
out[#out + 1] = appdata .. "/" .. d .. "/SharedClientCache/cache/machine_token.json"
end
end
local home = os.getenv and (os.getenv("HOME") or os.getenv("USERPROFILE")) or nil
if type(home) == "string" and home ~= "" then
for _, d in ipairs({ "Qoder", "Qoder CN" }) do
out[#out + 1] = home .. "/Library/Application Support/" .. d
.. "/SharedClientCache/cache/machine_token.json"
out[#out + 1] = home .. "/.config/" .. d .. "/SharedClientCache/cache/machine_token.json"
end
end
return out
end
local function qoderFindRuntimeInfo()
if not (os.getenv and os.getenv("HOME")) then return nil end
local exeName = "runtime-info"
for _, dirName in ipairs({ ".qoder", ".qoder-cn" }) do
local binDir = os.getenv("HOME") .. "/" .. dirName .. "/.bin"
local h = io.popen("ls -1 " .. binDir .. " 2>/dev/null")
if h then
local found
for line in h:lines() do
if line:sub(1, 5) == "umid-" then found = line break end
end
h:close()
if found then
local exe = binDir .. "/" .. found .. "/" .. exeName
local f = io.open(exe, "rb")
if f then f:close() return exe end
end
end
end
return nil
end
local function qoderReadFromRuntimeInfo()
local exe = qoderFindRuntimeInfo()
if not exe then return nil end
local tmp = "/tmp/llmsproxy-qoder-stdin-" .. tostring(math.random(1e12))
local f = io.open(tmp, "w")
if not f then return nil end
f:write('{"account":""}')
f:close()
local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format(
exe, plugin.K.QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(tmp)
if out == "" then return nil end
local i = out:find("{", 1, true)
if not i then return nil end
local ok, rec = pcall(json.decode, out:sub(i))
if not ok or type(rec) ~= "table" then return nil end
local token, typ = rec.machineToken, rec.machineType
if type(token) ~= "string" or token == "" then return nil end
if type(typ) ~= "string" or typ == "" then return nil end
return { token = token, type = typ }
end
qoderMachineIdentity = function()
local c = qoderMachineCache()
if c.done then return c.identity end
local id = qoderReadFromRuntimeInfo()
if not id then
for _, path in ipairs(qoderMachineCandidatePaths()) do
id = qoderParseMachineFile(path)
if id then break end
end
end
c.done = true
c.identity = id
return id
end
LOBSTERAI_ACTIVITY_BASE = "https://lobsterai-server.youdao.com"
local function lobsteraiActivityHeaders(cred)
return {
["Authorization"] = "Bearer " .. tostring(cred.access_token or ""),
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["User-Agent"] = "LobsterAI/0.1.0",
}
end
local function lobsteraiClaim(plat, tok, args)
local cred = (args and args.credential) or nil
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
local hdrs = lobsteraiActivityHeaders(cred)
local cver = lobsteraiClientVersion(plat)
local slotURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/slot?placement=desktop_sidebar"
.. "&clientVersion=" .. rfc3986Escape(cver)
.. "&containerApiVersion=2&platform=win32"
local sresp, serr = __llmsproxy_http({ url = slotURL, method = "GET", headers = hdrs })
if serr or not sresp or sresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动槽位查询失败: " .. tostring(serr or (sresp and sresp.status)) }
end
local sok, srec = pcall(json.decode, sresp.body or "")
if not sok or type(srec) ~= "table" or srec.code ~= 0 or type(srec.data) ~= "table" then
return { done = true, kind = "failed", message = "活动槽位信封异常" }
end
local act = type(srec.data.activity) == "table" and srec.data.activity or {}
local slotState = tostring(srec.data.slotState or "")
local activityCode = tostring(act.activityCode or "")
local configRevision = tonumber(act.configRevision) or 0
if slotState ~= "available" or activityCode == "" then
return { done = true, kind = "inactive", message = "无可用活动(slotState=" .. slotState .. ")" }
end
local ctxURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
.. rfc3986Escape(activityCode) .. "/context?configRevision=" .. tostring(configRevision)
local cresp, cerr = __llmsproxy_http({ url = ctxURL, method = "GET", headers = hdrs })
if cerr or not cresp or cresp.status ~= 200 then
return { done = true, kind = "failed",
message = "活动上下文查询失败: " .. tostring(cerr or (cresp and cresp.status)) }
end
local cok, crec = pcall(json.decode, cresp.body or "")
if not cok or type(crec) ~= "table" or crec.code ~= 0 or type(crec.data) ~= "table" then
return { done = true, kind = "failed", message = "活动上下文信封异常" }
end
local state = type(crec.data.state) == "table" and crec.data.state or {}
if state.claimedToday == true then
return { done = true, kind = "already-claimed", message = "今天已签到" }
end
local hasCheckIn = false
for _, a in ipairs(crec.data.actions or {}) do
if a == "check_in" then hasCheckIn = true break end
end
if not hasCheckIn then
return { done = true, kind = "inactive", message = "当前不可签到" }
end
local body = json.encode({ configRevision = configRevision,
idempotencyKey = newToken():sub(1, 8) .. "-" .. newToken():sub(9, 12) .. "-4"
.. newToken():sub(14, 16) .. "-8" .. newToken():sub(18, 20) .. "-" .. newToken():sub(21, 32),
payload = {} })
local url = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/"
.. rfc3986Escape(activityCode) .. "/actions/check_in"
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = hdrs, body = body })
if err or not resp or resp.status ~= 200 then
return { done = true, kind = "failed",
message = "领取请求失败: " .. tostring(err or (resp and resp.status)) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or rec.code ~= 0 then
local why = (ok and type(rec) == "table") and tostring(rec.msg or rec.message or rec.code) or "信封异常"
return { done = true, kind = "failed", message = "领取失败: " .. why }
end
local result = type(rec.data) == "table" and type(rec.data.result) == "table" and rec.data.result or {}
local credit = tonumber(result.creditsGranted) or tonumber(result.rewardCredits) or tonumber(result.credits)
local msg = "已签到"
if credit then msg = msg .. ",获得 " .. tostring(credit) .. " 积分" end
return { done = true, kind = "success", message = msg }
end
do
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then
p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end
break
end
end
for _, p in ipairs(PLATFORMS) do
if p.id == "lobsterai" then
p.claim_fn = function(plat, tok, args) return lobsteraiClaim(plat, tok, args) end
break
end
end
for _, p in ipairs(PLATFORMS) do
if p.id == "qoder" then
p.claim_fn = function(plat, tok, args) return qoderClaim(plat, tok, args) end
break
end
end
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then
p.refresh_fn = function(plat, cred) return refreshCodeArts(plat, cred) end
break
end
end
end
local function declaredHosts()
local seen, out = {}, {}
for _, p in ipairs(PLATFORMS) do
for _, h in ipairs(p.hosts or {}) do
local hn = h:lower()
if not seen[hn] then seen[hn] = true out[#out+1] = hn end
end
end
for _, p in ipairs(PLATFORMS) do
local h = (p.base_url or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
for _, p in ipairs(PLATFORMS) do
local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
local function addHost(u)
local h = (u or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
for _, p in ipairs(PLATFORMS) do
if p.wechat then
addHost(p.wechat.auth_url); addHost(p.wechat.qr_url)
addHost(p.wechat.poll_url); addHost(p.wechat.account_base)
end
if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end
if p.qr then addHost(p.qr.api_base) end
if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end
if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end
if p.id == "buddy" then addHost("https://www.workbuddy.ai") end
if p.id == "qoder" then
addHost("https://qoder.cn"); addHost("https://openapi.qoder.com.cn")
end
if p.models_remote then
for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end
end
if p.capability_key then addHost("https://models.dev/api.json") end
if p.balance then addHost(p.balance.url) end
if p.checkin_status then addHost(p.checkin_status.url) end
if p.callback then addHost(p.callback.exchange_url) end
end
table.sort(out)
return out
end
plugin.hosts = declaredHosts()
local function bodyOf(req)
if not req or not req.body or req.body == "" then return {} end
if type(req.body) == "table" then return req.body end
local ok, v = pcall(json.decode, req.body)
if not ok or type(v) ~= "table" then return {} end
return v
end
rfc3986Escape = function(v)
v = tostring(v or "")
v = v:gsub("([^A-Za-z0-9%-%._~])", function(c)
return string.format("%%%02X", string.byte(c))
end)
v = v:gsub("!", "%%21"):gsub("'", "%%27"):gsub("%(", "%%28")
:gsub("%)", "%%29"):gsub("%*", "%%2A")
return v
end
local function opensslDigest(mode, data, hmacKey)
local inPath = "/tmp/llmsproxy-sig-in-" .. tostring(math.random(1e12))
local f = io.open(inPath, "wb")
if not f then return nil, "无法写临时文件" end
f:write(data)
f:close()
local cmd
if hmacKey and hmacKey ~= "" then
local keyPath = inPath .. ".key"
local kf = io.open(keyPath, "wb")
if not kf then os.remove(inPath) return nil, "无法写密钥文件" end
kf:write(hmacKey)
kf:close()
cmd = ('openssl dgst -%s -hmac "$(cat %s)" -binary %s 2>/dev/null | base64 -w0')
:format(mode, keyPath, inPath)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(keyPath)
os.remove(inPath)
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
end
cmd = ("openssl dgst -%s -binary %s 2>/dev/null | base64 -w0"):format(mode, inPath)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(inPath)
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
end
local function loomyContentMd5(body)
if not body or body == "" then return "" end
return opensslDigest("md5", body, nil) or ""
end
local function loomySigningString(method, path, query, body, contentType, date, nonce)
local md5 = loomyContentMd5(body)
return table.concat({
method:upper(), path, query or "", md5, contentType or "",
date, nonce, "", "",
}, "\n")
end
local function loomyAuthHeaders(ak, aks, method, path, body, contentType)
local date = os.date("!%a, %d %b %Y %H:%M:%S GMT")
local nonce = string.format("%s-%s-%s",
sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))):sub(1, 8),
sha256_hex(tostring(math.random(1e12))):sub(1, 4),
sha256_hex(tostring(math.random(1e12))):sub(1, 12))
local ctype = contentType or "application/json"
local sts = loomySigningString(method, path, "", body, ctype, date, nonce)
local sig, err = opensslDigest("sha1", sts, aks)
if not sig then return nil, err or "签名计算失败" end
local h = {
["Authorization"] = "account " .. ak .. ":" .. sig,
["Date"] = date,
["Nonce"] = nonce,
["Content-Type"] = ctype,
}
local md5 = loomyContentMd5(body)
if md5 ~= "" then h["Content-MD5"] = md5 end
return h
end
local function devicePkce()
local v = sha256_hex(tostring(os.time and os.time() or 0) .. math.random(1e12))
.. sha256_hex(tostring(math.random(1e12)))
return v, b64url(hexToBin(sha256_hex(v)))
end
local function deviceOneStep(plat, sess)
local d = plat.device
local url, method, body = d.token_url, d.token_method or "POST", nil
if type(d.token_url) == "string" and d.token_url:find("{openapi}", 1, true) then
local region = tostring(sess.region or "intl")
d = setmetatable({ token_url = (d.token_url:gsub("{openapi}",
plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE)) }, { __index = d })
end
if d.poll_query or (d.token_method == "GET_QUERY") then
local q = (d.poll_query or "")
:gsub("{nonce}", sess.device_nonce or sess.nonce or "")
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or "")
:gsub("{device_code}", sess.device_code or "")
url = d.token_url .. "?" .. q
method = "GET"
else
body = (d.token_body
:gsub("{device_code}", sess.device_code or "")
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or ""))
end
local resp, err = __llmsproxy_http({
url = url,
method = method,
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
if err then return false end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
local workos = (d.judge == "workos")
local status = rec.status
local ecode = rec.error
local step = tonumber(d.slow_down_step) or 5
if d.judge == "pending404" then
if resp.status == 404 then return false end
if resp.status == 200 then
local tok = rec.token or rec.device_token or rec.access_token
if tok == nil or tok == "" then return false end
return true, { ok = true, access_token = tok,
refresh_token = rec.refresh_token or rec.refreshToken,
account = rec.user_name or rec.userName or "",
uid = rec.user_id or rec.userId,
expires_at = rec.expires_at or rec.expiresAt,
refresh_expires_at = rec.refresh_token_expires_at or rec.refreshTokenExpiresAt }
end
return true, { ok = false, reason = "error",
message = "登录服务返回异常(HTTP " .. tostring(resp.status) .. ")" }
end
if workos then
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then
local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期")
return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg }
end
if resp.status == 200 and rec.access_token and rec.refresh_token then
return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token,
need_register = true, account = "cline" }
end
if resp.status ~= 200 and next(rec) ~= nil then
return true, { ok = false, reason = "error",
message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")"
.. (rec.error_description and (" - " .. rec.error_description) or "") }
end
return false
end
if resp.status == 200 and status == "pending" then return false end
if resp.status == 200 and status == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if resp.status == 200 and (status == "denied" or status == "access_denied") then
return true, { ok = false, reason = "denied", message = "用户拒绝了授权" }
end
if resp.status == 200 and (status == "expired" or status == "expired_token") then
return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" }
end
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
sess.device_interval = (sess.device_interval or d.interval or 5) + step
return false
end
if resp.status == 200 and rec.access_token then
return true, { ok = true, access_token = rec.access_token,
refresh_token = rec.refresh_token, account = rec.nickname or rec.sub or "" }
end
return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) }
end
local function wechatExchange(plat, wxCode)
local w = plat.wechat
if not w then return nil, "平台未声明 wechat 配置" end
local path = w.bind_path
local body = json.encode({
tcode = { code = wxCode },
type = "wx",
appId = w.app_id,
})
local headers, herr = loomyAuthHeaders(w.access_key_id, w.access_key_secret,
"POST", path, body, "application/json")
if not headers then return nil, "签名失败: " .. tostring(herr) end
headers["Accept"] = "application/json"
local resp, err = __llmsproxy_http({
url = w.account_base .. path, method = "POST", headers = headers, body = body,
})
if err then return nil, "账号端点请求失败: " .. err end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, "账号端点响应无法解析(HTTP " .. tostring(resp.status) .. ")"
end
local data = type(rec.data) == "table" and rec.data or rec
local rcode = data.rcode
if type(rcode) ~= "string" or rcode == "" then
return nil, "微信授权响应缺少 rcode(code 可能已失效)"
end
local bind = (data.bind == 1) and 1 or 0
return json.encode({
access_token = rcode,
rcode = rcode,
bind = bind,
session = data.session or data.token,
user_id = data.userid or data.user_id,
nickname = data.nickname,
headpic = data.headpic,
phone_bound = bind == 1,
token_type = "Bearer",
account_type = "personal",
}), nil
end
local function deviceRegister(plat, payload)
local r = plat.register
if not r or not r.url then return payload.access_token, payload.account, nil end
local body = (r.body or "")
:gsub("{access_token}", payload.access_token or "")
:gsub("{refresh_token}", payload.refresh_token or "")
local resp, err = __llmsproxy_http({
url = r.url,
method = r.method or "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "token 注册失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")"
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "token 注册响应无法解析"
end
local data = rec.data or rec
local access = data.accessToken or data.access_token or data.Token or data.token
if not access or access == "" then
return nil, nil, "token 注册响应缺少 accessToken"
end
local acct = "cline"
if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then
acct = tostring(data.userInfo.email or data.userInfo.clineUserId)
end
return access, acct, nil
end
local function platformById(id)
for _, p in ipairs(PLATFORMS) do
if p.id == id then return p end
end
local injected = plugin.state and plugin.state.injected_platforms
if type(injected) == "table" then
for _, p in ipairs(injected) do
if type(p) == "table" and p.id == id then return p end
end
end
return nil
end
local function allPlatforms()
local out = {}
for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end
local injected = plugin.state and plugin.state.injected_platforms
if type(injected) == "table" then
for _, p in ipairs(injected) do
if type(p) == "table" and p.id then out[#out + 1] = p end
end
end
return out
end
local function storeDir()
local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")
if type(env) == "string" and env ~= "" then return env end
local kern = __llmsproxy_plugin_dir
if type(kern) == "string" and kern ~= "" then return kern end
local s = plugin.state.settings and plugin.state.settings.store_dir
if type(s) == "string" and s ~= "" then return s end
for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do
local f = io.open(path, "r")
if f then
local txt = f:read("*a")
f:close()
if txt then
local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)")
if d and d ~= "" then return d end
end
end
end
return "/etc/llmsproxy/plugins"
end
local PERSIST_KEYS = {
"accounts", "pool", "modelDisabled", "settings",
"providerOrder", "log", "injected_platforms",
"remoteModels",
"deadModels",
"ttlCache",
"claimCovered",
"capabilities", "capabilitiesMeta",
"catalogGate",
}
local function persistState()
local dir = storeDir()
local path = dir .. "/" .. plugin.K.STORE_NAME
local snap = {}
for _, k in ipairs(PERSIST_KEYS) do
if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
end
local body = json.encode({ version = 1, state = snap })
if not body then return false, "json.encode 失败" end
local tmp = path .. ".tmp"
local dir = path:match("^(.*)/[^/]+$")
if dir and dir ~= "" then os.execute("mkdir -p '" .. dir .. "' 2>/dev/null") end
local w = io.open(tmp, "w")
if not w then return false, "无法写入 " .. tmp end
w:write(body)
w:close()
os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp))
local ok = os.rename(tmp, path)
if not ok then
os.remove(tmp)
return false, "rename 失败"
end
return true
end
local function applySnapshot(snap)
for _, k in ipairs(PERSIST_KEYS) do
if snap[k] ~= nil then plugin.state[k] = snap[k] end
end
end
local function dropLegacyKernelState()
local legacy = storeDir() .. "/.channels.state.json"
local f = io.open(legacy, "r")
if not f then return end
f:close()
os.remove(legacy)
io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n")
end
local function loadOwnSnapshot()
local dir = storeDir()
local f = io.open(dir .. "/" .. plugin.K.STORE_NAME, "r")
if f then
local txt = f:read("*a")
f:close()
if txt and txt ~= "" then
local ok, rec = pcall(json.decode, txt)
if ok and type(rec) == "table" and type(rec.state) == "table" then
applySnapshot(rec.state)
return true
end
io.stderr:write("[channels] 自有存储损坏,改用默认值\n")
end
end
return false
end
local function migrateLegacyKernelState()
local dir = storeDir()
local legacy = dir .. "/.channels.state.json"
local f = io.open(legacy, "r")
if not f then return false end
local txt = f:read("*a")
f:close()
if not txt or txt == "" then return false end
local ok, rec = pcall(json.decode, txt)
if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then
return false
end
applySnapshot(rec.state)
local written, werr = persistState()
if written then
os.remove(legacy)
io.stderr:write("[channels] 已迁移遗留状态到 " .. plugin.K.STORE_NAME .. "\n")
else
io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
.. tostring(werr) .. "\n")
end
return true
end
local function loadState()
if loadOwnSnapshot() then
dropLegacyKernelState()
return true
end
return migrateLegacyKernelState()
end
local function markDirty()
local ok, err = persistState()
if not ok then
io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n")
end
end
plugin.state = plugin.state or {}
plugin.state.accounts = plugin.state.accounts or {}
plugin.state.log = plugin.state.log or {}
loadState()
do
local dir = storeDir()
local sh = io.popen and io.popen("rm -f " .. dir ..
"/callback-*.port " .. dir .. "/callback-*.json 2>/dev/null")
if sh then sh:close() end
end
plugin.utf8Clip = function(s, maxBytes)
s = tostring(s or "")
if #s <= maxBytes then return s end
local cut = s:sub(1, maxBytes)
for _ = 1, 4 do
local b = cut:byte(-1)
if not b then break end
if b < 0x80 then break
elseif b >= 0xC0 then
cut = cut:sub(1, -2)
break
else
cut = cut:sub(1, -2)
end
end
return cut
end
local function logEvent(msg, level, platform)
local log = plugin.state.log
local now = os.time and os.time() or 0
local top = log[1]
if top and top.msg == msg and (top.level or "info") == (level or "info") then
top.n = (top.n or 1) + 1
top.at = now
markDirty()
return
end
table.insert(log, 1, {
at = now,
msg = msg,
level = level or "info",
platform = platform,
})
while #log > 50 do table.remove(log) end
markDirty()
end
local function accountOf(id)
return plugin.state.accounts[id]
end
local function poolTable()
if not plugin.state.pool then plugin.state.pool = {} end
return plugin.state.pool
end
local function poolAccounts(provider)
local t = poolTable()[provider]
if not t then t = {} poolTable()[provider] = t end
return t
end
local function poolList(provider)
local out = {}
for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end
table.sort(out, function(x, y)
local px, py = x.priority or 0, y.priority or 0
if px ~= py then return px > py end
local cx, cy = x.createdAt or 0, y.createdAt or 0
if cx ~= cy then return cx < cy end
return tostring(x.id or "") < tostring(y.id or "")
end)
return out
end
local function poolGet(provider, accountId)
return poolAccounts(provider)[accountId]
end
local function poolAdd(provider, entry)
poolAccounts(provider)[entry.id] = entry
markDirty()
end
local function poolRemove(provider, accountId)
poolAccounts(provider)[accountId] = nil
markDirty()
end
local function credentialOf(ref)
local raw = ref and __llmsproxy_secrets.get(ref)
if not raw or raw == "" then return nil, nil, nil end
if type(raw) == "string" and raw:sub(1, 1) ~= "{" then return raw, nil, nil end
local ok, rec = pcall(json.decode, type(raw) == "string" and raw or "")
if ok and type(rec) == "table" then
local at = rec.access_token or rec.accessToken or rec.token
local rt = rec.refresh_token or rec.refreshToken
if at and at ~= "" then return at, rt, rec end
return raw, rt, rec
end
return raw, nil, nil
end
local function credentialFull(ref)
local _, _, full = credentialOf(ref)
return full
end
for i = 1, #plugin.K.B64_ALPHABET do plugin.K.B64_LOOKUP[plugin.K.B64_ALPHABET:sub(i, i)] = i - 1 end
local function b64url_decode(input)
if type(input) ~= "string" or input == "" then return nil end
local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
local out, acc, bits = {}, 0, 0
for i = 1, #s do
local v = plugin.K.B64_LOOKUP[s:sub(i, i)]
if v == nil then return nil end
acc = acc * 64 + v
bits = bits + 6
if bits >= 8 then
bits = bits - 8
local byte = math.floor(acc / (2 ^ bits)) % 256
out[#out + 1] = string.char(byte)
acc = acc % (2 ^ bits)
end
end
return table.concat(out)
end
local function jwtClaim(token, name)
if type(token) ~= "string" or token == "" then return nil end
local payload = token:match("^[^%.]+%.([^%.]+)")
if not payload then return nil end
local bin = b64url_decode(payload)
if not bin then return nil end
local ok, rec = pcall(json.decode, bin)
if not ok or type(rec) ~= "table" then return nil end
return rec[name]
end
local function stripControl(v)
if type(v) ~= "string" then return v end
v = v:gsub("[%z\1-\31\127]+", " ")
v = v:gsub("%s%s+", " ")
return (v:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function readStr(rec, key)
if type(rec) ~= "table" then return "" end
local v = rec[key]
if type(v) == "string" then return stripControl(v) end
if type(v) == "number" then return tostring(v) end
return ""
end
local function absExpiryMs(rec, absKey, relKey, accessToken)
local absolute = readStr(rec, absKey)
if absolute ~= "" then
if absolute:match("^%d+$") then
local n = tonumber(absolute)
if n then
return tostring(n > 1e12 and n or n * 1000)
end
end
return absolute
end
local rel = rec[relKey]
local relSec = nil
if type(rel) == "number" then relSec = rel
elseif type(rel) == "string" and rel:match("^%-?%d+%.?%d*$") then relSec = tonumber(rel) end
if relSec == nil then return "" end
local iat = jwtClaim(accessToken, "iat")
local baseMs = (type(iat) == "number" and iat * 1000) or (os.time and os.time() * 1000 or 0)
return tostring(baseMs + relSec * 1000)
end
local function buildCredential(data, accountData)
local access = readStr(data, "accessToken")
if access == "" then access = readStr(data, "access_token") end
local refresh = readStr(data, "refreshToken")
if refresh == "" then refresh = readStr(data, "refresh_token") end
local tokenType = readStr(data, "tokenType")
if tokenType == "" then tokenType = "Bearer" end
local acct = type(accountData) == "table" and accountData or {}
local nickname = readStr(acct, "nickname")
if nickname == "" then nickname = readStr(acct, "nickName") end
if nickname == "" then
local jn = jwtClaim(access, "nickname")
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "preferred_username") end
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "name") end
if type(jn) == "string" then nickname = stripControl(jn) end
end
local uid = readStr(acct, "uid")
if uid == "" then
local sub = jwtClaim(access, "sub")
if type(sub) == "string" then uid = sub end
end
local acctType = readStr(acct, "type")
if acctType == "" then acctType = "personal" end
return {
access_token = access,
refresh_token = refresh,
expires_at = absExpiryMs(data, "expiresAt", "expiresIn", access),
refresh_expires_at = absExpiryMs(data, "refreshExpiresAt", "refreshExpiresIn", access),
token_type = tokenType,
scope = readStr(data, "scope"),
domain = readStr(data, "domain"),
user_id = uid,
nickname = nickname,
enterprise_id = readStr(acct, "enterpriseId"),
account_type = acctType,
}
end
local function credentialExpiresAtMs(cred)
if type(cred) ~= "table" then return nil end
local raw = cred.expires_at
if type(raw) == "string" and raw ~= "" then
if raw:match("^%d+$") then
local n = tonumber(raw)
if n then return n > 1e12 and n or n * 1000 end
end
local Y, Mo, D, H, Mi, S, rest =
raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$")
if Y then
local naive = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false })
local localOff = os.time() - os.time(os.date("!*t"))
if rest:sub(1, 1) == "+" or rest:sub(1, 1) == "-" then
local sign = rest:sub(1, 1)
local oh, om = rest:sub(2):match("^(%d%d):?(%d*)")
local off = (tonumber(oh) or 0) * 3600
+ (tonumber(om ~= "" and om or "0") or 0) * 60
if sign == "-" then off = -off end
return (naive + (localOff - off)) * 1000
end
return (naive + localOff) * 1000
end
end
local exp = jwtClaim(cred.access_token, "exp")
if type(exp) == "number" then return exp * 1000 end
return nil
end
local function credentialIsRefreshable(cred)
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
end
local function urlencode(v)
return (tostring(v or ""):gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
local REFRESH_STYLES = {
cline = {
body = function(cred, r)
return json.encode({ refreshToken = cred.refresh_token or "",
grantType = "refresh_token" })
end,
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
pick = function(rec)
local d = type(rec.data) == "table" and rec.data or rec
return d.accessToken or d.access_token, d.refreshToken or d.refresh_token
end,
},
google = {
form = true,
body = function(cred, r)
return "client_id=" .. urlencode(r.client_id or "")
.. "&client_secret=" .. urlencode(r.client_secret or "")
.. "&refresh_token=" .. urlencode(cred.refresh_token or "")
.. "&grant_type=refresh_token"
end,
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
["Accept"] = "application/json" },
pick = function(rec) return rec.access_token, rec.refresh_token end,
},
}
local function refreshCredential(plat, acct)
local cred = credentialFull(acct.credentialRef)
if type(cred) ~= "table" then return false, "凭据无法解析" end
if type(plat.refresh_fn) == "function" then
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
local newCred, ferr = plat.refresh_fn(plat, cred)
if not newCred then return false, tostring(ferr or "续期失败") end
__llmsproxy_secrets.set(acct.credentialRef, json.encode(newCred))
if newCred.expires_at then
local ms = credentialExpiresAtMs(newCred)
if ms then acct.expiresAt = math.floor(ms / 1000) end
end
return true, "已续期"
end
local r = plat.refresh
if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
local style = REFRESH_STYLES[r.style or "google"]
if not style then return false, "未知续期风格: " .. tostring(r.style) end
local body = style.body(cred, r)
local resp, err = __llmsproxy_http({
url = r.url, method = r.method or "POST", headers = style.headers, body = body,
})
if err then return false, "续期请求失败: " .. tostring(err) end
if not resp or resp.status ~= 200 then
return false, "续期 HTTP " .. tostring(resp and resp.status)
end
local okd, rec = pcall(json.decode, resp.body or "")
if not okd or type(rec) ~= "table" then return false, "续期响应无法解析" end
local newTok, newRefresh = style.pick(rec)
if type(newTok) ~= "string" or newTok == "" then
local msg = rec.error_description or rec.error or rec.message
return false, "续期响应无 access_token" .. (msg and (": " .. tostring(msg)) or "")
end
cred.access_token = newTok
if type(newRefresh) == "string" and newRefresh ~= "" then
cred.refresh_token = newRefresh
end
local rec2 = type(rec.data) == "table" and rec.data or rec
local ei = tonumber(rec2.expires_in or rec.expires_in)
if ei and ei > 0 then
cred.expires_at = tostring((os.time and os.time() or 0) + ei)
end
__llmsproxy_secrets.set(acct.credentialRef, json.encode(cred))
if cred.expires_at then
local ms = credentialExpiresAtMs(cred)
if ms then acct.expiresAt = math.floor(ms / 1000) end
end
acct.refreshable = true
poolAdd(plat.id, acct)
markDirty()
return true, "已续期"
end
local deadModelIds
local function effectiveModels(provider)
local plat = platformById(provider)
if not plat then return {} end
local unavailable = {}
for _, m in ipairs(plat.models_unavailable or {}) do unavailable[m] = true end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
local out, seen = {}, {}
if type(remote) == "table" and type(remote.ids) == "table" then
for _, id in ipairs(remote.ids) do
if type(id) == "string" and id ~= "" and not seen[id] then
seen[id] = true out[#out+1] = id
end
end
end
local haveRemote = type(remote) == "table" and type(remote.ids) == "table" and #remote.ids > 0
if not (plat.models_fallback_no_supplement and haveRemote) then
for _, id in ipairs(plat.models or {}) do
if not seen[id] then seen[id] = true out[#out+1] = id end
end
end
local dead = deadModelIds(provider)
local kept = {}
for _, id in ipairs(out) do
if not dead[id] and not unavailable[id] then kept[#kept+1] = id end
end
return kept
end
remoteFreeSets = remoteFreeSets or {}
local function parseRemoteModels(kind, body, provider)
local ok, rec = pcall(json.decode, body or "")
if not ok or type(rec) ~= "table" then return {} end
local out = {}
if kind == "id_list" then
local arr = rec.data or rec.models or rec
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "buddy_config" then
local d = rec.data or rec
local arr = d.models or d.agents or rec.models
local metaById = {}
if type(arr) == "table" then
for _, m in ipairs(arr) do
if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then
metaById[m.id] = m
end
end
end
local agentRef = {}
if type(d.agents) == "table" then
for _, ag in ipairs(d.agents) do
if type(ag) == "table" and type(ag.models) == "table" then
for _, mid in ipairs(ag.models) do
if type(mid) == "string" then agentRef[mid] = true end
end
end
end
end
local NOT_CALLABLE = {
["hy4-preview-x"] = true,
}
local function buddyIsChatModel(id)
if NOT_CALLABLE[id] then return false end
if id == "auto" or id == "default" then return false end
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
or id:sub(1, 9) == "codewise-" then return false end
local m = metaById[id]
if type(m) == "table" then
if m.supportsExtra == true then return false end
local mo = tonumber(m.maxOutputTokens)
if mo and mo > 0 and mo <= 256 then return false end
if type(m.tags) == "table" then
for _, t in ipairs(m.tags) do
if t == "text-to-image" then return false end
end
end
end
return true
end
local seen = {}
local function pushId(id)
if type(id) ~= "string" or id == "" then return end
if seen[id] or not buddyIsChatModel(id) then return end
seen[id] = true
out[#out + 1] = id
end
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.id or m.modelId or m.name) or nil
if type(id) == "string" then pushId(id) end
end
end
for id in pairs(agentRef) do pushId(id) end
elseif kind == "codearts_builtin" then
local raw = rec.builtinModels or (rec.data and rec.data.builtinModels)
if type(raw) == "table" then
for _, m in pairs(raw) do
local id = type(m) == "table" and (m.model_id or m.modelId or m.id)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "codearts_gateway" then
local arr = rec.result and rec.result.models
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.model_id or m.modelId or m.id)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "trae_batch" then
local TRAE_CHANNELS = {
"solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote",
"solo_agent_lite",
"chat_v3", "builder_v3", "solo_coder", "solo_design_lite",
"solo_design_remote", "git_ai", "code_reviewer", "code_review_summary",
"multimodal", "system_diagnosis",
}
local rankOf = {}
for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end
local function rbool(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return v
end
local function rstr(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return type(v) == "string" and v or nil
end
local function rnum(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return tonumber(v)
end
local function declaresEffort(e)
local rc = e.reasoning
if type(rc) ~= "table" then return false end
local st = rbool(rc, "support_thinking", "supportThinking")
if st == false then return false end
local opts = rc.options or rc.Options
return type(opts) == "table" and #opts > 0
end
local function parseEntry(it, chan)
local id = rstr(it, "config_name", "ConfigName")
if not id or id == "" then return nil end
local disp = it.display_config or it.DisplayConfig
if type(disp) ~= "table" then disp = {} end
local e = {
id = id,
name = rstr(disp, "display_name", "displayName") or id,
chan = chan,
usage = rstr(it, "usage", "Usage"),
enabled = rbool(it, "config_switch", "ConfigSwitch"),
hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"),
custom = rbool(disp, "is_custom_model", "IsCustomModel"),
reasoning = it.reasoning_effort_config or it.reasoningEffortConfig,
}
local cw = it.context_window_tokens or it.contextWindowTokens
if type(cw) == "table" then
e.contextWindow = rnum(cw, "dev", "Dev")
end
local mdl = it.model_detail_list or it.modelDetailList
if type(mdl) == "table" and type(mdl[1]) == "table" then
e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens")
end
local dcc = it.display_contact_config or it.DisplayContactConfig
if type(dcc) == "string" and dcc ~= "" then
local okj, cfg = pcall(json.decode, dcc)
if okj and type(cfg) == "table" then
local rate = cfg.consumption_rate or cfg.ConsumptionRate
if type(rate) == "table" then
local enable = rbool(rate, "enable", "Enable")
if enable ~= false then
local data = rate.data or rate.Data
if type(data) == "table" then
local v = rnum(data, "rate", "Rate")
if v and v >= 0 then e.creditsRate = v end
end
end
end
local disc = cfg.activity_discount or cfg.ActivityDiscount
if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then
local ddata = disc.data or disc.Data
if type(ddata) == "table" then
local cur = ddata.current or ddata.Current
if type(cur) == "table" then
local dtype = (rstr(cur, "discount_type", "discountType") or "")
dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "")
if dtype ~= "" and dtype ~= "none" then
local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate")
local after = rnum(cur, "consumption_rate", "consumptionRate")
if before and before > 0 and (not after or before > after) then
local endsAt
for _, v in pairs(ddata) do
if type(v) == "table" then
local endv = rnum(v, "end_at", "endAt")
if endv and endv > 0 then endsAt = endv break end
end
end
local nowS = os.time and os.time() or 0
if not endsAt or endsAt > nowS then
e.origCreditsRate = before
e.discountEndsAt = endsAt
end
end
end
end
end
end
end
end
return e
end
local groups = rec.function_configs or rec.functionConfigs
if type(groups) == "table" then
local byId, chosenRank, order = {}, {}, {}
for _, g in ipairs(groups) do
if type(g) == "table" then
local chan = rstr(g, "function", "Function")
local admitted = nil
do
local pl = platformById(provider)
local mr2 = pl and pl.models_remote
local af = mr2 and mr2.admit_functions
if type(af) == "table" then admitted = {} for _, f in ipairs(af) do admitted[f] = true end end
end
if admitted and chan and not admitted[chan] then chan = nil end
if chan and rankOf[chan] then
local list = g.config_info_list or g.ConfigInfoList
if type(list) == "table" then
for _, it in ipairs(list) do
if type(it) == "table" then
local e = parseEntry(it, chan)
if e then
local ok = true
if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end
if e.enabled == false then ok = false end
if e.hidden == true then ok = false end
if e.custom == true then ok = false end
if ok then
local inc = byId[e.id]
if inc == nil then
byId[e.id] = e
chosenRank[e.id] = rankOf[chan]
order[#order + 1] = e.id
else
local incEff = declaresEffort(inc)
local candEff = declaresEffort(e)
local take = true
if incEff and not candEff then
take = false
elseif incEff and candEff then
take = rankOf[chan] < (chosenRank[e.id] or 0)
end
if take then
byId[e.id] = e
chosenRank[e.id] = rankOf[chan]
end
end
end
end
end
end
end
end
end
end
for _, id in ipairs(order) do
out[#out + 1] = id
if not plugin.state.modelMeta then plugin.state.modelMeta = {} end
plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {}
plugin.state.modelMeta[provider][id] = byId[id]
end
end
elseif kind == "zcode_configs" then
local raw = rec.data and rec.data.builtinModels
if type(raw) == "table" then
for _, m in pairs(raw) do
local id = type(m) == "table" and (m.modelId or m.id)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
elseif kind == "cline_merge" then
local function pushFrom(node)
if type(node) ~= "table" then return end
local arr = node.data or node
if type(arr) ~= "table" then return end
for _, m in ipairs(arr) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
end
end
pushFrom(rec.free)
pushFrom(rec)
if type(rec.free) == "table" then
local fr = {}
for _, m in ipairs(rec.free) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then fr[id] = true end
end
remoteFreeSets = remoteFreeSets or {}
remoteFreeSets[provider] = fr
end
end
return out
end
local function refreshRemoteModels(provider)
local plat = platformById(provider)
if not plat then return nil, "未知平台" end
local lastBody = nil
local mr = plat.models_remote
if not mr then return nil, "该平台无远端目录端点" end
local tok
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local t = credentialOf(a.credentialRef)
if t and t ~= "" then tok = t break end
end
end
if (not tok or tok == "") and mr.auth ~= "none" then return nil, "无可用凭据" end
local merged, seen = {}, {}
local lastErr
for _, ep in ipairs(mr.endpoints or {}) do
local url = ep.url
if ep.query then url = url .. "?" .. ep.query end
local headers = { ["Accept"] = "application/json" }
local method = ep.method or "GET"
if ep.body then headers["Content-Type"] = "application/json" end
local useAuth = ep.url_auth ~= false and mr.auth ~= "none"
if useAuth and tok and tok ~= "" then
headers["Authorization"] = "Bearer " .. tok
end
if mr.headers_fn and plat.headers_fn then
local extra = plat.headers_fn(tok or "", nil)
for k, v in pairs(extra or {}) do headers[k] = v end
end
local req = { url = url, method = method, headers = headers }
if ep.body then req.body = ep.body end
if ep.sign == "trae_solo" then
local cred
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and cj.access_token then
cred = cj break
end
end
end
if not cred then
lastErr = "无可用 TRAE 凭据"
elseif type(traeIdeHeaders) ~= "function" then
lastErr = "traeIdeHeaders 未就绪(前向引用顺序问题)"
else
local h, herr = traeIdeHeaders(plat, cred, "application/json")
if herr then
lastErr = tostring(herr)
else
for k, v in pairs(h or {}) do req.headers[k] = v end
end
end
end
if ep.sign == "codearts" then
local cred
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and (cj.access_key_id or cj.access_key) then
cred = cj break
end
end
end
if not cred then
lastErr = "无可用 AK/SK 凭据"
else
local sreq, serr = codeartsSignedRequest(req.method, url, cred, nil)
if sreq then
for k, v in pairs(ep.headers or {}) do sreq.headers[k] = v end
req = sreq
else
lastErr = tostring(serr)
end
end
end
local resp, err = __llmsproxy_http(req)
if err or not resp or resp.status ~= 200 then
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
else
lastBody = resp.body or ""
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
if not seen[id] then seen[id] = true merged[#merged+1] = id end
end
end
end
if #merged == 0 then
if not lastErr then
local seenBody = false
if type(lastBody) == "string" and lastBody:find("function_configs", 1, true) then
seenBody = true
end
if seenBody then
return nil, "远端 200 但无模型配置(账号侧未下发 function config,"
.. "企业/租户账号需管理员开启,实测 error_code 2001)"
end
end
return nil, lastErr or "远端返回空目录"
end
if not plugin.state.remoteModels then plugin.state.remoteModels = {} end
plugin.state.remoteModels[provider] = {
ids = merged,
fetched_at = os.time(),
}
markDirty()
return merged, nil
end
local function disabledModelsOf(provider)
if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end
local m = plugin.state.modelDisabled[provider]
if not m then m = {} plugin.state.modelDisabled[provider] = m end
return m
end
local function setModelDisabled(provider, modelId, disabled)
local m = disabledModelsOf(provider)
if disabled then m[modelId] = true else m[modelId] = nil end
markDirty()
end
local CHANNEL_CREDITS = {
codearts = { claim = true },
buddy = { status = true },
lobsterai = { claim = true },
qoder = { claim = true },
trae = { claim = true },
cline = { unit = "dollars" },
loomy = { claim = true },
raccoon = { claim = true, unit = "points" },
minimax = { claim = true, status = true },
zcode = { claim = true, unit = "tokens", auto_exclude = true },
gemini = { unit = "quota", quota = true },
}
local function providerSourceName(provider)
return "channels-" .. provider
end
local function accountSourceName(provider, accountId)
return "channels-" .. provider .. "-" .. accountId
end
pickAccountForSource = function(plat)
local accounts = poolList(plat.id)
if #accounts == 0 then return nil, "无账号" end
local withCred = {}
for _, a in ipairs(accounts) do
if a.enabled ~= false then
local tok
if plat.credential_shape == "full" then
tok = __llmsproxy_secrets.get(a.credentialRef)
else
tok = credentialOf(a.credentialRef)
end
if tok ~= nil and tok ~= "" then withCred[#withCred + 1] = a end
end
end
if #withCred == 0 then return nil, "无可用凭据(占位账号或密文不可读)" end
local ranked = withCred
if plat.balance then
local allowPermanent = not (plugin.state.settings
and plugin.state.settings.lock_permanent_credits)
local expiring, permanent, unknown = {}, {}, {}
for _, a in ipairs(withCred) do
local raw = accountBalanceRaw(plat.id, a.id)
if raw == nil then
unknown[#unknown + 1] = a
else
local tier = balanceTier(raw.expiring_balance or raw.expiringBalance,
raw.permanent_balance or raw.permanentBalance, allowPermanent)
if tier == "expiring" then expiring[#expiring + 1] = a
elseif tier == "permanent" then permanent[#permanent + 1] = a
else unknown[#unknown + 1] = a end
end
end
ranked = {}
for _, a in ipairs(expiring) do ranked[#ranked + 1] = a end
for _, a in ipairs(permanent) do ranked[#ranked + 1] = a end
for _, a in ipairs(unknown) do ranked[#ranked + 1] = a end
if #ranked == 0 then return nil, "所有账号余额均不可用" end
end
for _, a in ipairs(ranked) do
if not a.termErr then
return a, "选中账号 " .. tostring(a.id)
.. (a.nickname and a.nickname ~= "" and ("(" .. a.nickname .. ")") or "")
end
end
return ranked[1], "所有账号均标记失效,回退到 " .. tostring(ranked[1].id)
end
local function syncProviderSource(provider)
local plat = platformById(provider)
if not plat then return end
local acct, why = pickAccountForSource(plat)
if not acct then
local gone = providerSourceName(provider)
__llmsproxy_kernel.call("remove_source", { name = gone })
if plugin.state.chosen then plugin.state.chosen[provider] = nil end
logEvent("通道 " .. provider .. " 无可用账号,已撤源:" .. tostring(why), "warn", provider)
return
end
local tok, err
if plat.credential_shape == "full" then
tok = __llmsproxy_secrets.get(acct.credentialRef)
err = (tok == nil or tok == "") and "无凭据" or nil
else
tok = credentialOf(acct.credentialRef)
err = (tok == nil or tok == "") and "无凭据" or nil
end
if err then
logEvent("通道 " .. provider .. " 凭据读取失败,跳过同步", "warn", provider)
return
end
plugin.state.chosen = plugin.state.chosen or {}
plugin.state.chosen[provider] = acct.id
local name = providerSourceName(provider)
local models = {}
local disabled = disabledModelsOf(provider)
for _, m in ipairs(effectiveModels(provider)) do
if not disabled[m] then
models[#models+1] = { id = m, kind = "chat", priority = 20 }
end
end
local rr, rerr = __llmsproxy_kernel.call("remove_source", { name = name })
if rr == nil or rr.ok ~= true then
local l = plugin.state.log
local last = type(l) == "table" and l[1] and l[1].msg or ""
if not tostring(last):find("撤源(重建)失败", 1, true) then
logEvent("撤源(重建)失败 " .. tostring(name) .. ": "
.. tostring(rerr or "(无错误信息)"), "warn", provider)
end
end
local chatBase = plat.base_url
if provider == "buddy" and plugin.state.chosen and plugin.state.chosen[provider] then
local chosenCred = credentialFull(
(poolGet(provider, plugin.state.chosen[provider]) or {}).credentialRef)
if type(chosenCred) == "table" and chosenCred.region == "intl" then
chatBase = "https://" .. plugin.K.BUDDY_REGION_HOST.intl
end
end
local src = {
name = name,
base_url = chatBase,
adapter = plat.adapter or "openai",
api_key = tok,
max_concurrent = 1,
rpm = plat.rpm or 20,
models = models,
}
if plat.endpoint then src.endpoint = plat.endpoint end
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
if plat.headers_extra then src.headers = plat.headers_extra end
if type(plat.retry_policy) == "table" then src.retry = plat.retry_policy end
if plat.source_meta_from_credential then
local cmeta = credentialFull(acct.credentialRef)
if type(cmeta) == "table" then
src.meta = {}
for _, mk in ipairs({ "uid", "device_id", "machine_id", "enterprise_id" }) do
if cmeta[mk] ~= nil and cmeta[mk] ~= "" then src.meta[mk] = cmeta[mk] end
end
end
end
do
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = ls
if type(arr) == "table" and arr.json then arr = arr.json end
if type(arr) == "table" and arr.sources then arr = arr.sources end
if type(arr) ~= "table" then arr = {} end
if type(arr) ~= "table" then arr = {} end
local legacyPrefix = "channels-" .. provider .. "-"
for _, exist in ipairs(arr) do
local sn = type(exist) == "table" and exist.name or nil
if type(sn) == "string" and sn ~= name
and sn:sub(1, #legacyPrefix) == legacyPrefix then
__llmsproxy_kernel.call("remove_source", { name = sn })
logEvent("已清理旧版按账号拆源 " .. sn, "task", provider)
end
end
end
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
if aerr then
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider)
elseif r and r.json and r.json.ok == false then
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider)
else
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型,账号 "
.. tostring(acct.id) .. ")", "task", provider)
end
end
local function syncAccountSource(provider, _accountId)
syncProviderSource(provider)
end
local function dropAccountSource(provider, accountId)
local legacy = accountSourceName(provider, accountId)
local r, err = __llmsproxy_kernel.call("remove_source", { name = legacy })
if r == nil or r.ok ~= true then
local msg = tostring(err or "")
if not msg:find("not found", 1, true) and not msg:find("不存在", 1, true) then
logEvent("撤旧源失败 " .. tostring(legacy) .. ": " .. tostring(err or "(无错误信息)")
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)",
"error", provider)
return false, err
end
end
syncProviderSource(provider)
return true
end
newToken = function()
return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels"))
end
local function sessions()
if not plugin.state.sessions then plugin.state.sessions = {} end
return plugin.state.sessions
end
local browserUtil = {}
function browserUtil.pathExists(p)
local f = io.open(p, "rb")
if f then f:close() return true end
local h = io.popen(("test -e %s && echo y"):format("'" .. tostring(p) .. "'"))
local r = h and h:read("*l")
if h then h:close() end
return r == "y"
end
function browserUtil.readTail(path, max)
local f = io.open(path, "r")
if not f then return "(无日志)" end
local txt = f:read("*a") or ""
f:close()
local keep = {}
for line in txt:gmatch("[^\n]+") do
if not line:find("dbus", 1, true) and not line:find("crashpad", 1, true)
and not line:find("NameHasOwner", 1, true) and not line:find("UPower", 1, true) then
keep[#keep + 1] = line
end
end
local joined = table.concat(keep, " | ")
local lim = max or 400
if #joined > lim then joined = joined:sub(-lim) end
return joined
end
function browserUtil.cdpAlive(port)
for _ = 1, 10 do
local h = io.popen(("curl -s --max-time 2 http://127.0.0.1:%d/json/version 2>/dev/null"):format(port))
local out = h and h:read("*a") or ""
if h then h:close() end
if out and out:find("webSocketDebuggerUrl", 1, true) then return true end
os.execute("sleep 0.5")
end
return false
end
local reapLoopback, browserStack, reapBrowser
local function sweepSessions()
local now = os.time and os.time() or 0
for id, s in pairs(sessions()) do
if s.expires_at and s.expires_at < now then
reapLoopback(id)
if s.owns_browser then
local cur = browserStack()
if cur.owner == id then
reapBrowser()
end
end
sessions()[id] = nil
end
end
end
browserStack = function()
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
return plugin.state.browser
end
local BROWSER_DEPS = {
{ bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" },
{ bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" },
{ bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" },
{ bin = "python3", pkg = "python3", why = "CDP 取凭据" },
}
local function which(bin)
local h = io.popen("command -v " .. bin .. " 2>/dev/null")
if not h then return false end
local p = h:read("*l")
h:close()
return p ~= nil and p ~= ""
end
local function killProcsMatching(needle)
if not needle or needle == "" then return 0 end
local h = io.popen("ls /proc 2>/dev/null")
if not h then return 0 end
local victims = {}
for entry in h:lines() do
if entry:match("^%d+$") then
local f = io.open("/proc/" .. entry .. "/cmdline", "r")
if f then
local raw = f:read("*a") or ""
f:close()
local cmd = raw:gsub("%z", " ")
if cmd ~= "" and cmd:find(needle, 1, true) then
victims[#victims + 1] = entry
end
end
end
end
h:close()
for _, pid in ipairs(victims) do
os.execute("kill -TERM " .. pid .. " 2>/dev/null")
end
if #victims > 0 then
os.execute("sleep 0.3")
end
for _, pid in ipairs(victims) do
os.execute("kill -9 " .. pid .. " 2>/dev/null")
end
return #victims
end
reapBrowser = function()
local b = browserStack()
local sess = b.session
local disp = b.display or (sess and sess.display)
local prof = b.profile
if prof then
killProcsMatching("user-data-dir=" .. prof)
end
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -TERM " .. tostring(pid) .. " 2>/dev/null")
end
if #(b.pids or {}) > 0 then
os.execute("sleep 0.4")
end
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
end
local disp = b.display or (b.session and b.session.display)
if disp then
os.remove("/tmp/.X" .. tostring(disp) .. "-lock")
os.remove("/tmp/.X11-unix/X" .. tostring(disp))
end
if b.profile then
os.execute("rm -rf " .. b.profile .. " 2>/dev/null")
end b.profile = nil
if b.home then
os.execute("rm -rf " .. b.home .. " 2>/dev/null")
end b.home = nil
b.display = nil
b.pids = {}
b.session = nil
end
local function xdisplayBusy(n)
local lock = "/tmp/.X" .. tostring(n) .. "-lock"
local ok = os.execute('[ -e "' .. lock .. '" ] 2>/dev/null')
local hasLock = (ok == true or ok == 0)
if not hasLock then
local sok = os.execute('[ -e "/tmp/.X11-unix/X' .. tostring(n) .. '" ] 2>/dev/null')
return sok == true or sok == 0
end
local f = io.open(lock, "r")
if not f then return true end
local pid = (f:read("*l") or ""):gsub("%s", "")
f:close()
if pid == "" then return true end
if pid:match("^%d+$") then
local alive = io.open("/proc/" .. pid .. "/stat", "r")
if alive then
alive:close()
return true
end
else
return true
end
os.remove(lock)
os.remove("/tmp/.X11-unix/X" .. tostring(n))
return false
end
local function pickFreeX(start, span)
local base = nil
local env = os.getenv and os.getenv("LLMSPROXY_XDISPLAY_BASE")
if type(env) == "string" and env ~= "" then base = tonumber(env) end
if not base then
local st = plugin.state and plugin.state.settings
if type(st) == "table" then base = tonumber(st.xdisplay_base) end
end
if base then start = base end
for n = start, start + span do
if not xdisplayBusy(n) then return n end
end
return nil
end
local function browserDeps()
local missing, present = {}, {}
for _, d in ipairs(BROWSER_DEPS) do
if which(d.bin) then present[#present + 1] = d.bin
else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end
end
local chromium = nil
for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do
if which(c) then chromium = c break end
end
if chromium then present[#present + 1] = chromium
else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end
local novnc = nil
for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do
local f = io.open(p .. "/vnc.html", "r")
if f then f:close() novnc = p break end
end
if not novnc then
missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" }
end
local pyws = false
if which("python3") then
local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes")
if h then local r = h:read("*l") h:close() pyws = (r == "yes") end
end
if which("python3") and not pyws then
missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end
return {
ok = #missing == 0,
missing = missing,
present = present,
chromium = chromium,
novnc = novnc,
}
end
local function buildAuthorizeUrl(plat, port, state, redirectUri, vars)
local a = plat.authorize or {}
local base = a.hash or a.base or plat.login_url
local parts = {}
local function add(k, v)
v = tostring(v):gsub("{port}", tostring(port))
if redirectUri and redirectUri ~= "" then
v = v:gsub("{redirect_uri}", redirectUri)
end
if type(vars) == "table" then
for vk, vv in pairs(vars) do
v = v:gsub("{" .. vk .. "}", tostring(vv))
end
end
if k == "state" and (v == "" or v == "nil") then v = state end
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
end
local keys = {}
for k in pairs(a.params or {}) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do add(k, a.params[k]) end
if not (a.params and a.params.state) then add("state", state) end
return base .. "?" .. table.concat(parts, "&")
end
local function gatewayPort()
local s = plugin.state.settings and plugin.state.settings.gateway_port
if tonumber(s) then return tonumber(s) end
for _, path in ipairs({
"/etc/llmsproxy/config.yaml",
"/usr/local/etc/llmsproxy/config.yaml",
}) do
local f = io.open(path, "r")
if f then
local listen = f:read("*a")
f:close()
if listen then
local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)")
if addr then
local p = addr:match(":(%d+)")
if p then return tonumber(p) end
end
end
end
end
do
local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1")
if h then
local line = h:read("*l")
h:close()
local p = line and line:match(":(%d+)")
if p then return tonumber(p) end
end
end
return 8081
end
local function waitPort(host, port, timeoutMs)
local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs
while true do
local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port))
local r = h and h:read("*l")
if h then h:close() end
if r == "ok" then return true end
local now = (os.time and os.time() or 0) * 1000
if now >= deadline then return false end
os.execute("sleep 0.3")
end
end
local CDP_PY = [[
import json, sys, urllib.request
try:
import websocket
except Exception as e:
print(json.dumps({"ok": False, "error": "websocket-client 未安装: %s" % e}))
sys.exit(0)
port = int(sys.argv[1])
with open(sys.argv[2]) as f:
expr = f.read()
def http(path):
with urllib.request.urlopen("http://127.0.0.1:%d%s" % (port, path), timeout=10) as r:
return json.load(r)
try:
targets = http("/json/list")
except Exception as e:
print(json.dumps({"ok": False, "error": "CDP 不可达: %s" % e})); sys.exit(0)
page = None
for t in targets:
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
page = t; break
if page is None:
print(json.dumps({"ok": False, "error": "没有可用的 page target"})); sys.exit(0)
try:
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=20)
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "returnByValue": True,
"awaitPromise": True}}))
while True:
msg = json.loads(ws.recv())
if msg.get("id") == 1:
break
ws.close()
except Exception as e:
print(json.dumps({"ok": False, "error": "CDP 求值失败: %s" % e})); sys.exit(0)
res = msg.get("result", {})
if "exceptionDetails" in res:
print(json.dumps({"ok": False, "error": res["exceptionDetails"].get("text", "JS 异常")}))
sys.exit(0)
print(json.dumps({"ok": True, "value": res.get("result", {}).get("value")}))
]]
local function cdpScriptPath()
local p = "/tmp/llmsproxy-cdp-eval.py"
local f = io.open(p, "r")
if f then f:close() return p end
local w = io.open(p, "w")
if not w then return nil end
w:write(CDP_PY)
w:close()
return p
end
local function cdpEval(cdpPort, expr)
local script = cdpScriptPath()
if not script then return nil, "无法写入 CDP 脚本" end
local exprFile = ("/tmp/llmsproxy-cdp-expr-%d.js"):format(cdpPort)
local w = io.open(exprFile, "w")
if not w then return nil, "无法写入表达式文件" end
w:write(expr)
w:close()
local h = io.popen(("python3 %s %d %s 2>/dev/null"):format(script, cdpPort, exprFile))
if not h then os.remove(exprFile) return nil, "python3 执行失败" end
local out = h:read("*a")
h:close()
os.remove(exprFile)
if not out or out == "" then return nil, "CDP 无输出" end
local ok, rec = pcall(json.decode, out)
if not ok or type(rec) ~= "table" then return nil, "CDP 输出无法解析: " .. tostring(out):sub(1, 200) end
if rec.ok ~= true then return nil, tostring(rec.error or "CDP 失败") end
return rec.value, nil
end
local LOOPBACK_PY = [[
import sys, json, os, socket, tempfile, threading, random
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
prefix, out_path, want_state, port_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
# Trae 的回调不带 state(它把 token 放在 query 里,靠路径区分);其余平台必须带。
# 各平台的回调查验规则不同,**不能一刀切**(照参考逐个平台看):
# /authorize Trae:token 直接放 query,**不带 state**
# /oauth/callback CodeArts:带 code + ticket_id(无 state),旧流程回退带 secret
# 其余(有道 /auth/callback、Gemini /oauth-callback):标准授权码 + state 校验
require_state = (prefix != "/authorize" and prefix != "/oauth/callback")
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
u = urlparse(self.path)
if not u.path.startswith(prefix):
self.send_response(404); self.end_headers(); self.wfile.write(b"Not found"); return
q = parse_qs(u.query)
state = (q.get("state") or [""])[0]
if require_state and (not state or (want_state and state != want_state)):
# state 缺失/不匹配 → 不是本次登录发起的(或伪造),直接拒(对齐参考)。
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u53c2\u6570\u65e0\u6548".encode()); return
# 把整个回调 query 原样转成 JSON(含 code/authCode 与 Trae 的
# refreshToken/userJwt/userInfo 等),由 Lua 侧按平台形态取用。
payload = {}
for k, v in q.items():
payload[k] = v[0] if v else ""
code = payload.get("code") or payload.get("authCode") or ""
# codearts 旧流程回退只带 secret;authorize 只带 token —— 都不要求 code
need_code = (prefix != "/authorize" and prefix != "/oauth/callback")
if need_code and not code:
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u7f3a\u5c11\u6388\u6743\u7801".encode()); return
if not payload:
self.send_response(400); self.end_headers(); self.wfile.write(b"No params"); return
# 不经 shell 拼串,直接写成 JSON 文件(避免引号/特殊字符注入)。
d = os.path.dirname(out_path)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump(payload, f)
os.replace(tmp, out_path)
self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.end_headers()
self.wfile.write("<html><body><h2>\u767b\u5f55\u6210\u529f\uff0c\u53ef\u4ee5\u5173\u95ed\u6b64\u7a97\u53e3\u4e86</h2></body></html>".encode())
# 双栈:两个 HTTP 实例都要主动停(否则非当前那个会挂着不退)。
def _stop():
for s in SERVERS:
try: s.shutdown()
except Exception: pass
threading.Thread(target=_stop, daemon=True).start()
SERVERS = []
# 为何要双栈:参考注释点明「浏览器常把 localhost 解析成 IPv6」——
# 只绑 127.0.0.1 时,Gemini 的 http://localhost:…/oauth-callback 会连不上。
# 不能改成绑 ::(那等于对外暴露);只绑回环地址的两种家族。
# 注意:address_family 必须在**构造前**就在类上定好(socketserver 建 socket
# 时读的就是这个类属性);构造后再赋值无效 —— 那会让 IPv6 分支静默失败。
class V6Server(HTTPServer):
address_family = socket.AF_INET6
# minPort:某些平台(CodeArts)要求回调端口 >= 10000,低于此值 portal 会拒绝。
# 照参考 listenOnCallbackPort:bind(0) 拿到的端口若过小,关掉后用 [minPort,65535]
# 的随机端口重试。
min_port = int(sys.argv[5]) if len(sys.argv) > 5 else 0
port = 0
try:
v6 = V6Server(("::1", 0), H)
port = v6.server_address[1]
if min_port and port < min_port:
v6.server_close()
port = 0
else:
SERVERS.append(v6)
except Exception:
pass
if port == 0:
for _ in range(6):
cand = random.randint(min_port, 65535) if min_port else 0
try:
v6 = V6Server(("::1", cand), H)
port = v6.server_address[1]
SERVERS.append(v6)
break
except Exception:
port = 0
try:
v4 = HTTPServer(("127.0.0.1", port), H)
if port == 0:
port = v4.server_address[1]
SERVERS.append(v4)
except Exception:
pass
if not SERVERS:
sys.exit(3)
# 把实际选中的端口写回文件:调用方读它拼 redirect_uri(bind 0 时端口只有 bind 后才知道)。
with open(port_path, "w") as f:
f.write(str(port))
for s in SERVERS:
threading.Thread(target=s.serve_forever, daemon=True).start()
threading.Event().wait()
]]
local function loopbackScriptPath()
local p = "/tmp/llmsproxy-loopback.py"
local w = io.open(p, "w")
if not w then return nil end
w:write(LOOPBACK_PY)
w:close()
return p
end
reapLoopback = function(sessionId)
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
if not l then return end
if l.pid and l.pid ~= "" then
os.execute("kill -9 " .. tostring(l.pid) .. " 2>/dev/null")
end
if l.file then os.remove(l.file) end
if l.port_file then os.remove(l.port_file) end
plugin.state.loopbacks[sessionId] = nil
end
local function loopbackReceiver(sessionId, cbPath, minPort)
cbPath = cbPath or "/auth/callback"
local script = loopbackScriptPath()
if not script then return nil, "无法写入 loopback 脚本" end
local dir = storeDir()
local outFile = dir .. "/callback-" .. sessionId .. ".json"
local portFile = dir .. "/callback-" .. sessionId .. ".port"
os.remove(outFile)
os.remove(portFile)
local sess = sessions()[sessionId]
local wantState = (sess and sess.nonce) or ""
local cmd = ("python3 %s %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format(
script, cbPath, outFile, wantState, portFile, tostring(minPort or 0), sessionId)
local h = io.popen(cmd)
local pid = h and h:read("*l")
if h then h:close() end
if not pid or pid == "" then return nil, "loopback 启动失败(python3 不可用?)" end
if not plugin.state.loopbacks then plugin.state.loopbacks = {} end
plugin.state.loopbacks[sessionId] = { pid = pid, file = outFile, port_file = portFile }
local deadline = (os.time and os.time() or 0) + 10
local port = nil
while true do
local f = io.open(portFile, "r")
if f then
local v = f:read("*l")
f:close()
port = tonumber(v)
if port then break end
end
if (os.time and os.time() or 0) >= deadline then
reapLoopback(sessionId)
return nil, "loopback 未就绪(未取得端口)"
end
os.execute("sleep 0.2")
end
local host = (sess and sess.callback_host) or "127.0.0.1"
return "http://" .. host .. ":" .. tostring(port) .. cbPath, nil
end
local function readLoopbackCallback(sessionId)
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
local f = l and l.file and io.open(l.file, "r")
if not f then return nil, nil end
local body = f:read("*a")
f:close()
local ok, rec = pcall(json.decode, body or "")
if not ok or type(rec) ~= "table" then return nil, nil end
return rec, nil
end
local function spawnBrowser(start_url)
local b = browserStack()
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
local deps = browserDeps()
if not deps.ok then
local miss = {}
for _, m in ipairs(deps.missing) do
miss[#miss + 1] = ("%s(%s,装 %s)"):format(m.bin, m.why, m.pkg)
end
return nil, "缺少浏览器栈依赖: " .. table.concat(miss, ", ")
end
b.seq = b.seq + 1
local seq = b.seq
local tag = tostring(os.time and os.time() or 0) .. "-" .. newToken():sub(1, 8)
local function pickFree(start, isFree)
for n = start, start + 40 do
if isFree(n) then return n end
end
return start
end
local function pathExists(path)
local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null')
return ok == true or ok == 0
end
local disp = pickFreeX(91, 200)
if not disp then
return nil, "没有可用的显示器号(/tmp/.X*-lock 全被占用)"
end
local bs = browserStack()
bs.display = disp
local function portFree(p)
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
local h = io.open(f, "r")
if h then
local hex = ("%04X"):format(p)
for line in h:lines() do
local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)")
if lp == hex then h:close() return false end
end
h:close()
end
end
return true
end
local vport = pickFree(59100, portFree)
local wport = pickFree(60100, portFree)
local cdpPort = pickFree(61100, portFree)
local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1"
local pass = sha256_hex(newToken()):sub(1, 16)
local gwPort = gatewayPort()
local pids = {}
local function spawn(name, cmd, host, port, timeoutMs)
local h = io.popen(cmd)
local pid = h and h:read("*l")
if h then h:close() end
if not pid or pid == "" then
return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")"
end
pids[#pids + 1] = pid
b.pids = pids
if port and port > 0 then
if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then
return false, name .. " 端口未就绪(:" .. port .. ")"
end
end
return true, pid
end
local chromium = deps.chromium
local novnc = deps.novnc
local profileDir = "/tmp/llmsproxy-ch-" .. tag
local homeDir = "/tmp/llmsproxy-bhome-" .. tag
os.execute("rm -rf " .. profileDir .. " " .. homeDir .. " 2>/dev/null")
os.execute("mkdir -p " .. homeDir .. "/.cache " .. homeDir .. "/.config " .. homeDir .. "/.local/share 2>/dev/null")
os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null")
browserStack().profile = profileDir
browserStack().home = homeDir
local steps = {
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 },
{ "chromium", ("HOME=%s XDG_CACHE_HOME=%s/.cache XDG_CONFIG_HOME=%s/.config "
.. "XDG_DATA_HOME=%s/.local/share "
.. "%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
.. "--no-first-run --disable-gpu --in-process-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
homeDir, homeDir, homeDir, homeDir,
chromium, profileDir, cdpPort, disp, start_url, tag), "127.0.0.1", cdpPort, 15000 },
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
.. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 },
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%s.log 2>&1 & echo $!"):format(
novnc, bind, wport, vport, tag), bind, wport, 8000 },
}
local xvfbStep = steps[1]
local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0)
if not xok then
reapBrowser()
return nil, xerr
end
local unixSock = ("/tmp/.X11-unix/X%d"):format(disp)
local xdeadline = (os.time and os.time() or 0) + 10
while true do
if pathExists(unixSock) then break end
if (os.time and os.time() or 0) >= xdeadline then
reapBrowser()
return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)"
end
os.execute("sleep 0.3")
end
for i = 2, #steps do
local st = steps[i]
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
if not ok then
reapBrowser()
return nil, err
end
end
os.execute("sleep 1")
local cpid = pids[2]
if cpid and cpid ~= "" and not browserUtil.pathExists("/proc/" .. cpid) then
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
reapBrowser()
return nil, "浏览器启动后即退出(多半是 GPU/权限问题):" .. tostring(why)
end
if not browserUtil.cdpAlive(cdpPort) then
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
reapBrowser()
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
end
b.pids = pids
local sess = tostring(seq) .. "-" .. pass:sub(1, 8)
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq, sess = sess,
expires_at = (os.time and os.time() or 0) + 900 }
local base = "/api/plugins/http/channels/browser/proxy/" .. sess
local proxyPath = base .. "/vnc.html?autoconnect=1&resize=scale&password=" .. pass
.. "&path=websockify"
return {
view_url = proxyPath,
proxy_path = proxyPath,
proxy_url = base .. "/vnc.html",
session_id = sess,
vnc_target = ("http://127.0.0.1:%d"):format(wport),
password = pass,
cdp_port = cdpPort,
display = disp,
}
end
local function initLoginSession(plat, args, now)
local sess = {
id = newToken():sub(1, 24),
platform = plat.id,
flow = plat.flow,
url = plat.login_url,
nonce = newToken():sub(1, 32),
state = "pending",
created_at = now,
expires_at = now + plugin.K.SESSION_TTL,
}
if plat.id == "lobsterai" then
local seed = newToken()
sess.install_uuid = seed:sub(1, 8) .. "-" .. seed:sub(9, 12) .. "-4"
.. seed:sub(14, 16) .. "-8" .. seed:sub(18, 20) .. "-" .. seed:sub(21, 32)
sess.first_keyfrom = tostring(now * 1000)
elseif plat.id == "trae" then
sess.machine_id = newToken():sub(1, 32)
sess.device_id = newToken():sub(33, 64)
local joined = sess.machine_id .. sess.device_id
sess.login_trace_id = #joined >= 16 and joined:sub(-16) or joined
end
sessions()[sess.id] = sess
sess.account_id = args and args.account_id
local region = tostring((args and args.region) or "")
if region ~= "cn" then region = "intl" end
sess.region = region
return sess
end
function plugin.start_login(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then
return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } }
end
sweepSessions()
local now = os.time and os.time() or 0
local sess = initLoginSession(plat, args, now)
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
local out = {
session = { id = sess.id, platform = sess.platform, flow = sess.flow,
state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at },
platform = plat.id,
}
if plat.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" or plat.id == "codearts" then
local cbPath = (plat.authorize and plat.authorize.callback_path)
or (plat.id == "gemini" and "/oauth-callback")
or (plat.id == "trae" and "/authorize") or "/auth/callback"
if plat.id == "gemini" then sess.callback_host = "localhost" end
if plat.id == "codearts" then
local pkce, perr = codeartsPkcePair()
if not pkce then
sess.state = "failed"
out.error = "PKCE 生成失败: " .. tostring(perr)
return { status = 200, json = out }
end
local key, kerr = codeartsNewDpop()
if not key then
sess.state = "failed"
out.error = "DPoP 密钥生成失败: " .. tostring(kerr)
return { status = 200, json = out }
end
sess.codearts = { pkce = pkce, key = key, ticket_id = newToken():sub(1, 64) }
sess.min_port = (plat.authorize and plat.authorize.min_port) or 10000
end
local minPort = nil
if plat.id == "codearts" then
minPort = (plat.authorize and plat.authorize.min_port) or 10000
end
local cb_url, cb_err = loopbackReceiver(sess.id, cbPath, minPort)
if cb_err then
sess.state = "failed"
out.error = "无法启动回调接收器: " .. cb_err
return { status = 200, json = out }
end
sess.redirect_uri = cb_url
out.redirect_uri = cb_url
end
if plat.flow == "manual" then
sess.state = "failed"
sess.message = plat.manual_hint or "该平台不支持自动登录"
out.manual_hint = sess.message
out.url = plat.login_url
logEvent("手动流程 " .. plat.id)
return { status = 200, json = out }
elseif plat.flow == "state-poll" then
local region = tostring(sess.region or "intl")
local bhost = plugin.K.BUDDY_REGION_HOST[region] or plugin.K.BUDDY_REGION_HOST.intl
local bplat = (region == "cn") and "ide" or "workbuddy-ai"
local stateUrl = tostring(plat.state.state_url)
:gsub("{buddyhost}", bhost):gsub("{platform}", bplat)
local resp, err = __llmsproxy_http({
url = stateUrl,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = "{\"platform\":\"" .. bplat .. "\"}",
})
if err or resp.status ~= 200 then
sess.state = "failed"
out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status)))
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or type(rec.data) ~= "table"
or not rec.data.state or rec.data.state == "" then
sess.state = "failed"
out.error = "auth/state 响应缺少 state 字段"
return { status = 200, json = out }
end
sess.state_token = rec.data.state
out.url = rec.data.authUrl or plat.login_url
logEvent("state 已申请 " .. plat.id)
elseif plat.flow == "vnc" then
local bs, berr = spawnBrowser(string.format(plat.login_url, 0))
if berr then
out.browser_error = berr
out.hint = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖"
logEvent("服务端浏览器启动失败: " .. berr)
else
out.view_url = bs.view_url
out.session.cdp_port = bs.cdp_port
sess.cdp_port = bs.cdp_port
sess.collect_expr = plat.collect_expr
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
end
elseif plat.flow == "qr-poll" then
local q = plat.qr
local code, scanPath, interval
local usedIAM = false
if type(q.create_path) == "string" and q.create_path ~= "" then
local cresp, cerr = __llmsproxy_http({
url = q.api_base .. q.create_path,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = "{}",
})
if not cerr and cresp and cresp.status == 200 then
local okc, crec = pcall(json.decode, cresp.body or "")
local d = okc and type(crec) == "table" and (crec.data or crec) or nil
if type(d) == "table" and type(d.qrcode_code) == "string" and d.qrcode_code ~= "" then
code = d.qrcode_code
usedIAM = true
if type(d.scan_path) == "string" and d.scan_path ~= ""
and d.scan_path ~= q.login_page then
scanPath = d.scan_path
end
if type(d.poll_interval) == "number" and d.poll_interval > 0 then
interval = math.max(1, math.min(60, d.poll_interval))
end
end
end
end
if code == nil then
code = tostring(sha256_hex(tostring(os.time and os.time() or 0) .. tostring(math.random(1e12)))):sub(1, 32)
logEvent("二维码走了本地兜底(服务端建码失败,扫码可能无法完成) " .. plat.id, "warn", plat.id)
end
sess.qr_code = code
sess.qr_iam = usedIAM and true or false
out.qr_url = q.api_base .. (scanPath or q.scan_page or q.login_page)
.. "?code=" .. code
.. "&appname=" .. rfc3986Escape(q.app_name or "")
.. "&source=" .. rfc3986Escape(q.source or "platform")
out.url = out.qr_url
out.session.interval = interval or q.interval or 2
logEvent("二维码已生成 " .. plat.id)
elseif plat.flow == "wechat-poll" then
local w = plat.wechat
local authUrl = w.auth_url:gsub("{state}", sess.nonce)
local resp, err = __llmsproxy_http({
url = authUrl, method = "GET",
headers = { ["Accept"] = "text/html", ["User-Agent"] = w.user_agent,
["Referer"] = "https://open.weixin.qq.com/" },
})
if err or not resp or resp.status ~= 200 then
sess.state = "failed"
out.error = "微信授权页拉取失败: " .. (err or ("HTTP " .. tostring(resp and resp.status)))
return { status = 200, json = out }
end
local html = resp.body or ""
local uuid = html:match("/connect/qrcode/([%w_%-=+/]+)")
or html:match("l/qrconnect%?uuid=([%w_%-=+/]+)")
if not uuid or uuid == "" then
sess.state = "failed"
out.error = "微信授权页未包含二维码 uuid(页面结构可能已变化)"
return { status = 200, json = out }
end
sess.wechat_uuid = uuid
sess.wechat_last = ""
out.qr_url = w.qr_url:gsub("{uuid}", uuid)
out.url = authUrl
logEvent("微信二维码已就绪 " .. plat.id)
elseif plat.flow == "init-poll" then
local ip = plat.initpoll
local flowSecret = sha256_hex(newToken() .. tostring(os.time and os.time() or 0))
.. sha256_hex(newToken())
local resp, err = __llmsproxy_http({
url = ip.init_url,
method = "POST",
headers = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. flowSecret,
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
["HTTP-Referer"] = ip.origin or "",
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
["X-Platform"] = "win32",
},
body = json.encode({ provider = ip.provider or "bigmodel" }),
})
if err then
sess.state = "failed"
out.error = "授权初始化失败: " .. err
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
local data = ok and type(rec) == "table" and rec.data or nil
if not data or type(data.flow_id) ~= "string" or data.flow_id == "" then
sess.state = "failed"
out.error = "授权初始化响应缺少 flow_id(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
end
sess.init_flow_id = data.flow_id
sess.init_secret = flowSecret
sess.device_interval = tonumber(data.poll_interval_sec) or ip.interval or 2
out.url = data.authorize_url or plat.login_url
out.session.interval = sess.device_interval
logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12))
elseif plat.flow == "device-poll" then
local verifier, challenge = devicePkce()
local machineId = newToken():sub(1, 32)
local dm = plat.device.code_method or "POST"
local rec = nil
if dm == "GET_QUERY" or plat.device.auth_query then
local ra = plugin.K.QODER_REGION_AUTH[tostring(sess.region or "intl")]
or plugin.K.QODER_REGION_AUTH.intl
local codeUrl = tostring(plat.device.code_url or "")
codeUrl = codeUrl:gsub("{auth}", ra.auth):gsub("{openapi}",
plugin.K.QODER_REGION_BASE[sess.region or "intl"] or QODER_SASH_BASE)
local q = (plat.device.auth_query or "")
:gsub("{challenge}", challenge):gsub("{nonce}", sess.nonce)
:gsub("{machine_id}", machineId):gsub("{verifier}", verifier)
:gsub("{client_id}", ra.client_id)
out.url = codeUrl .. "?" .. q
sess.device_verifier = verifier
sess.device_challenge = challenge
sess.device_nonce = sess.nonce
sess.device_machine = machineId
sess.device_interval = plat.device.interval or 2
out.session.interval = sess.device_interval
logEvent("授权页已就绪 " .. plat.id)
return { status = 200, json = out }
end
local body = (plat.device.code_body:gsub("{challenge}", challenge))
local resp, err = __llmsproxy_http({
url = plat.device.code_url,
method = dm,
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
if err then
sess.state = "failed"
out.error = "设备码申请失败: " .. err
return { status = 200, json = out }
end
local ok, r2 = pcall(json.decode, resp.body or "")
rec = ok and type(r2) == "table" and r2 or nil
if not rec or not rec.device_code then
sess.state = "failed"
out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
end
sess.device_code = rec.device_code
sess.device_verifier = verifier
sess.device_challenge = challenge
sess.device_interval = tonumber(rec.interval) or plat.device.interval or 5
sess.user_code = rec.user_code
out.url = rec.verification_uri_complete or rec.verification_uri or rec.verification_url or plat.login_url
out.session.user_code = rec.user_code
out.session.interval = sess.device_interval
logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code))
else
local port = gatewayPort()
if plat.flow == "callback" then
if plat.id == "codearts" and sess.codearts then
local cbPort = tonumber(sess.redirect_uri and sess.redirect_uri:match(":(%d+)/"))
out.url = buildCodeartsAuthorizeUrl(cbPort or port, sess.codearts.pkce,
sess.codearts.ticket_id)
elseif type(plat.authorize) == "table" then
local vars
if sess.machine_id then
vars = { machine_id = sess.machine_id, device_id = sess.device_id,
trace_id = sess.login_trace_id }
end
out.url = buildAuthorizeUrl(plat, port, sess.nonce, sess.redirect_uri, vars)
else
local cb = "http://localhost:" .. tostring(port) .. "/api/plugins/http/channels/callback"
local sep = plat.login_url:find("?", 1, true) and "&" or "?"
out.url = plat.login_url .. sep .. "redirect_uri=" .. rfc3986Escape(cb)
.. "&state=" .. sess.nonce
end
local deps = browserDeps()
if deps.ok then
local cur = browserStack()
if cur.session then reapBrowser() end
cur.owner = sess.id
sess.owns_browser = true
local bs, berr = spawnBrowser(out.url)
if not berr then
out.view_url = bs.view_url
out.session.cdp_port = bs.cdp_port
sess.cdp_port = bs.cdp_port
sess.collect_expr = plat.collect_expr or plat.callback_collect_expr
out.server_browser = true
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
else
out.browser_error = berr
logEvent("服务端浏览器启动失败 " .. plat.id .. ": " .. berr)
end
else
local miss = {}
for _, m in ipairs(deps.missing) do miss[#miss + 1] = m.bin end
out.browser_error = "缺浏览器栈依赖: " .. table.concat(miss, ", ")
end
out.hint = out.server_browser
and "请在 VNC 窗口内完成授权(回调会落在网关本机)"
or "callback 流的回调是回环地址,只有与网关同机的浏览器才能完成;请先安装浏览器栈(channels/deps/status 可查缺什么)"
else
out.url = plat.login_url
end
end
return { status = 200, json = out }
end
lobsteraiClientVersion = function(plat)
local cv = plat.client_version
if type(cv) ~= "table" then return "2026.9.4" end
local cache = plugin.state.clientVersion
local now = os.time and os.time() or 0
if type(cache) == "table" and cache.value and cache.at
and (now - cache.at) < (cv.ttl or 3600) then
return cache.value
end
local resp, err = __llmsproxy_http({
url = cv.url, method = "GET",
headers = { ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" },
})
if not err and resp and resp.status == 200 then
local ok, rec = pcall(json.decode, resp.body or "")
local v = ok and rec
for seg in tostring(cv.path or ""):gmatch("[^%.]+") do
if type(v) ~= "table" then v = nil break end
v = v[seg]
end
if type(v) == "string" and v:match("^%d+[%d%.]*[%w%.%-]*$") then
plugin.state.clientVersion = { value = v, at = now }
return v
end
logEvent("有道版本号格式异常,改用兜底: " .. tostring(v))
end
return cv.fallback or "2026.9.4"
end
local function lobsteraiExchange(plat, code, s)
if type(code) ~= "string" or code == "" then return nil, nil, "missing authCode" end
local now = tostring(os.time() * 1000)
local resp, err = __llmsproxy_http({
url = plat.callback.token_url, method = "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json",
["User-Agent"] = "LobsterAI/0.1.0" },
body = json.encode({ authCode = code, firstKeyfrom = s.first_keyfrom,
latestKeyfrom = now, uuid = s.install_uuid, version = lobsteraiClientVersion(plat) }),
})
if err or not resp or resp.status ~= 200 then
return nil, nil, "LobsterAI exchange 请求失败: " .. tostring(err or (resp and resp.status))
end
local ok, rec = pcall(json.decode, resp.body or "")
local d = ok and type(rec) == "table" and rec.data
if not (rec and type(rec) == "table" and rec.code == 0 and type(d) == "table"
and type(d.accessToken) == "string" and d.accessToken ~= "") then
local why = ""
if ok and type(rec) == "table" then
why = tostring(rec.msg or rec.message or ("code=" .. tostring(rec.code)))
end
return nil, nil, "LobsterAI exchange 失败: " .. why
end
local u = type(d.user) == "table" and d.user or {}
local function nonempty(v) return type(v) == "string" and v ~= "" and v or nil end
local uid = nonempty(u.id) or nonempty(u.userId) or nonempty(u.yid)
or sha256_hex(d.accessToken):sub(1, 16)
local account = nonempty(u.nickname) or uid
local cred = { access_token = d.accessToken, refresh_token = d.refreshToken or "",
uid = uid, user_id = nonempty(u.userId) or nonempty(u.yid) or "", nickname = account,
uuid = s.install_uuid, first_keyfrom = s.first_keyfrom, latest_keyfrom = now }
local exp = tonumber(d.expiresIn)
if exp and exp > 0 then cred.expires_at = tostring(os.time() * 1000 + exp * 1000)
else cred.expires_at = credentialExpiresAtMs(cred) end
return json.encode(cred), account, nil
end
function plugin.poll_login(req)
local id = (req and req.query and req.query.id) or ""
sweepSessions()
local s = sessions()[id]
if not s then return { status = 200, json = { state = "gone" } } end
if s.flow == "callback" and s.state == "pending" and s.redirect_uri then
local cb, _ = readLoopbackCallback(s.id)
local gotState = cb and (cb.state or "")
local code = cb and (cb.code or cb.authCode or "")
local stateOK = (s.platform == "trae") or (s.platform == "codearts")
or (gotState ~= "" and gotState == s.nonce)
if cb and stateOK then
local plat = platformById(s.platform)
local credential, account, cerr
if s.platform == "codearts" then
local ca = s.codearts or {}
local secret = cb and (cb.secret or "")
if secret ~= "" then
local credTbl, terr = codeartsTicketPoll(ca.ticket_id, secret,
CODEARTS_LOGIN_TICKET_NAME or nil, nil)
if not credTbl then
cerr = terr
else
credential, account = json.encode(credTbl), (credTbl.user_name or "codearts")
end
elseif code ~= "" and ca.pkce and ca.key then
local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0
local token, terr = codeartsRequestToken({
client_id = CODEARTS.client_id,
code = code,
code_verifier = ca.pkce.verifier,
grant_type = "authorization_code",
redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS.redirect_path,
}, ca.key)
if not token then
cerr = terr
else
local cred = codeartsCredentialFrom(token, ca.pkce, ca.key)
credential = json.encode(cred)
account = ca.ticket_id and ("codearts-" .. tostring(ca.ticket_id):sub(1, 8)) or "codearts"
end
else
cerr = "回调既无 code 也无 secret(无法完成 CodeArts 登录)"
end
elseif s.platform == "lobsterai" then
credential, account, cerr = lobsteraiExchange(plat, code, s)
elseif s.platform == "trae" then
credential, account, cerr = traeCallback(plat, cb, s)
else
local body = (plat.callback.token_body or "")
:gsub("{code}", code)
:gsub("{port}", tostring(gatewayPort()))
:gsub("{verifier}", s.device_verifier or "")
local resp, err = __llmsproxy_http({
url = plat.callback.token_url, method = "POST",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded",
["Accept"] = "application/json" },
body = body,
})
if err or not resp or resp.status ~= 200 then
cerr = "token 交换失败: " .. tostring(err or (resp and resp.status))
if resp and resp.body then cerr = cerr .. "(" .. tostring(resp.body):sub(1, 160) .. ")" end
else
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.access_token then
cerr = "token 交换响应缺少 access_token"
else
credential, account = json.encode(rec), "oauth"
end
end
end
reapLoopback(s.id)
if cerr then
s.state = "failed"
s.message = cerr
else
local res = plugin.complete_login({ platform = s.platform, credential = credential,
account = account, account_id = s.account_id })
if res.json and (res.json.ok or res.json.account_id) then
s.state = "completed"
s.account = account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
end
if s.flow == "device-poll" and s.state == "pending"
and (s.device_code or s.device_verifier) then
local plat = platformById(s.platform)
local done, payload = deviceOneStep(plat, s)
if done and payload then
if payload.ok then
local cred, acct, rerr = deviceRegister(plat, payload)
if rerr then
s.state = "failed"
s.message = rerr
else
if type(cred) == "table" and s.region then cred.region = s.region end
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct or payload.account, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct or payload.account
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
else
s.state = "failed"
s.message = payload.message
end
end
elseif s.flow == "qr-poll" and s.state == "pending" and s.qr_code then
local plat = platformById(s.platform)
local q = plat and plat.qr
if q then
local pollUrl
if s.qr_iam and type(q.iam_poll_path) == "string" and q.iam_poll_path ~= "" then
pollUrl = q.api_base .. q.iam_poll_path
else
pollUrl = q.api_base .. q.auth_prefix .. q.poll_path
end
local resp, err = __llmsproxy_http({
url = pollUrl,
method = "POST",
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" },
body = json.encode({ qrcode_code = s.qr_code }),
})
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
local env = ok and type(rec) == "table" and rec or nil
local codeOk = env and (env.code == 0 or env.code == "OK")
if codeOk and type(env.data) == "table" then
local st = env.data.status
if st == "canceled" then
s.state = "failed"
s.message = "用户取消了扫码授权"
elseif st == "success" then
local at = env.data.access_token
if type(at) ~= "string" or at == "" then
s.wechat_hint = "扫码成功,等待凭据下发"
else
local cred = json.encode({
access_token = at,
refresh_token = env.data.refresh_token,
office_identity = env.data.office_identity,
token_type = "Bearer",
account_type = "personal",
})
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = "raccoon", account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = "raccoon"
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
elseif st == "logging" then
s.wechat_hint = "已扫码,正在登录"
end
end
end
end
elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then
local plat = platformById(s.platform)
local w = plat and plat.wechat
if w then
local last = s.wechat_last or ""
local url = w.poll_url .. "?uuid=" .. s.wechat_uuid
.. (last ~= "" and ("&last=" .. last) or "")
.. "&_=" .. tostring(os.time and os.time() or 0)
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = { ["Accept"] = "*/*", ["User-Agent"] = w.user_agent,
["Referer"] = w.auth_url:sub(1, (w.auth_url:find("?")) and w.auth_url:find("?") - 1 or #w.auth_url) },
})
if not err and resp then
local body = resp.body or ""
local errcode = body:match("wx_errcode%s*=%s*(%d+)") or ""
local code = body:match("wx_code%s*=%s*'([^']*)'") or ""
s.wechat_last = errcode
if errcode == "405" then
if code ~= "" then
local cred, cerr = wechatExchange(plat, code)
if cerr then
s.state = "failed"
s.message = cerr
else
local acct = "wechat"
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
elseif errcode == "404" then
s.wechat_hint = "已扫码,请在手机上确认"
elseif errcode == "403" then
s.state = "failed"
s.message = "用户取消了授权"
elseif errcode == "402" then
s.state = "failed"
s.message = "二维码已失效,请重新发起登录"
end
end
end
elseif s.flow == "init-poll" and s.state == "pending" and s.init_flow_id then
local plat = platformById(s.platform)
local ip = plat and plat.initpoll
if ip then
local url = ip.poll_url:gsub("{flow_id}", s.init_flow_id)
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = {
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. (s.init_secret or ""),
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
["HTTP-Referer"] = ip.origin or "",
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
},
})
if not err and resp then
local st = resp.status
local terminal = st >= 400 and st < 500 and st ~= 408 and st ~= 429
if terminal then
s.state = "failed"
s.message = "轮询被拒(HTTP " .. tostring(st) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" and rec.code == 0 and type(rec.data) == "table" then
local d = rec.data
local status = d.status
if status == "failed" then
s.state = "failed"
s.message = "用户拒绝了授权或授权失败"
elseif status == "ready" then
local jwt = d.token
local user = d.user or {}
local pick = function(blk)
if type(blk) ~= "table" then return nil, nil end
return blk.access_token or blk.accessToken, blk.refresh_token or blk.refreshToken
end
local bmAt, bmRt = pick(d.bigmodel)
local zaiAt, zaiRt = pick(d.zai)
local thirdAt = bmAt or zaiAt
if type(jwt) ~= "string" or jwt == "" then
s.state = "failed"
s.message = "授权响应缺少 zcode token"
else
local cred = json.encode({
access_token = jwt,
zcode_jwt = jwt,
bigmodel_access_token = bmAt,
zai_access_token = zaiAt,
refresh_token = bmRt or zaiRt,
user_id = user.user_id or user.id,
nickname = user.name or user.email,
token_type = "Bearer",
account_type = "personal",
})
local acct = user.name or user.email or user.user_id or "zcode"
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
end
end
end
end
elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then
local plat = platformById(s.platform)
local sp = plat and plat.state
local bhost = plugin.K.BUDDY_REGION_HOST[tostring(s.region or "intl")]
or plugin.K.BUDDY_REGION_HOST.intl
local resp, err = __llmsproxy_http({
url = (sp.token_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" },
})
if not err and resp then
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
local code = rec.code
local data = rec.data
if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then
local access = data.accessToken or data.access_token
local accountData = nil
local aresp, aerr = __llmsproxy_http({
url = (sp.account_url or ""):gsub("{buddyhost}", bhost) .. "?state=" .. tostring(s.state_token),
method = "GET",
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access },
})
if not aerr and aresp and aresp.status == 200 then
local aok, arec = pcall(json.decode, aresp.body or "")
if aok and type(arec) == "table" and type(arec.data) == "table" then
accountData = arec.data
end
end
local credTable = buildCredential(data, accountData)
local credJson = json.encode(credTable)
local acct = credTable.nickname
if acct == nil or acct == "" then
acct = credTable.user_id
end
if acct == nil or acct == "" then acct = "codebuddy" end
credTable.region = tostring(s.region or "intl")
credJson = json.encode(credTable)
local res = plugin.complete_login({ platform = s.platform, credential = credJson,
account = acct, account_id = s.account_id,
expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
elseif code == sp.code_not_ready then
elseif resp.status == 200 and data == nil then
else
s.state = "failed"
s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code)
end
end
end
end
return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } }
end
function plugin.complete_login(req)
local args = bodyOf(req)
if args.platform == nil then
args = req
end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local token = args and args.credential
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
local aid = args.account_id
if aid and aid ~= "" then
local acct = poolGet(plat.id, aid)
if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end
local ref = acct.credentialRef
local _, serr = __llmsproxy_secrets.set(ref, token)
if serr then return { status = 400, json = { error = serr } } end
local credTbl = nil
if type(token) == "string" and token:sub(1, 1) == "{" then
local dok, drec = pcall(json.decode, token)
if dok and type(drec) == "table" then credTbl = drec end
end
local derivedNick = credTbl and credTbl.nickname
if derivedNick == nil or derivedNick == "" then derivedNick = credTbl and credTbl.user_id end
local derivedExp = credTbl and credentialExpiresAtMs(credTbl)
acct.nickname = args.account or derivedNick or acct.nickname or aid
acct.expiresAt = args.expires_at or (derivedExp and tostring(derivedExp)) or acct.expiresAt
if args.refreshable ~= nil then
acct.refreshable = args.refreshable and true or false
elseif credTbl ~= nil then
acct.refreshable = credentialIsRefreshable(credTbl)
else
acct.refreshable = acct.refreshable or false
end
poolAdd(plat.id, acct)
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending"
and (sess.account_id == aid or sess.pool_account_id == aid) then
sess.state = "completed"
end
end
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
syncAccountSource(plat.id, aid)
return { status = 200, json = { ok = true, account_id = aid, source = providerSourceName(plat.id) } }
end
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
if err then return { status = 400, json = { error = err } } end
local legacyTok = credentialOf("channels:" .. plat.id) or token
plugin.state.accounts[plat.id] = {
account = args.account or "已登录",
added_at = os.time and os.time() or 0,
enabled = true,
}
for id, sess in pairs(sessions()) do
if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end
end
logEvent("已登录 " .. plat.name)
local r, aerr = __llmsproxy_kernel.call("add_source", {
source = {
name = "channels-" .. plat.id,
base_url = plat.base_url,
adapter = "openai",
api_key = legacyTok,
max_concurrent = 1,
rpm = plat.rpm or 20,
models = (function()
local ms = {}
local eff = effectiveModels(plat.id)
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end
return ms
end)(),
},
})
if aerr then
logEvent("接入源失败 " .. plat.id .. ": " .. aerr)
return { status = 200, json = { ok = true, source_error = aerr } }
end
logEvent("已接入源 " .. r.name)
return { status = 200, json = { ok = true, source = r.name } }
end
function plugin.claim_daily(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if (not plat.checkin or not plat.checkin.url) and not plat.claim_fn then
return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } }
end
local tok, acctEntry, credArgs
if args.account_id and args.account_id ~= "" then
acctEntry = poolGet(plat.id, args.account_id)
if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end
local _, _, rec = credentialOf(acctEntry.credentialRef)
tok = credentialOf(acctEntry.credentialRef)
credArgs = { account_id = args.account_id, credential = rec }
else
local _, _, rec = credentialOf("channels:" .. plat.id)
tok = credentialOf("channels:" .. plat.id)
credArgs = { credential = rec }
end
if not tok then return { status = 400, json = { error = "未登录" } } end
local spec = {
url = plat.checkin and plat.checkin.url or "",
method = (plat.checkin and plat.checkin.method) or "POST",
headers = (plat.checkin and plat.checkin.headers)
or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = (plat.checkin and plat.checkin.body) or "{}",
}
if plat.claim_fn then
local custom, cerr = plat.claim_fn(plat, tok, credArgs)
if cerr then return { status = 400, json = { error = cerr } } end
if custom then spec = custom end
end
if type(spec) == "table" and spec.done then
local acct0 = acctEntry or plugin.state.accounts[plat.id] or {}
acct0.last_claim = os.time and os.time() or 0
acct0.last_claim_status = spec.kind == "success" and 200 or 0
acct0.last_claim_summary = spec.message
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
local covers = spec.covers_today
if covers == nil then covers = true end
local outcome = { kind = spec.kind, message = spec.message, covers_today = covers }
if spec.action_required then outcome.action_required = true end
return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } }
end
local resp, err = __llmsproxy_http(spec)
if err then
logEvent("领取失败 " .. plat.id .. ": " .. err)
return { status = 400, json = { error = err } }
end
local acct
if acctEntry then
acct = acctEntry
else
acct = plugin.state.accounts[plat.id] or {}
end
acct.last_claim = os.time and os.time() or 0
acct.last_claim_status = resp.status
if plat.claim_result_fn and resp.status == 200 then
local ok, summary = pcall(plat.claim_result_fn, resp.body)
if ok and summary then acct.last_claim_summary = summary end
end
if acctEntry then
poolAdd(plat.id, acctEntry)
else
plugin.state.accounts[plat.id] = acct
end
logEvent("领取返回 " .. tostring(resp.status))
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } }
end
plugin.schedule = {
{ fn = "auto_claim", every = 7200, first_due_now = true, jitter = 300 },
{ fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 },
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
}
local function normalizeCapability(id, raw)
if type(raw) ~= "table" then return nil end
local declared = {}
local mods = raw.modalities
if type(mods) == "table" and type(mods.input) == "table" then
for _, m in ipairs(mods.input) do declared[#declared+1] = tostring(m) end
end
local modalities = { "text" }
for _, m in ipairs(declared) do
if m == "image" then modalities = { "text", "image" } break end
end
local lim = type(raw.limit) == "table" and raw.limit or {}
local cost = type(raw.cost) == "table" and raw.cost or {}
local ci, co = tonumber(cost.input), tonumber(cost.output)
local efforts = {}
if type(raw.efforts) == "table" then
for _, e in ipairs(raw.efforts) do efforts[#efforts+1] = tostring(e) end
end
return {
id = id,
name = type(raw.name) == "string" and raw.name ~= "" and raw.name or id,
modalities = modalities,
contextWindow = tonumber(lim.context) or 0,
maxOutputTokens = tonumber(lim.output) or 0,
reasoning = (raw.reasoning == true) and #efforts > 0,
efforts = efforts,
toolCall = raw.tool_call ~= false,
isFree = (ci ~= nil and ci == 0 and co ~= nil and co == 0),
}
end
local function capabilityOf(provider, modelId)
local table_ = plugin.state.capabilities and plugin.state.capabilities[provider]
if type(table_) ~= "table" then return nil end
return table_[modelId]
end
local function refreshCapabilities()
local cached = plugin.state.capabilitiesMeta
local now = os.time and os.time() or 0
if cached and cached.at and cached.at <= now and (now - cached.at) < plugin.K.MODELS_DEV_TTL then
return false, "未到刷新时间"
end
local resp, err = __llmsproxy_http({
url = plugin.K.MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
headers = { ["Accept"] = "application/json" },
})
if err or not resp or resp.status ~= 200 then
local es = tostring(err or (resp and resp.status))
if es:find("byte cap") then
return false, "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,"
.. "不为此放宽)—— 能力增强不可用,已退回兜底表"
end
return false, "拉取失败: " .. es
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return false, "响应无法解析" end
local out = {}
local n = 0
for _, plat in ipairs(allPlatforms()) do
local key = plat.capability_key or plat.id
local src = rec[key]
if type(src) == "table" then
local models = type(src.models) == "table" and src.models or src
local tbl = {}
for id, raw in pairs(models) do
local cap = normalizeCapability(tostring(id), raw)
if cap then tbl[tostring(id)] = cap n = n + 1 end
end
out[plat.id] = tbl
end
end
plugin.state.capabilities = out
plugin.state.capabilitiesMeta = { at = now, count = n }
markDirty()
return true, n
end
local function ttlCacheGet(namespace, key, ttl, loader)
plugin.state.ttlCache = plugin.state.ttlCache or {}
local ns = plugin.state.ttlCache[namespace]
if not ns then ns = {} plugin.state.ttlCache[namespace] = ns end
local now = os.time and os.time() or 0
local e = ns[key]
if type(e) == "table" then
if e.at <= now and (now - e.at) < ttl then
return e.value
end
ns[key] = nil
end
local inflight = plugin.state.ttlInflight or {}
plugin.state.ttlInflight = inflight
local ik = namespace .. ":" .. tostring(key)
local last = inflight[ik]
if last and last <= now and (now - last) < 1 then
return nil
end
inflight[ik] = now
local ok, value = pcall(loader)
if not ok or value == nil then return nil end
ns[key] = { at = now, value = value }
return value
end
local function ttlCacheInvalidate(namespace, key)
plugin.state.ttlCache = plugin.state.ttlCache or {}
if key == nil then
plugin.state.ttlCache[namespace] = {}
elseif plugin.state.ttlCache[namespace] then
plugin.state.ttlCache[namespace][key] = nil
end
local inflight = plugin.state.ttlInflight
if inflight then
if key == nil then
local prefix = namespace .. ":"
for k in pairs(inflight) do
if k:sub(1, #prefix) == prefix then inflight[k] = nil end
end
else
inflight[namespace .. ":" .. tostring(key)] = nil
end
end
end
local function serialTryAcquire(namespace, ttl)
plugin.state.serialLock = plugin.state.serialLock or {}
local now = os.time and os.time() or 0
local held = plugin.state.serialLock[namespace]
if held and held <= now and (now - held) < (ttl or 120) then
return false
end
plugin.state.serialLock[namespace] = now
return true
end
local function serialRelease(namespace)
if plugin.state.serialLock then plugin.state.serialLock[namespace] = nil end
end
expiringWindowDays = function()
local cfg = plugin.state.settings and plugin.state.settings.expiring_window_days
if cfg ~= nil then
local n = tonumber(cfg)
if n and n >= 0 then return n end
end
local raw = os.getenv and os.getenv("DSH_BUDDY_EXPIRING_WINDOW_DAYS")
if type(raw) == "string" and raw ~= "" then
local n = tonumber(raw)
if n and n >= 0 then return n end
end
return plugin.K.EXPIRING_WINDOW_DAYS_DEFAULT
end
balanceTier = function(expiring, permanent, allow_permanent)
local function pos(v)
local n = tonumber(v)
if n and n > 0 then return n end
return 0
end
if pos(expiring) > 0 then return "expiring" end
if allow_permanent == false then return "none" end
if pos(permanent) > 0 then return "permanent" end
return "none"
end
local function tierUsable(tier)
return tier ~= "none"
end
accountBalanceRaw = function(provider, accountId)
local plat = platformById(provider)
local acct = poolGet(provider, accountId)
if not plat or not acct or not plat.balance then return nil end
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then return nil end
local plate = plat
local act = acct
return ttlCacheGet("balance", provider .. ":" .. accountId, plugin.K.BALANCE_CACHE_TTL, function()
local rec, err = fetchAccountJSON(plate.balance, tok,
credentialFull(act.credentialRef), plate)
if err or type(rec) ~= "table" then return nil end
if plate.balance.tier_fn then
local okf, t = pcall(plate.balance.tier_fn, rec)
if okf and type(t) == "table" then return t end
return nil
end
if plate.balance.total_fn then
local okf, total = pcall(plate.balance.total_fn, rec)
if okf and type(total) == "number" then
return { permanent_balance = total, expiring_balance = 0 }
end
end
return nil
end)
end
local function rankAccounts(provider)
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
local allowPermanent = not lockPermanent
local expiring, permanent, none = {}, {}, {}
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = accountBalanceRaw(provider, a.id)
local tier
if raw == nil then
tier = "none"
else
tier = balanceTier(raw.expiring_balance or raw.expiringBalance,
raw.permanent_balance or raw.permanentBalance, allowPermanent)
end
if tier == "expiring" then expiring[#expiring+1] = a
elseif tier == "permanent" then permanent[#permanent+1] = a
else none[#none+1] = a end
end
end
local all = {}
for _, a in ipairs(expiring) do all[#all+1] = a end
for _, a in ipairs(permanent) do all[#all+1] = a end
for _, a in ipairs(none) do all[#all+1] = a end
local usable = {}
for _, a in ipairs(expiring) do usable[#usable+1] = a end
if allowPermanent then
for _, a in ipairs(permanent) do usable[#usable+1] = a end
end
return { usable = usable, all = all, expiring = #expiring,
permanent = #permanent, none = #none,
locked = lockPermanent and true or false }
end
local DEAD_GONE_PATTERNS = {
"model%s+not%s+found",
"model%s+is%s+not%s+registered",
"unknown%s+model",
"no%s+such%s+model",
"nonexistent%s+model",
"model%s+%S+%s+has%s+been%s+deprecated",
"model%s+has%s+been%s+deprecated",
}
local DEAD_NOT_GONE_PATTERNS = {
"rate%s*%-?%s*limit",
"too%s+many%s+requests",
"quota",
"insufficient",
"unauthor", "forbidden", "invalid%s+token", "invalid%s+api%s*key", "invalid%s+credential",
"token%s+expired", "session%s+expired", "credential%s+expired", "key%s+expired",
"auth%s+expired", "lease%s+expired",
"expired%s+token", "expired%s+session", "expired%s+credential", "expired%s+key",
"expired%s+auth",
}
local function isModelGone(message)
if type(message) ~= "string" or message == "" then return false end
local m = message:lower()
for _, pat in ipairs(DEAD_NOT_GONE_PATTERNS) do
if m:find(pat) then return false end
end
for _, pat in ipairs(DEAD_GONE_PATTERNS) do
if m:find(pat) then return true end
end
local from = 1
while true do
local a, b = m:find("model[_%-]not[_%-]found", from)
if not a then break end
local rest = m:sub(b + 1, b + 1)
if rest == "" or rest:find('[%]%)%}%.!%;"]') then return true end
from = b + 1
end
if m:find("does%s+not%s+exist") or m:find("doesn't%s+exist") then return true end
return false
end
local function deadModelsOf(provider)
plugin.state.deadModels = plugin.state.deadModels or {}
local p = plugin.state.deadModels[provider]
if not p then p = {} plugin.state.deadModels[provider] = p end
return p
end
local function recordDeadModel(provider, modelId, reason)
if not modelId or modelId == "" then return false end
local p = deadModelsOf(provider)
if p[modelId] then return false end
p[modelId] = { at = os.time and os.time() or 0, reason = tostring(reason or ""):sub(1, 120) }
return true
end
deadModelIds = function(provider)
local p = deadModelsOf(provider)
local now = os.time and os.time() or 0
local ttl = plugin.K.DEAD_MODEL_TTL_DAYS * 86400
local out = {}
for id, meta in pairs(p) do
local at = type(meta) == "table" and tonumber(meta.at) or 0
if ttl > 0 and (now - at) > ttl then
p[id] = nil
else
out[id] = true
end
end
return out
end
local function clearDeadModels(provider, modelId)
local p = deadModelsOf(provider)
if modelId and modelId ~= "" then
local had = p[modelId] ~= nil
p[modelId] = nil
return had and 1 or 0
end
local n = 0
for _ in pairs(p) do n = n + 1 end
plugin.state.deadModels[provider] = {}
return n
end
local function catalogGateOf(provider)
plugin.state.catalogGate = plugin.state.catalogGate or {}
local g = plugin.state.catalogGate[provider]
if not g then g = {} plugin.state.catalogGate[provider] = g end
return g
end
local function catalogGateCooling(provider, now)
local g = catalogGateOf(provider)
if not g.retry_at then return false end
if now >= g.retry_at then return false end
if g.retry_at - now > plugin.K.REMOTE_CATALOG_COOLDOWN then return false end
return true
end
local function catalogGateBegin(provider, now)
local g = catalogGateOf(provider)
if catalogGateCooling(provider, now) then return false end
if g.in_flight_at and (now - g.in_flight_at) < 10 then return false end
g.in_flight_at = now
return true
end
local function catalogGateEnd(provider, got, now)
local g = catalogGateOf(provider)
g.in_flight_at = nil
g.retry_at = got and nil or (now + plugin.K.REMOTE_CATALOG_COOLDOWN)
end
function plugin.refresh_models()
local updated, failed, cooled = 0, {}, 0
local now = os.time and os.time() or 0
for _, plat in ipairs(allPlatforms()) do
if plat.models_remote then
if not catalogGateBegin(plat.id, now) then
cooled = cooled + 1
goto continue
end
local ids, err = refreshRemoteModels(plat.id)
catalogGateEnd(plat.id, ids ~= nil, now)
if ids then
updated = updated + 1
for _, a in ipairs(poolList(plat.id)) do
syncAccountSource(plat.id, a.id)
end
else
failed[#failed + 1] = plat.id .. ": " .. tostring(err)
end
end
::continue::
end
if cooled > 0 then
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")
end
local cok, cres, cerr = pcall(refreshCapabilities)
if not cok then
logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")
elseif cres == true then
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")
elseif cerr then
logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")
end
if updated > 0 then
logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")
end
if #failed > 0 then
for _, f in ipairs(failed) do
local pid = tostring(f):match("^([%w_%-]+):") or nil
logEvent("模型刷新失败 " .. tostring(f), "warn", pid)
end
end
end
function plugin.refresh_creds()
local lead = 3600
local now = os.time and os.time() or 0
local refreshed, skipped, failed = 0, 0, {}
for _, plat in ipairs(allPlatforms()) do
if plat.exchange_body or plat.refresh_url
or (type(plat.refresh) == "table" and plat.refresh.url)
or type(plat.refresh_fn) == "function" then
for _, a in ipairs(poolList(plat.id)) do
local cred = credentialFull(a.credentialRef)
if type(cred) == "table" then
local exp = tonumber(a.expiresAt) or credentialExpiresAtMs(cred)
local due = exp == nil or exp == 0
if not due then
local e = exp > 1e11 and math.floor(exp / 1000) or exp
due = (e - now) <= lead
end
if due then
local ok, err = plugin.account_refresh({ platform = plat.id, account_id = a.id })
if ok and ok.json and ok.json.refreshed then refreshed = refreshed + 1
else
skipped = skipped + 1
local why = ok and ok.json and ok.json.message or err
if why and why ~= "该平台未声明续期方式" then
failed[#failed+1] = plat.id .. ": " .. tostring(why)
end
end
else
skipped = skipped + 1
end
end
end
end
end
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个", "task") end
if #failed > 0 then
logEvent("凭据续期失败 " .. #failed .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn")
end
for _, plat in ipairs(allPlatforms()) do
syncProviderSource(plat.id)
end
end
local function todayKey()
local nowMs = (os.time and os.time() or 0) * 1000
local utc8 = os.date("!*d", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
local utc8m = os.date("!*m", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
local utc8y = os.date("!*Y", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
end
local function claimCoversToday(resp)
if type(resp) ~= "table" then return false end
local j = resp.json
if type(j) ~= "table" then return false end
local o = j.outcome
if type(o) ~= "table" then
return j.ok == true
end
if o.covers_today == false then return false end
local k = o.kind
return k == "success" or k == "claimed" or k == "already-claimed"
end
function plugin.auto_claim()
plugin.state.settings = plugin.state.settings or {}
if plugin.state.settings.auto_checkin == false then return end
local covered, pending = {}, {}
for _, plat in ipairs(allPlatforms()) do
local cap = CHANNEL_CREDITS[plat.id]
if not cap or not cap.claim then goto continue end
if cap.auto_exclude then goto continue end
if plugin.state.claimCovered and plugin.state.claimCovered[plat.id] == todayKey() then
covered[#covered + 1] = plat.id
goto continue
end
local got = false
local legacy = accountOf(plat.id)
if legacy and legacy.enabled then
local r = plugin.claim_daily({ platform = plat.id })
if claimCoversToday(r) then got = true end
end
for _, a in ipairs(poolList(plat.id)) do
if a.enabled ~= false and credentialOf(a.credentialRef) then
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id })
if claimCoversToday(r) then got = true end
end
end
if got then
plugin.state.claimCovered = plugin.state.claimCovered or {}
plugin.state.claimCovered[plat.id] = todayKey()
markDirty()
else
pending[#pending + 1] = plat.id
end
::continue::
end
if #pending > 0 then
logEvent("自动领取:已覆盖 " .. #covered .. " 个平台,"
.. #pending .. " 个待重试(" .. table.concat(pending, ", "):sub(1, 120) .. ")")
end
end
plugin.http = {
{ path = "channels/platforms", method = "GET", handler = "list_platforms",
roles = { "admin" }, description = "平台清单与账号状态" },
{ path = "channels/login", method = "POST", handler = "start_login",
roles = { "admin" }, description = "发起某平台的登录" },
{ path = "channels/login/status", method = "GET", handler = "poll_login",
roles = { "admin" }, description = "轮询登录会话状态" },
{ path = "channels/login/complete", method = "POST", handler = "complete_login",
roles = { "admin" }, description = "登录完成:存凭据并接入源" },
{ path = "channels/claim", method = "POST", handler = "claim_daily",
roles = { "admin" }, description = "手动领取某平台每日额度" },
{ path = "channels/status", method = "GET", handler = "status_page",
roles = { "admin" }, description = "接入状态与最近日志" },
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
public = false, roles = { "admin" }, description = "OAuth 回调" },
{ path = "channels/account/add", method = "POST", handler = "account_add",
roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" },
{ path = "channels/account/list", method = "POST", handler = "account_list",
roles = { "admin" }, description = "列平台账号池" },
{ path = "channels/account/update", method = "POST", handler = "account_update",
roles = { "admin" }, description = "改账号昵称/启用态" },
{ path = "channels/account/clear_credentialless", method = "POST", handler = "account_clear_credentialless",
roles = { "admin" }, description = "清理无凭据账号(占位;dry_run 默认开启)" },
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
{ path = "channels/account/test", method = "POST", handler = "account_test",
roles = { "admin" }, description = "测账号可达" },
{ path = "channels/account/refresh", method = "POST", handler = "account_refresh",
roles = { "admin" }, description = "刷新账号(重同步源)" },
{ path = "channels/account/reorder", method = "POST", handler = "account_reorder",
roles = { "admin" }, description = "调账号优先级" },
{ path = "channels/model/list", method = "POST", handler = "model_list",
roles = { "admin" }, description = "列平台模型与开关态" },
{ path = "channels/model/refresh", method = "POST", handler = "model_refresh",
roles = { "admin" }, description = "从上游拉取模型目录(远端为主、兜底补位)" },
{ path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled",
roles = { "admin" }, description = "开关单个模型" },
{ path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled",
roles = { "admin" }, description = "全开/全关平台模型" },
{ path = "channels/provider/status", method = "POST", handler = "provider_status",
roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" },
{ path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled",
roles = { "admin" }, description = "供应商整体开关" },
{ path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all",
roles = { "admin" }, description = "一键领取全平台每日额度" },
{ path = "channels/backup/export", method = "POST", handler = "backup_export",
roles = { "admin" }, description = "导出账号备份(含凭据)" },
{ path = "channels/backup/import", method = "POST", handler = "backup_import",
roles = { "admin" }, description = "导入账号备份" },
{ path = "channels/auto_checkin", method = "POST", handler = "auto_checkin",
roles = { "admin" }, description = "自动签到开关读写" },
{ path = "channels/credits/status", method = "POST", handler = "credits_status",
roles = { "admin" }, description = "逐账号签到状态" },
{ path = "channels/credits/pool_reason", method = "POST", handler = "credits_pool_reason",
roles = { "admin" }, description = "为什么没有可用账号(区分用尽/查不到)" },
{ path = "channels/credits/balances", method = "POST", handler = "credits_balances",
roles = { "admin" }, description = "逐账号余额" },
{ path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock",
roles = { "admin" }, description = "永久积分锁定" },
{ path = "channels/account/reset", method = "POST", handler = "account_reset",
roles = { "admin" }, description = "清账号限流标记" },
{ path = "channels/account/adopt", method = "POST", handler = "account_adopt",
help = "把旧版非池凭据接管进账号池(升级后账号消失时用)" },
{ path = "channels/account/retest", method = "POST", handler = "account_retest",
roles = { "admin" }, description = "重测账号" },
{ path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many",
roles = { "admin" }, description = "批量开关模型" },
{ path = "channels/model/clear_dead", method = "POST", handler = "model_clear_dead",
roles = { "admin" }, description = "清死模型" },
{ path = "channels/provider/get_order", method = "POST", handler = "provider_get_order",
roles = { "admin" }, description = "供应商显示顺序" },
{ path = "channels/provider/set_order", method = "POST", handler = "provider_set_order",
roles = { "admin" }, description = "设置供应商显示顺序" },
{ path = "channels/gateway/get_enabled", method = "POST", handler = "gateway_get_enabled",
roles = { "admin" }, description = "网关开关状态" },
{ path = "channels/gateway/set_enabled", method = "POST", handler = "gateway_set_enabled",
roles = { "admin" }, description = "网关整体开关" },
{ path = "channels/backup/status", method = "POST", handler = "backup_status",
roles = { "admin" }, description = "备份健康度" },
{ path = "channels/usage/token_ledger", method = "POST", handler = "usage_token_ledger",
roles = { "admin" }, description = "token 用量快照" },
{ path = "channels/usage/badge", method = "POST", handler = "usage_badge",
roles = { "admin" }, description = "渠道徽标" },
{ path = "channels/usage/badge_preference", method = "POST", handler = "usage_badge_preference",
roles = { "admin" }, description = "徽标显示偏好" },
{ path = "channels/onboarding/status", method = "POST", handler = "onboarding_status",
roles = { "admin" }, description = "新人任务状态" },
{ path = "channels/onboarding/claim", method = "POST", handler = "onboarding_claim",
roles = { "admin" }, description = "领取新人奖励" },
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
{ path = "channels/deps/status", method = "GET", handler = "deps_status",
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
roles = { "admin" }, description = "服务端浏览器会话状态" },
{ path = "channels/browser/proxy/:session", method = "GET", prefix = true,
handler = "browser_proxy", roles = { "admin" },
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
{ path = "channels/reap_orphan_sources", method = "POST", handler = "reap_orphan_sources",
roles = { "admin" }, description = "清理孤儿源(账号已删但源仍在对外服务;默认 dry_run)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
roles = { "admin" }, description = "回收服务端浏览器栈" },
}
function plugin.account_add(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local aid = args.account_id or newToken():sub(1, 12)
if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end
local ref = "channels:" .. plat.id .. ":" .. aid
if args.credential and args.credential ~= "" then
local _, serr = __llmsproxy_secrets.set(ref, args.credential)
if serr then return { status = 400, json = { error = serr } } end
end
local now = os.time and os.time() or 0
local entry = {
id = aid, provider = plat.id,
nickname = args.nickname or aid,
enabled = args.enabled ~= false,
credentialRef = ref,
refreshable = args.refreshable or false,
createdAt = now,
expiresAt = args.expires_at,
last_claim = 0, last_claim_status = 0, last_claim_summary = "",
}
poolAdd(plat.id, entry)
logEvent("账号入池 " .. plat.id .. "/" .. aid)
if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
end
plugin.__hasCredential = function(acct)
if not acct or not acct.credentialRef then return false end
local v = __llmsproxy_secrets.get(acct.credentialRef)
return type(v) == "string" and v ~= ""
end
function plugin.account_clear_credentialless(req)
local args = bodyOf(req)
if type(req) == "table" and req.body == nil and req.query == nil then args = req end
local dry = args.dry_run
if dry == nil then dry = true end
local platforms
if args.platform and args.platform ~= "" then
local plat = platformById(args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
platforms = { plat }
else
platforms = allPlatforms()
end
local victims, kept = {}, 0
for _, plat in ipairs(platforms) do
for _, a in ipairs(poolList(plat.id)) do
if plugin.__hasCredential(a) then
kept = kept + 1
else
victims[#victims + 1] = { platform = plat.id, id = a.id, nickname = a.nickname }
end
end
end
if dry then
return { status = 200, json = {
ok = true, dry_run = true, would_delete = #victims,
kept_with_credential = kept, accounts = victims,
message = "预演:将删除 " .. #victims .. " 个无凭据账号(有凭据的 " .. kept
.. " 个不动)。确认后传 dry_run=false 执行。",
} }
end
local done, failed = {}, {}
for _, v in ipairs(victims) do
local r = plugin.account_delete({ platform = v.platform, account_id = v.id })
if r and r.json and r.json.ok then
done[#done + 1] = v.platform .. "/" .. v.id
else
failed[#failed + 1] = v.platform .. "/" .. v.id
end
end
logEvent("清理无凭据账号 " .. #done .. " 个(失败 " .. #failed .. ")")
return { status = 200, json = {
ok = true, dry_run = false, deleted = #done, failed = #failed,
kept_with_credential = kept, deleted_accounts = done,
failed_accounts = #failed > 0 and failed or nil,
} }
end
function plugin.account_list(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local out = {}
for _, a in ipairs(poolList(plat.id)) do
local has = credentialOf(a.credentialRef)
out[#out+1] = {
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
credentialRef = a.credentialRef, refreshable = a.refreshable or false,
createdAt = a.createdAt, expiresAt = a.expiresAt,
has_credential = has ~= nil and has ~= "",
last_claim = a.last_claim, last_claim_status = a.last_claim_status,
last_claim_summary = a.last_claim_summary,
}
end
return { status = 200, json = { ok = true, accounts = out } }
end
function plugin.account_update(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local patch = type(args.patch) == "table" and args.patch or {}
if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end
if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end
poolAdd(plat.id, acct)
if patch.enabled ~= nil then
if acct.enabled then syncAccountSource(plat.id, acct.id)
else dropAccountSource(plat.id, acct.id) end
end
return { status = 200, json = { ok = true } }
end
function plugin.account_delete(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
dropAccountSource(plat.id, acct.id)
__llmsproxy_secrets.delete(acct.credentialRef)
poolRemove(plat.id, acct.id)
if #poolList(plat.id) == 0 then
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
end
logEvent("账号删除 " .. plat.id .. "/" .. acct.id)
return { status = 200, json = { ok = true } }
end
function plugin.account_test(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local tok = credentialOf(acct.credentialRef)
if not tok or tok == "" then
return { status = 200, json = { ok = false, error = "无凭据(需先登录)",
probed = false } }
end
syncAccountSource(plat.id, acct.id)
local spec = plat.balance or plat.checkin_status
if not spec or not spec.url then
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
local found = false
for _, s in ipairs(srcs or {}) do
if s.name == providerSourceName(plat.id) then found = true break end
end
return { status = 200, json = { ok = found, probed = false,
error = found and nil or "源未同步",
note = "该平台未声明可探测端点,仅校验本地凭据与源" } }
end
local t0 = os.clock and os.clock() or 0
local rec, err = fetchAccountJSON(spec, tok, credentialFull(acct.credentialRef), plat)
local ms = math.floor(((os.clock and os.clock() or 0) - t0) * 1000)
if err then
local kind = "transient"
local el = tostring(err):lower()
if el:find("401") or el:find("403") or el:find("unauthor")
or el:find("invalid_grant") or el:find("expired") then
kind = "auth"
end
return { status = 200, json = { ok = false, probed = true, ms = ms,
error = tostring(err), kind = kind,
action = kind == "auth" and "凭据已失效,请重新登录该账号" or "上游暂时不可用,可稍后重试" } }
end
return { status = 200, json = { ok = true, probed = true, ms = ms } }
end
function plugin.account_adopt(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local legacyRef = "channels:" .. plat.id
local raw = __llmsproxy_secrets.get(legacyRef)
if not raw or raw == "" then
return { status = 200, json = { ok = false,
error = "没有可接管的旧凭据(" .. legacyRef .. ")" } }
end
local aid = args.account_id
if not aid or aid == "" then
return { status = 400, json = { error = "缺 account_id(要接管进哪个池账号)" } }
end
local acct = poolGet(plat.id, aid)
if not acct then
local now = os.time and os.time() or 0
acct = { id = aid, provider = plat.id, nickname = args.nickname or aid,
enabled = true, credentialRef = "channels:" .. plat.id .. ":" .. aid,
refreshable = false, createdAt = now, last_claim = 0, last_claim_status = 0,
last_claim_summary = "" }
poolAdd(plat.id, acct)
end
local _, serr = __llmsproxy_secrets.set(acct.credentialRef, raw)
if serr then return { status = 200, json = { ok = false, error = serr } } end
if type(raw) == "string" and raw:sub(1, 1) == "{" then
local dok, cred = pcall(json.decode, raw)
if dok and type(cred) == "table" then
acct.nickname = args.nickname or cred.nickname or cred.user_name or cred.user_id or acct.nickname
acct.refreshable = credentialIsRefreshable(cred)
local exp = credentialExpiresAtMs(cred)
if exp then acct.expiresAt = tostring(exp) end
end
end
poolAdd(plat.id, acct)
markDirty()
syncAccountSource(plat.id, aid)
logEvent("已接管旧凭据到 " .. plat.id .. "/" .. aid)
return { status = 200, json = { ok = true, account_id = aid,
credentialRef = acct.credentialRef, message = "旧凭据已接入账号池" } }
end
function plugin.account_refresh(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
local rok, rmsg = refreshCredential(plat, acct)
if not rok then
local tok = credentialOf(acct.credentialRef)
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
return { status = 200, json = { ok = false, refreshed = false, message = rmsg } }
end
syncAccountSource(plat.id, acct.id)
return { status = 200, json = { ok = true, refreshed = true, message = rmsg } }
end
function plugin.account_reorder(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local ordered = args and args.ordered_ids
if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end
for i, aid in ipairs(ordered) do
local acct = poolGet(plat.id, tostring(aid))
if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end
end
for _, a in ipairs(poolList(plat.id)) do
syncAccountSource(plat.id, a.id)
end
return { status = 200, json = { ok = true,
reordered = #poolList(plat.id), applied_to_sources = true } }
end
function plugin.model_refresh(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local targets = {}
if args and args.platform then
local plat = platformById(args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
targets = { plat }
else
for _, p in ipairs(allPlatforms()) do
if p.models_remote then targets[#targets+1] = p end
end
end
local results = {}
for _, plat in ipairs(targets) do
local ids, err = refreshRemoteModels(plat.id)
results[#results+1] = {
platform = plat.id,
ok = ids ~= nil,
count = ids and #ids or 0,
error = err,
}
if ids then
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
end
end
return { status = 200, json = { ok = true, results = results } }
end
function plugin.model_list(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local disabled = disabledModelsOf(plat.id)
local hidden = {}
for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end
for _, m in ipairs(plat.models_unavailable or {}) do hidden[m] = true end
local free = {}
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local FREE_PREFIX = plat.free_prefix
local out = {}
for i, m in ipairs(effectiveModels(plat.id)) do
if not hidden[m] then
local cap = capabilityOf(plat.id, m)
local is_free = false
if plat.free_source == "capability" then
if cap and cap.isFree ~= nil then
is_free = cap.isFree and true or false
else
is_free = free[m] and true or false
end
else
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and FREE_PREFIX and m:sub(1, #FREE_PREFIX) == FREE_PREFIX then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
local entry = { id = m, name = (cap and cap.name) or m,
disabled = disabled[m] and true or false,
isFree = is_free }
if cap then
entry.capabilities = {
modalities = cap.modalities,
contextWindow = cap.contextWindow,
maxOutputTokens = cap.maxOutputTokens,
reasoning = cap.reasoning,
efforts = cap.efforts,
toolCall = cap.toolCall,
}
end
local rm = plugin.state.modelMeta and plugin.state.modelMeta[plat.id]
and plugin.state.modelMeta[plat.id][m]
if rm then
entry.remote = {}
if rm.contextWindow then
entry.remote.contextWindow = rm.contextWindow
if not (cap and cap.contextWindow) then entry.contextWindow = rm.contextWindow end
end
if rm.maxOutputTokens then
entry.remote.maxOutputTokens = rm.maxOutputTokens
if not (cap and cap.maxOutputTokens) then entry.maxOutputTokens = rm.maxOutputTokens end
end
if rm.chan then entry.remote["function"] = rm.chan end
if rm.creditsRate then entry.remote.creditsRate = rm.creditsRate end
if rm.origCreditsRate then entry.remote.origCreditsRate = rm.origCreditsRate end
if rm.discountEndsAt then entry.remote.discountEndsAt = rm.discountEndsAt end
local rc = rm.reasoning
if type(rc) == "table" then
local st = rc.support_thinking
if st == nil then st = rc.supportThinking end
local opts = rc.options or rc.Options
if st ~= false and type(opts) == "table" and #opts > 0 then
entry.remote.efforts = opts
if not (cap and cap.efforts) then entry.efforts = opts end
entry.reasoning = true
end
end
end
out[#out+1] = entry
end
end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[plat.id]
return { status = 200, json = {
ok = true, models = out,
remote = remote ~= nil,
fetched_at = remote and remote.fetched_at or nil,
fallbackOnly = remote == nil,
} }
end
function plugin.model_set_disabled(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local mid = tostring(args and args.model_id or "")
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
local want = args.disabled and true or false
setModelDisabled(plat.id, mid, want)
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, disabled = want } }
end
function plugin.model_set_all_disabled(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local disabled = disabledModelsOf(plat.id)
if args.disabled then
for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end
else
for k in pairs(disabled) do disabled[k] = nil end
end
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true } }
end
function plugin.provider_status(req)
local out = {}
for _, p in ipairs(allPlatforms()) do
local accts = poolList(p.id)
local enabledN = 0
for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end
local disabled = disabledModelsOf(p.id)
local disabledN = 0
local eff = effectiveModels(p.id)
for _, m in ipairs(eff) do if disabled[m] then disabledN = disabledN + 1 end end
local closed = #eff > 0 and disabledN == #eff
local state_accounts = plugin.state.accounts[p.id]
local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0
out[#out+1] = {
id = p.id, name = p.name, flow = p.flow, closed = closed,
models = { total = #eff, disabled = disabledN },
accounts = { total = #accts + legacyN, enabled = enabledN + legacyN },
}
end
return { status = 200, json = { ok = true, statuses = out } }
end
function plugin.provider_set_enabled(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local enabled = args.enabled and true or false
local disabled = disabledModelsOf(plat.id)
if enabled then
for k in pairs(disabled) do disabled[k] = nil end
markDirty()
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
local legacy = plugin.state.accounts[plat.id]
local legacyTok = credentialOf("channels:" .. plat.id)
if legacy and legacyTok then
__llmsproxy_kernel.call("add_source", { source = {
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
api_key = legacyTok, max_concurrent = 1,
models = (function() local ms = {} local eff = effectiveModels(plat.id)
for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
} })
end
else
for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end
for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end
__llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id })
end
logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id)
return { status = 200, json = { ok = true } }
end
function plugin.credits_claim_all(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local results = {}
local claimed = 0
for _, a in ipairs(poolList(plat.id)) do
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef })
local ok = r.json and r.json.ok and true or false
if ok then claimed = claimed + 1 end
results[#results+1] = { accountId = a.id, outcome = ok
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
end
if #poolList(plat.id) == 0 then
local r = plugin.claim_daily({ platform = plat.id })
local ok = r.json and r.json.ok and true or false
if ok then claimed = claimed + 1 end
results[#results+1] = { accountId = "legacy", outcome = ok
and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) }
or { kind = "failed", message = (r.json and r.json.error) or "失败" } }
end
return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } }
end
function plugin.backup_export(req)
local payload = {}
payload.version = 1
payload.exportedAt = os.time and os.time() or 0
payload.accounts = {}
payload.credentials = {}
payload.modelDisabled = plugin.state.modelDisabled or {}
payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do
payload.accounts[#payload.accounts+1] = a
local tok = credentialOf(a.credentialRef)
if tok then payload.credentials[a.credentialRef] = tok end
end
local lt = credentialOf("channels:" .. p.id)
if lt then payload.credentials["channels:" .. p.id] = lt end
end
return { status = 200, json = { ok = true, payload = payload } }
end
function plugin.backup_import(req)
local args = bodyOf(req)
local payload = args and args.payload
if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end
local n = 0
if type(payload.credentials) == "table" then
for ref, tok in pairs(payload.credentials) do
if type(ref) == "string" and type(tok) == "string" and tok ~= "" then
__llmsproxy_secrets.set(ref, tok) n = n + 1
end
end
end
if type(payload.accounts) == "table" then
for _, a in ipairs(payload.accounts) do
if type(a) == "table" and a.id and a.provider then
poolAdd(tostring(a.provider), {
id = tostring(a.id), provider = tostring(a.provider),
nickname = a.nickname or a.id, enabled = a.enabled ~= false,
credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id),
refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0),
expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "",
})
end
end
end
if type(payload.modelDisabled) == "table" then
plugin.state.modelDisabled = payload.modelDisabled
markDirty()
end
if payload.autoCheckin ~= nil then
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.auto_checkin = payload.autoCheckin and true or false
end
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end
end
logEvent("备份已导入,恢复 " .. n .. " 条凭据")
return { status = 200, json = { ok = true, restored = n } }
end
function plugin.auto_checkin(req)
plugin.state.settings = plugin.state.settings or {}
local args = bodyOf(req)
if args.enabled ~= nil then
plugin.state.settings.auto_checkin = args.enabled and true or false
markDirty()
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
end
return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } }
end
fetchAccountJSON = function(spec, tok, credTable, plat)
local headers
local hfn = spec.headers_fn or (plat and plat.headers_fn)
if hfn then
local okf, h = pcall(hfn, tok, credTable or {})
if okf and type(h) == "table" then headers = h end
end
if not headers then
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
end
local resp, err = __llmsproxy_http({
url = spec.url, method = spec.method or "GET",
headers = headers, body = spec.body,
})
if err then return nil, err end
if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end
local e = rec.error
if type(e) == "string" and e ~= "" then return nil, e end
if type(e) == "table" then
local msg = e.message or e.msg
if type(msg) == "string" and msg ~= "" then return nil, msg end
return nil, "上游返回 error 对象"
end
local code = rec.code
if code ~= nil then
local n = tonumber(code)
if n and n ~= 0 and n ~= 200 then
return nil, tostring(rec.msg or rec.message or ("业务码 " .. tostring(code)))
end
end
return rec, nil
end
function plugin.credits_status(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local out = {}
for _, a in ipairs(poolList(plat.id)) do
local status = nil
local cs = plat.checkin_status
if cs then
local tok = credentialOf(a.credentialRef)
if tok then
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
if rec and plat.checkin_status.status_fn then
local okf, st = pcall(plat.checkin_status.status_fn, rec)
if okf then status = st end
elseif err then
status = { error = err }
end
end
end
out[#out+1] = { accountId = a.id, nickname = a.nickname, status = status }
end
return { status = 200, json = { ok = true, accounts = out } }
end
function plugin.credits_pool_reason(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local accounts = poolList(plat.id)
local enabled = 0
for _, a in ipairs(accounts) do
if a.enabled ~= false then enabled = enabled + 1 end
end
if enabled == 0 then
return { status = 200, json = {
ok = true, reason = "no_accounts",
detail = "该平台没有启用的账号" .. (#accounts > 0 and ("(有 " .. #accounts .. " 个被停用)") or ""),
counts = { total = #accounts, enabled = 0 } } }
end
if not plat.balance then
return { status = 200, json = {
ok = true, reason = "unknown",
detail = "该平台未声明余额端点,无法判定是否用尽(不要当成额度已用尽)",
counts = { total = #accounts, enabled = enabled } } }
end
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
local allowPermanent = not lockPermanent
local r = rankAccounts(plat.id)
local errors = {}
for _, a in ipairs(accounts) do
if a.enabled ~= false then
local raw = accountBalanceRaw(plat.id, a.id)
if raw == nil then errors[#errors + 1] = a.id end
end
end
if #r.usable > 0 then
return { status = 200, json = {
ok = true, reason = "usable", usable = #r.usable,
detail = "有 " .. #r.usable .. " 个可用账号",
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = lockPermanent and true or false } }
end
if #errors > 0 then
return { status = 200, json = {
ok = true, reason = "unknown",
detail = "有 " .. #errors .. " 个账号的余额**查询失败**,无法判定它们是否真的没额度"
.. "(不要当成额度已用尽:可能是网络抖动;也不要去解锁永久积分)",
error_accounts = errors,
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = lockPermanent and true or false } }
end
if lockPermanent then
return { status = 200, json = {
ok = true, reason = "locked",
detail = "永久积分已锁定," .. tostring(r.permanent) .. " 个只剩永久积分的账号不可用"
.. "(如需使用可解锁;这不是「额度用尽」)",
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = true } }
end
return { status = 200, json = {
ok = true, reason = "exhausted",
detail = "所有账号都查到了余额,且可用积分确实为 0(等额度重置)",
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = false } }
end
function plugin.credits_balances(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local cap = CHANNEL_CREDITS[plat.id] or {}
local out = {}
for _, a in ipairs(poolList(plat.id)) do
local entry = { accountId = a.id, balance = nil, error = nil }
local b = plat.balance
if not b then
entry.error = "平台未声明余额端点"
else
local tok = credentialOf(a.credentialRef)
if not tok then
entry.error = "无凭据"
else
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
if err then
entry.error = err
elseif b.total_fn then
local okf, total = pcall(b.total_fn, rec)
if okf and type(total) == "number" then
entry.balance = { total = total, unit = cap.unit or "credits" }
else
entry.error = "余额解析失败"
end
end
end
end
out[#out+1] = entry
end
return { status = 200, json = { ok = true, accounts = out } }
end
function plugin.credits_permanent_lock(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
plugin.state.settings = plugin.state.settings or {}
if args.locked ~= nil then
if type(args.locked) ~= "boolean" then
return { status = 400, json = { error = "locked 必须是布尔值" } }
end
plugin.state.settings["lock_" .. plat.id] = args.locked
markDirty()
logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id)
end
return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true,
windowDays = 3 } }
end
function plugin.account_reset(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if args.account_id == "__all__" then
for _, a in ipairs(poolList(plat.id)) do a.rateLimited = nil poolAdd(plat.id, a) end
return { status = 200, json = { ok = true, reset = #poolList(plat.id) } }
end
local acct = poolGet(plat.id, args and args.account_id)
if not acct then return { status = 400, json = { error = "账号不存在" } } end
acct.rateLimited = nil
poolAdd(plat.id, acct)
return { status = 200, json = { ok = true } }
end
function plugin.account_retest(req)
local args = bodyOf(req)
if args.account_id == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
if args.account_id == "__all__" then
local results = {}
for _, a in ipairs(poolList(plat.id)) do
local r = plugin.account_test({ platform = plat.id, account_id = a.id })
results[#results+1] = { accountId = a.id, ok = r.json and r.json.ok and true or false,
error = r.json and r.json.error or nil }
end
return { status = 200, json = { ok = true, results = results } }
end
return plugin.account_test({ platform = plat.id, account_id = args.account_id })
end
function plugin.model_set_disabled_many(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local ids = args and args.model_ids
if type(ids) ~= "table" then return { status = 400, json = { error = "model_ids 必须是数组" } } end
local disabled = disabledModelsOf(plat.id)
for _, mid in ipairs(ids) do
if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end
end
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true } }
end
function plugin.model_clear_dead(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local mid = args and args.model_id
local n = clearDeadModels(plat.id, mid)
markDirty()
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
return { status = 200, json = { ok = true, removed = n,
message = n > 0 and ("已恢复 " .. n .. " 个模型") or "无失效模型记录" } }
end
function plugin.provider_get_order(req)
local order = plugin.state.providerOrder
if type(order) ~= "table" then order = {} end
return { status = 200, json = { ok = true, order = order } }
end
function plugin.provider_set_order(req)
local args = bodyOf(req)
local order = args and args.order
if type(order) ~= "table" then return { status = 400, json = { error = "order 必须是字符串数组" } } end
local cleaned = {}
for _, id in ipairs(order) do
if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end
end
plugin.state.providerOrder = cleaned
markDirty()
return { status = 200, json = { ok = true } }
end
function plugin.gateway_get_enabled(req)
local any = false
for _, p in ipairs(allPlatforms()) do
local disabled = disabledModelsOf(p.id)
local total = #effectiveModels(p.id)
local allOff = total > 0
for _, m in ipairs(effectiveModels(p.id)) do if not disabled[m] then allOff = false break end end
if not allOff then any = true break end
end
return { status = 200, json = { ok = true, enabled = any,
apiKey = nil, address = nil, models = { source = "channels" } } }
end
function plugin.gateway_set_enabled(req)
local args = bodyOf(req)
if type(args.enabled) ~= "boolean" then
return { status = 400, json = { error = "非空布尔 enabled 必填" } }
end
for _, p in ipairs(allPlatforms()) do
plugin.provider_set_enabled({ platform = p.id, enabled = args.enabled })
end
logEvent("网关整体" .. (args.enabled and "开启" or "关闭"))
return { status = 200, json = { ok = true } }
end
function plugin.backup_status(req)
local total, noExpiry = 0, 0
for _, p in ipairs(allPlatforms()) do
for _, a in ipairs(poolList(p.id)) do
total = total + 1
if a.expiresAt == nil then noExpiry = noExpiry + 1 end
end
end
return { status = 200, json = { ok = true, accounts = total, withoutExpiry = noExpiry } }
end
function plugin.usage_token_ledger(req)
local args = bodyOf(req)
local limit = tonumber(args and args.limit) or 50
local key = (req and req.key) or ""
local url = "http://127.0.0.1:8081/api/stats?period=all&limit=" .. tostring(limit)
if key ~= "" then
url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6)))
end
local headers = {}
if key ~= "" then
headers["Authorization"] = "Bearer " .. key
end
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers })
if err or not resp or resp.status ~= 200 then
return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and resp.status or "no response")) } }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return { status = 502, json = { ok = false, error = "usage snapshot 无法解析" } }
end
local total = type(rec.total) == "table" and rec.total or {}
local prompt = tonumber(total.prompt_tokens) or 0
local completion = tonumber(total.completion_tokens) or 0
local requests = tonumber(total.reqs) or 0
return { status = 200, json = { ok = true, snapshot = { available = true,
tokens = { prompt = prompt, completion = completion, total = prompt + completion },
requests = requests, raw = rec } } }
end
function plugin.usage_badge(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "provider 不能为空" } } end
local j = plugin.credits_balances({ platform = plat.id })
local accounts = asArrLua(j.json and j.json.accounts)
local total, known = 0, false
for _, a in ipairs(accounts) do
if type(a.balance) == "table" and type(a.balance.total) == "number" then
total = total + a.balance.total
known = true
end
end
return { status = 200, json = { ok = true, badge = known
and { kind = "credits", total = total, unit = (CHANNEL_CREDITS[plat.id] or {}).unit or "credits" }
or { kind = "unavailable" } } }
end
function plugin.usage_badge_preference(req)
plugin.state.settings = plugin.state.settings or {}
local args = bodyOf(req)
if args.preference ~= nil then
local valid = { auto = true, subscription = true, credits = true }
if not valid[args.preference] then
return { status = 400, json = { error = "preference 必须是 auto/subscription/credits 之一" } }
end
plugin.state.settings.badge_preference = args.preference
end
return { status = 200, json = { ok = true, preference = plugin.state.settings.badge_preference or "auto" } }
end
function plugin.onboarding_status(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local ob = plat.onboarding
if not ob then
return { status = 200, json = { ok = true, supported = false,
message = plat.name .. " 未声明新人任务端点(参考实现同样如实返回 null)" } }
end
local claimed = plugin.state.settings and plugin.state.settings["onboard_" .. plat.id] == true
return { status = 200, json = { ok = true, supported = true,
tasks = { desktop_login_reward = claimed }, earned = claimed and ob.reward_points or 0,
total = ob.reward_points } }
end
function plugin.onboarding_claim(req)
local args = bodyOf(req)
local plat = platformById(args and args.platform)
if not plat or not plat.onboarding then
return { status = 400, json = { error = "该平台无新人任务" } }
end
local tok = credentialOf("channels:" .. plat.id)
if not tok then return { status = 400, json = { error = "未登录" } } end
local resp, err = __llmsproxy_http({
url = plat.base_url .. "/login/points/grant", method = "POST",
headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
body = "{}",
})
if err then return { status = 400, json = { error = err } } end
if resp.status ~= 200 then return { status = 400, json = { error = "HTTP " .. tostring(resp.status) } } end
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings["onboard_" .. plat.id] = true
logEvent("新人奖励已领 " .. plat.id)
return { status = 200, json = { ok = true, points = plat.onboarding.reward_points } }
end
function asArrLua(t)
if type(t) ~= "table" then return {} end
if #t > 0 then return t end
return {}
end
function plugin.set_endpoints(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local allowed = { base_url = true, login_url = true }
local changed = {}
for k in pairs(allowed) do
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end
end
if type(args.callback) == "table" and plat.callback then
for _, k in ipairs({ "mode", "token_url" }) do
if args.callback[k] then
plat.callback[k] = tostring(args.callback[k])
changed[#changed+1] = "callback." .. k
end
end
end
if type(args.qoder_sash_base) == "table" and args.qoder_sash_base.url then
QODER_SASH_BASE = tostring(args.qoder_sash_base.url)
changed[#changed+1] = "qoder_sash_base.url"
end
if type(args.lobster_activity_base) == "table" and args.lobster_activity_base.url then
LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url)
changed[#changed+1] = "lobster_activity_base.url"
end
if type(args.codearts_sts) == "table" and args.codearts_sts.url then
CODEARTS.sts_token = tostring(args.codearts_sts.url)
changed[#changed+1] = "codearts_sts.url"
end
if type(args.client_version) == "table" and plat.client_version then
for _, k in ipairs({ "url", "path", "fallback" }) do
if args.client_version[k] then
plat.client_version[k] = tostring(args.client_version[k])
changed[#changed+1] = "client_version." .. k
end
end
plugin.state.clientVersion = nil
end
if type(args.models_remote) == "table" and plat.models_remote then
for i, ep in ipairs(plat.models_remote.endpoints or {}) do
local v = args.models_remote["endpoint" .. (i - 1)]
if v then
ep.url = tostring(v)
changed[#changed+1] = "models_remote.endpoint" .. (i - 1)
end
end
end
if type(args.qr) == "table" and plat.qr then
for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do
if args.qr[k] then
plat.qr[k] = tostring(args.qr[k])
changed[#changed+1] = "qr." .. k
end
end
end
if type(args.wechat) == "table" and plat.wechat then
for _, k in ipairs({ "auth_url", "qr_url", "poll_url", "account_base",
"bind_path", "user_agent", "access_key_id", "access_key_secret", "app_id" }) do
if args.wechat[k] then
plat.wechat[k] = tostring(args.wechat[k])
changed[#changed+1] = "wechat." .. k
end
end
end
if type(args.initpoll) == "table" and plat.initpoll then
for _, k in ipairs({ "init_url", "poll_url", "provider", "origin" }) do
if args.initpoll[k] then
plat.initpoll[k] = tostring(args.initpoll[k])
changed[#changed+1] = "initpoll." .. k
end
end
end
if type(args.device) == "table" and plat.device then
for _, k in ipairs({ "code_url", "token_url" }) do
if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end
end
end
if type(args.register) == "table" and plat.register then
for _, k in ipairs({ "url" }) do
if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end
end
end
if type(args.state) == "table" and plat.state then
for _, k in ipairs({ "state_url", "token_url", "account_url" }) do
if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. k end
end
end
if type(args.checkin) == "table" and plat.checkin then
for _, k in ipairs({ "url" }) do
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
end
end
if type(args.checkin_status) == "table" and plat.checkin_status then
for _, k in ipairs({ "url", "method", "body" }) do
if args.checkin_status[k] then
plat.checkin_status[k] = tostring(args.checkin_status[k])
changed[#changed+1] = "checkin_status." .. k
end
end
end
if type(args.balance) == "table" and plat.balance then
for _, k in ipairs({ "url", "method", "body" }) do
if args.balance[k] then
plat.balance[k] = tostring(args.balance[k])
changed[#changed+1] = "balance." .. k
end
end
end
if type(args.callback) == "table" and plat.callback then
for _, k in ipairs({ "exchange_url" }) do
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end
end
end
if #changed == 0 then return { status = 400, json = { error = "无可识别的端点字段" } } end
logEvent("端点已改写 " .. plat.id .. ": " .. table.concat(changed, ","))
return { status = 200, json = { ok = true, changed = changed } }
end
function plugin.list_platforms(req)
local out = {}
for _, p in ipairs(allPlatforms()) do
local acct = accountOf(p.id) or {}
local has = credentialOf("channels:" .. p.id)
out[#out + 1] = {
id = p.id, name = p.name, flow = p.flow,
logged_in = has ~= nil,
account = acct.account,
enabled = acct.enabled,
last_claim = acct.last_claim,
}
end
return { status = 200, json = { platforms = out } }
end
function plugin.deps_status(req)
local d = browserDeps()
local list = {}
for _, m in ipairs(d.missing) do
list[#list + 1] = { bin = m.bin, pkg = m.pkg, why = m.why }
end
return { status = 200, json = {
ok = d.ok,
missing = list,
present = d.present,
chromium = d.chromium,
novnc = d.novnc,
install_hint = d.ok and "" or
"sudo bash /usr/share/llmsproxy/install-browser-stack.sh"
.. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh"
.. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)",
} }
end
function plugin.collect_credentials(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local b = browserStack()
if not b.session then return { status = 400, json = { error = "没有运行中的浏览器会话" } } end
local cdp = b.session.cdp
if not cdp then return { status = 400, json = { error = "浏览器会话未启用 CDP" } } end
local expr = (args and args.expr) or plat.collect_expr
local value, err
if expr then
value, err = cdpEval(cdp, expr)
if err then return { status = 400, json = { error = "取值失败: " .. err } } end
end
local generic, gerr = cdpEval(cdp, [[
(function(){
var out = {localStorage:{}, sessionStorage:{}, cookie:document.cookie, url:location.href};
try { for (var i=0;i<localStorage.length;i++){var k=localStorage.key(i);out.localStorage[k]=localStorage.getItem(k);} } catch(e){}
try { for (var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);out.sessionStorage[k]=sessionStorage.getItem(k);} } catch(e){}
return JSON.stringify(out);
})()
]])
local rec = {}
if value ~= nil then
if type(value) == "table" then
for k, v in pairs(value) do rec[k] = v end
else
rec.collected = value
end
end
if generic then
local ok, parsed = pcall(json.decode, generic)
if ok and type(parsed) == "table" then
rec.browser = parsed
local ls = parsed.localStorage or {}
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token", "Authorization" }) do
if type(ls[k]) == "string" and ls[k] ~= "" and rec.token == nil then rec.token = ls[k] end
end
local ss = parsed.sessionStorage or {}
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token" }) do
if type(ss[k]) == "string" and ss[k] ~= "" and rec.token == nil then rec.token = ss[k] end
end
if rec.token == nil and type(parsed.cookie) == "string" then
rec.token = parsed.cookie:match("(access_token=[^;]+)")
or parsed.cookie:match("(token=[^;]+)")
end
end
elseif gerr then
rec.generic_error = gerr
end
if next(rec) == nil then
return { status = 200, json = { ok = false, error = "没有取到任何凭据(登录是否真的完成了?)" } }
end
return { status = 200, json = { ok = true, collected = rec } }
end
function plugin.stop_browser(req)
local had = browserStack().session ~= nil
reapBrowser()
browserStack().owner = nil
if had then logEvent("服务端浏览器已回收") end
return { status = 200, json = { ok = true, stopped = had } }
end
function plugin.__add_source_for_test(args)
args = args or {}
local ms = {}
for _, m in ipairs(args.models or {}) do
if type(m) == "table" then ms[#ms + 1] = m else ms[#ms + 1] = { id = m } end
end
local r, err = __llmsproxy_kernel.call("add_source", {
source = {
name = args.name, base_url = args.base_url, adapter = args.adapter,
models = ms,
},
})
if r == nil or r.ok ~= true then
return { json = { error = tostring(err or "add_source 失败") } }
end
return { json = { ok = true } }
end
function plugin.__forget_account_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
poolRemove(plat.id, args.account_id)
return { json = { ok = true } }
end
function plugin.reap_orphan_sources(req)
local args = bodyOf(req) or {}
if args.dry_run == nil then args = req or {} end
local dry = args.dry_run
if dry == nil then dry = true end
if dry ~= false then dry = true end
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = ls and ls.json or ls
if type(arr) ~= "table" then arr = {} end
local found, removed, failed = {}, 0, {}
for _, x in ipairs(arr) do
local nm = type(x) == "table" and x.name or nil
if type(nm) == "string" then
local plat, acct, why = nil, nil, nil
for _, p in ipairs(PLATFORMS) do
local pref = "channels-" .. p.id .. "-"
if nm:sub(1, #pref) == pref then
plat, acct = p.id, nm:sub(#pref + 1)
break
end
end
if plat and acct ~= "" and poolGet(plat, acct) == nil then
why = "账号 " .. acct .. " 已不在池中"
end
if plat == nil then
for _, p in ipairs(PLATFORMS) do
if nm == "channels-" .. p.id then
local pok, acc, areason = pcall(pickAccountForSource, p)
if not pok then acc = nil areason = "选号失败: " .. tostring(acc) end
if not acc then
plat, why = p.id, "该通道无可用账号:" .. tostring(areason)
end
break
end
end
end
if plat and why then
found[#found + 1] = nm
if not dry then
local r, err = __llmsproxy_kernel.call("remove_source", { name = nm })
if r == nil or r.ok ~= true then
failed[#failed + 1] = nm .. ": " .. tostring(err or "(无错误信息)")
logEvent("孤儿源清理失败 " .. nm .. ": " .. tostring(err or "(无错误信息)"),
"error", plat)
else
removed = removed + 1
logEvent("孤儿源已清理 " .. nm .. "(" .. tostring(why) .. ")", "warn", plat)
end
end
end
end
end
return { status = 200, json = {
ok = #failed == 0, dry_run = dry,
orphan_count = #found, orphans = found,
removed = removed, failed = failed,
} }
end
function plugin.reap_loopbacks(req)
local n = 0
for sid in pairs(plugin.state.loopbacks or {}) do
reapLoopback(sid)
n = n + 1
end
return { status = 200, json = { ok = true, reaped = n } }
end
function plugin.browser_status(req)
local b = browserStack()
local s = b.session
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
end
function plugin.browser_proxy(req)
sweepSessions()
local b = browserStack()
local s = b.session
local want = req and req.params and req.params.session or nil
if s and s.expires_at and s.expires_at <= os.time() then
reapBrowser()
s = nil
end
if not s or not s.ws or not s.sess or s.sess ~= want then
return { status = 404, json = { error = "没有这个浏览器会话" } }
end
return { proxy = ("http://127.0.0.1:%d"):format(s.ws) }
end
function plugin.inject_platforms(req)
local args = bodyOf(req)
if args.platforms == nil then args = req end
local list = args and (args.platforms or args.items)
if type(list) ~= "table" then
return { status = 400, json = { error = "platforms 必须是数组" } }
end
if #list > plugin.K.INJECT_PLATFORM_LIMIT then
return { status = 400, json = { error = "最多注入 " .. plugin.K.INJECT_PLATFORM_LIMIT .. " 个平台" } }
end
local builtin = {}
for _, p in ipairs(PLATFORMS) do builtin[p.id] = true end
local clean, seen = {}, {}
for _, p in ipairs(list) do
if type(p) ~= "table" or type(p.id) ~= "string" or p.id == "" then
return { status = 400, json = { error = "每个平台必须有 id" } }
end
if builtin[p.id] then
return { status = 400, json = { error = "不得覆盖内建平台: " .. p.id } }
end
if seen[p.id] then
return { status = 400, json = { error = "重复的 id: " .. p.id } }
end
seen[p.id] = true
clean[#clean + 1] = p
end
plugin.state.injected_platforms = clean
markDirty()
if #clean == 0 then
logEvent("已清空注入平台")
else
local ids = {}
for _, p in ipairs(clean) do ids[#ids + 1] = p.id end
logEvent("已注入平台 " .. #clean .. " 个: " .. table.concat(ids, ","))
end
return { status = 200, json = { ok = true, injected = #clean } }
end
function plugin.status_page(req)
local r = __llmsproxy_kernel.call("status", {})
return { status = 200, json = {
accounts = plugin.state.accounts,
log = plugin.state.log,
kernel_ready = r and r.ready or false,
} }
end
function plugin.oauth_callback(req)
local nonce = req.query and req.query.state
if not nonce or nonce == "" then
return { status = 400, body = "missing state" }
end
sweepSessions()
local s = nil
for _, sess in pairs(sessions()) do
if sess.nonce == nonce then s = sess break end
end
if not s then
return { status = 400, body = "unknown or expired login session" }
end
if s.flow ~= "callback" then
return { status = 400, body = "session is not a callback flow" }
end
if s.state ~= "pending" then
return { status = 409, body = "login session already consumed" }
end
local plat = platformById(s.platform)
local cb = plat and plat.callback or {}
local mode = cb.mode or "code"
local credential, account, cerr
if mode == "trae" then
credential, account, cerr = traeCallback(plat, req.query, s)
elseif mode == "code" and s.platform == "lobsterai" then
credential, account, cerr = lobsteraiExchange(plat, req.query.authCode or req.query.code, s)
elseif mode == "code" and cb.token_url then
local code = req.query.code
if not code or code == "" then
cerr = "missing code"
else
local body = (cb.token_body or "")
:gsub("{code}", code)
:gsub("{verifier}", s.device_verifier or "")
:gsub("{now}", tostring(os.time and os.time() * 1000 or 0))
:gsub("{uuid}", s.nonce or "")
:gsub("{port}", tostring(gatewayPort()))
local ctype = "application/x-www-form-urlencoded"
if body:sub(1, 1) == "{" then ctype = "application/json" end
local hdrs = { ["Content-Type"] = ctype, ["Accept"] = "application/json" }
if type(plat.callback_headers) == "table" then
for k, v in pairs(plat.callback_headers) do hdrs[k] = v end
end
local resp, err = __llmsproxy_http({
url = cb.token_url,
method = "POST",
headers = hdrs,
body = body,
})
if err then
cerr = "token 交换失败: " .. err
elseif resp.status ~= 200 then
cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.access_token then
cerr = "token 交换响应缺少 access_token"
else
credential = rec.access_token
account = "oauth"
end
end
end
else
credential = req.query.code
account = "oauth"
if not credential or credential == "" then cerr = "missing code" end
end
if cerr then
s.state = "failed"
s.message = cerr
return { status = 400, body = cerr }
end
local res = plugin.complete_login({ platform = s.platform, credential = credential,
account = account, account_id = s.account_id })
if res.json and res.json.error then
return { status = 400, body = res.json.error }
end
s.state = "completed"
s.account = account
return { status = 200, body = "ok" }
end
local function jsonField(raw, key)
if not raw or raw == "" then return "" end
local ok, v = pcall(json.decode, raw)
if not ok or type(v) ~= "table" then return "" end
return v[key] or ""
end
traeCallback = function(plat, q, s)
q = q or {}
local userJwt = q.userJwt or ""
local refreshToken = q.refreshToken
if not refreshToken or refreshToken == "" then
refreshToken = jsonField(userJwt, "RefreshToken")
end
local jwtToken = jsonField(userJwt, "Token")
local userInfo = q.userInfo or ""
local uid = jsonField(userInfo, "UserID")
local enterpriseId = jsonField(userInfo, "TenantID")
local account = traeFixNickname(jsonField(userInfo, "ScreenName"), uid)
if account == "" then account = "trae" end
local authCode = ""
do
local info = q.authCodeInfo or ""
for _, cand in ipairs({ q.code or "", q.authCode or "",
jsonField(info, "code"), jsonField(info, "authCode"),
info }) do
if type(cand) == "string" and cand ~= "" then authCode = cand; break end
end
authCode = authCode:gsub("^%s+", ""):gsub("%s+$", "")
end
if (not refreshToken or refreshToken == "") and (not jwtToken or jwtToken == "") then
if authCode and authCode ~= "" then
return nil, nil, "上游返回了 PKCE 授权码(code/authCodeInfo),本实现暂不支持该流程"
end
return nil, nil, "回调未携带 refreshToken / userJwt.Token / code"
end
local function packTrae(access)
local cred = { access_token = access, uid = uid, enterprise_id = enterpriseId,
nickname = account }
if s then
cred.device_id = s.device_id
cred.machine_id = s.machine_id
cred.login_trace_id = s.login_trace_id
end
return json.encode(cred)
end
if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then
local body = (plat.callback.exchange_body
:gsub("{client_id}", plat.callback.client_id or "")
:gsub("{refresh_token}", refreshToken))
local resp, err = __llmsproxy_http({
url = plat.callback.exchange_url,
method = "POST",
headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" },
body = body,
})
if err then return nil, nil, "TRAE ExchangeToken 失败: " .. err end
if resp.status ~= 200 then
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, nil, "TRAE ExchangeToken 响应无法解析"
end
local r2 = rec.Result or rec.result
if type(r2) ~= "table" then r2 = rec end
local access = r2.Token or r2.token or r2.AccessToken or r2.access_token
or rec.Token or rec.token
if not access or access == "" then
return nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)"
end
local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken
local expireAt = r2.TokenExpireAt or rec.TokenExpireAt
local packed = packTrae(access)
if (newRefresh and newRefresh ~= "") or expireAt then
local okc, cred = pcall(json.decode, packed)
if okc and type(cred) == "table" then
if newRefresh and newRefresh ~= "" then cred.refresh_token = newRefresh end
if expireAt then cred.expires_at = math.floor((tonumber(expireAt) or 0) / 1000) end
packed = json.encode(cred)
end
end
return packed, account, nil
end
if jwtToken and jwtToken ~= "" then return packTrae(jwtToken), account, nil end
return nil, nil, "回调未携带可用的 token"
end
local function traeDeriveStream(seed, salt, nbytes)
local prefix = tostring(salt) .. ":" .. tostring(seed)
local out, counter = "", 0
while #out < nbytes * 2 do
local be = string.char(
math.floor(counter / 16777216) % 256, math.floor(counter / 65536) % 256,
math.floor(counter / 256) % 256, counter % 256)
out = out .. sha256_hex(prefix .. be)
counter = counter + 1
end
return out:sub(1, nbytes * 2)
end
traeDeriveDeviceId15 = function(userId)
local hex = traeDeriveStream(userId, "devid", 15)
local out = {}
for i = 1, 15 do
local b = tonumber(hex:sub((i - 1) * 2 + 1, i * 2), 16)
out[#out + 1] = tostring(b % 10)
end
return table.concat(out)
end
traeDeriveMarketUserId = function(userId)
local h = traeDeriveStream(userId, "market", 16)
local bs = {}
for i = 1, 16 do bs[i] = tonumber(h:sub((i - 1) * 2 + 1, i * 2), 16) end
bs[7] = (bs[7] % 16) + 64
bs[9] = (bs[9] % 64) + 128
local hx = {}
for i = 1, 16 do hx[i] = ("%02x"):format(bs[i]) end
local s = table.concat(hx)
return s:sub(1, 8) .. "-" .. s:sub(9, 12) .. "-" .. s:sub(13, 16)
.. "-" .. s:sub(17, 20) .. "-" .. s:sub(21, 32)
end
traeDeriveSessionId = function(userId)
return traeDeriveStream(userId, "sess", 32)
end
local function utf8Encode(cp)
if cp < 0x80 then return string.char(cp) end
if cp < 0x800 then
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
end
if cp < 0x10000 then
return string.char(0xE0 + math.floor(cp / 4096),
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
end
return string.char(0xF0 + math.floor(cp / 262144),
0x80 + math.floor(cp / 4096) % 64, 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
end
local function latin1ToUtf8(s)
local out, i, n = {}, 1, #s
while i <= n do
local b = string.byte(s, i)
if not b then break end
local cp, len
if b < 0x80 then cp, len = b, 1
elseif b >= 0xC2 and b <= 0xDF then cp, len = b - 0xC0, 2
elseif b >= 0xE0 and b <= 0xEF then cp, len = b - 0xE0, 3
elseif b >= 0xF0 and b <= 0xF4 then cp, len = b - 0xF0, 4
else return nil, false end
if i + len - 1 > n then return nil, false end
for k = 1, len - 1 do
local cb = string.byte(s, i + k)
if not cb or cb < 0x80 or cb > 0xBF then return nil, false end
cp = cp * 64 + (cb - 0x80)
end
if (len == 2 and cp < 0x80) or (len == 3 and cp < 0x800)
or (len == 4 and cp < 0x10000) or cp > 0x10FFFF
or (cp >= 0xD800 and cp <= 0xDFFF) then
return nil, false
end
out[#out + 1] = utf8Encode(cp)
i = i + len
end
return table.concat(out), true
end
local function hasCJK(s)
if type(s) ~= "string" then return false end
local i = 1
while i <= #s do
local b = string.byte(s, i)
if b and b >= 0xE4 and b <= 0xE9 then
local b2, b3 = string.byte(s, i + 1), string.byte(s, i + 2)
if b2 and b3 and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then
local cp = (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
if cp >= 0x4E00 and cp <= 0x9FFF then return true end
end
end
i = i + 1
end
return false
end
traeFixNickname = function(raw, uid)
if raw == nil or raw == "" then return raw or "" end
local fixed, ok = latin1ToUtf8(raw)
if ok and fixed ~= "" and hasCJK(fixed) then return fixed end
if not hasCJK(raw) then
return "\231\148\168\230\136\183" .. tostring(uid):sub(-4)
end
return raw
end
traeCheckinHeaders = function(plat, cred, uid)
uid = uid or cred.uid or ""
if uid == "" then return nil, "缺少 user_id,无法构造设备身份" end
local h = {
["Authorization"] = "Cloud-IDE-JWT " .. tostring(cred.access_token or ""),
["Content-Type"] = "application/json",
["Accept"] = "*/*",
["Accept-Language"] = "zh-CN",
["User-Agent"] = "VSCode 1.107.1 (TRAE SOLO CN)",
["X-Market-Client-Id"] = "VSCode 1.107.1",
["X-Market-User-Id"] = traeDeriveMarketUserId(uid),
["X-User-Region"] = "CN",
["X-Device-Id"] = traeDeriveDeviceId15(uid),
["X-Lgw-Req-Sdk-Type"] = "3",
["Package-Type"] = "stable_cn",
["X-Lscbd-Aid"] = "787976",
["X-Lscbd-Platform"] = "windows",
["App-Version"] = plat.ide_version or "0.1.52",
["Vscode-Sessionid"] = traeDeriveSessionId(uid),
["Sec-Fetch-Dest"] = "empty",
}
h["X-Tt-Trace-Id"] = "00-" .. newToken():sub(1, 16) .. "-01"
local r = newToken()
h["X-Request-Id"] = r:sub(1, 8) .. "-" .. r:sub(9, 12) .. "-4" .. r:sub(14, 16)
.. "-8" .. r:sub(18, 20) .. "-" .. r:sub(21, 32)
return h, nil
end
traeIdeHeaders = function(plat, cred, accept)
local tok = tostring((cred and cred.access_token) or "")
if tok == "" then return nil, "缺少 access_token" end
local h = {
["Content-Type"] = "application/json",
["Accept"] = accept or "application/json",
["User-Agent"] = (plat and plat.user_agent) or "Trae/0.1.52",
["Authorization"] = "Cloud-IDE-JWT " .. tok,
["X-Cloudide-Token"] = tok,
["X-Ide-Token"] = tok,
["X-App-Id"] = (plat and plat.app_id) or "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
["X-App-Version"] = "default",
["X-Ide-Version"] = (plat and plat.ide_version) or "0.1.52",
["X-Ide-Version-Code"] = (plat and plat.ide_version_code) or "20260811",
["X-App-Version-Code"] = (plat and plat.ide_version_code) or "20260811",
["X-Ide-Version-Type"] = "stable",
["X-Device-Type"] = (plat and plat.device_type) or "macos",
["X-OS-Version"] = (plat and plat.os_version) or "macOS 15.7.4",
["X-Device-Brand"] = (plat and plat.device_brand) or "Apple",
["Request-Traffic-Type"] = "prod",
}
local uid = tostring((cred and cred.uid) or "")
if uid ~= "" then h["X-Uid"] = uid end
local mid = tostring((cred and cred.machine_id) or "")
local did = tostring((cred and cred.device_id) or "")
if mid ~= "" then h["X-Machine-Id"] = mid end
if did ~= "" then h["X-Device-Id"] = did end
return h, nil
end
do
for _, p in ipairs(PLATFORMS) do
if p.checkin and p.checkin.auth == "cloud-ide-jwt" then
local mkHeaders = function(plat)
return function(accessToken, cred)
local c = (type(cred) == "table") and cred or {}
if not c.access_token then c.access_token = accessToken end
return traeCheckinHeaders(plat, c, nil)
end
end
for _, specName in ipairs({ "checkin_status", "balance" }) do
local spec = p[specName]
if type(spec) == "table" and not spec.headers_fn then
spec.headers_fn = mkHeaders(p)
end
end
p.claim_fn = function(plat, tok, args)
local cred = (args and args.credential) or { access_token = tok }
local uid = (args and args.uid) or cred.uid
or (type(cred.user_id) == "string" and cred.user_id) or ""
local h, herr = traeCheckinHeaders(plat, cred, uid)
if herr then return nil, herr end
return { url = plat.checkin.url, method = plat.checkin.method or "POST",
headers = h, body = plat.checkin.body or "{}" }
end
end
end
end
plugin.K.CODEARTS_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "codearts"
adapter.version = "1.0.0"
-- ⚠️ 源注册时会用平台声明的 endpoint 覆盖;这里保持一致以免两处漂移。
adapter.endpoint = "/api/v2/chat/completions"
-- 华为云 SDK-HMAC-SHA256 签名。逐条对齐参考 sign.ts:signRequestHuawei:
-- canonical = METHOD\nURI\nQUERY\nCANONICAL_HEADERS\n\nSIGNED_HEADERS\nPAYLOAD_HASH
-- string_to_sign = "SDK-HMAC-SHA256\n" .. x-sdk-date .. "\n" .. sha256(canonical)
-- signature = HMAC-SHA256(SK, string_to_sign)
-- 三个易错点(参考注释都点名了):
-- 1. URI 末尾**必须补 /**(否则签名不匹配)
-- 2. Authorization 用 `Access=` 不是 `Credential=`
-- 3. Agent-Type / X-Language 是**签名后追加**的,不进 canonical
-- signed_parts:返回 (headers, canonical, string_to_sign)。
local function signed_parts(method, url, cred, body)
local ak = cred.access_key_id or cred.access_key or ""
local sk = cred.secret_access_key or cred.secret_key or ""
local st = cred.security_token or ""
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
local host = url:match("^https?://([^/]+)")
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
local query = url:match("%?([^#]*)$") or ""
local payload = body or ""
local payload_hash = sha256_hex(payload)
local date = os.date("!%Y%m%dT%H%M%SZ")
local h = {
["host"] = host,
["x-sdk-date"] = date,
["x-sdk-content-sha256"] = payload_hash,
}
if st ~= "" then h["x-security-token"] = st end
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
local keys = {}
for k in pairs(h) do keys[#keys + 1] = k end
table.sort(keys)
local lines, names = {}, {}
for _, k in ipairs(keys) do
lines[#lines + 1] = k .. ":" .. h[k]
names[#names + 1] = k
end
local signed = table.concat(names, ";")
local canonical = table.concat({
method:upper(), uri, query, table.concat(lines, "\n"), "",
signed, payload_hash,
}, "\n")
local sts = "SDK-HMAC-SHA256\n" .. date .. "\n" .. sha256_hex(canonical)
local sig = hmac_sha256_hex(sk, sts)
local out = {}
for _, k in ipairs(keys) do
if k ~= "host" then out[k] = h[k] end
end
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
.. ",SignedHeaders=" .. signed .. ",Signature=" .. sig
-- 签名后追加:不参与 canonical(参考注释点名)
out["Agent-Type"] = "PromptCenter"
out["X-Language"] = "zh-cn"
return out, canonical, sts
end
-- 兼容旧调用名(build_headers 只用 headers)
local function signed_headers(method, url, cred, body)
local out = signed_parts(method, url, cred, body)
return out
end
-- sign_parts(method,url,cred,body):把签名**中间值**返回给判据用。
-- 与 build_headers 共用同一个 signed_headers(不重复实现 —— 判据必须比对
-- 真实函数算出的 canonical / string_to_sign,自算版会掩盖真实签名被改坏)。
function adapter.sign_parts(method, url, cred, body)
local out, canonical, sts = signed_parts(method, url, cred, body)
if not out then return nil, canonical end
return out, canonical, sts
end
-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。
-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。
function adapter.build_headers(meta)
-- ★★ Connection: close 是**必须**的,不是优化。
--
-- CodeArts 后端按**账户**限制并发会话数(3 个),而它把"会话"绑定在
-- HTTP 连接上:内核的 Go transport 会保留空闲连接(默认每 host 2 条)
-- 用于 keep-alive,于是第 3~4 个请求就被判成"并发会话数已达上限(3个)"
-- (TM.00001041),用户看到的是 codeart 连续不可用。
--
-- 实证(2026-10-20):
-- * 走网关连续 5 次 → #1/#2/#3 成功,#4/#5 全部 TM.00001041;
-- * 同一凭据、每请求**新连接 + Connection: close** 连续 5 次 → 5/5 成功;
-- * 之后连续流式也一样:3 次成功后开始 400。
-- 即"会话"就是连接,关掉连接会话立刻释放。
--
-- 代价是每次多一次 TCP/TLS 握手(~百毫秒级),换"渠道真的能用",值得。
local h = { ["Content-Type"] = "application/json", ["Connection"] = "close" }
local raw = tostring(meta.api_key or "")
local cred = nil
if raw:sub(1, 1) == "{" then
local ok, rec = pcall(json.decode, raw)
if ok and type(rec) == "table" then cred = rec end
end
if cred == nil then
-- 裸 token 形态(兼容旧数据):无法签名,如实说明
h["X-Channels-Error"] = "codearts 需要完整凭据 JSON(AK/SK),当前是裸 token"
return h
end
local signed, err = signed_headers(meta.method or "POST", meta.url or "", cred, meta.body)
if not signed then
h["X-Channels-Error"] = tostring(err)
return h
end
for k, v in pairs(signed) do h[k] = v end
-- ★★ Session-Id 是**必须**的,不是可选项 —— 它决定"并发会话数"怎么算。
--
-- 实测(2026-10-20,对照参考实现 llm-adapter.ts:1168 `headers.set('Session-Id', …)`):
-- 不发送会话号时,后端把**每个请求**当成一个新会话;而会话在响应结束后还要
-- 存活一段时间才释放,于是连续 3 个请求就把账户的「并发会话数上限(3个)」占满,
-- 之后所有请求(含流式)稳定回 TM.00001041 / 200+空流(用户看到 empty stream)。
-- 发一个**稳定**的会话号(同一账号内恒定),后端复用同一会话 ⇒ 不再累加。
--
-- 会话号取 AK 的 sha256 前 32 hex:稳定、可复现、不引入需要持久化的状态。
local ak = tostring(cred.access_key_id or cred.access_key or "")
if ak ~= "" then
h["Session-Id"] = string.sub(sha256_hex(ak), 1, 32)
end
return h
end
-- 请求体:CodeArts 是标准 OpenAI 兼容,只需剥内核私有字段。
function adapter.transform_request(raw_body)
local req = __prelude.decode_body(raw_body)
if req == nil then return raw_body end
__prelude.strip_control(req)
req.model = req.model or "CodeArts-Pangu"
req.stream = req.stream or false
return json.encode(req)
end
function adapter.transform_response(raw_body)
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
-- CodeArts 的流式是 OpenAI 兼容格式,直接复用共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
return adapter
]==]
plugin.K.TRAE_SOLO_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "channels-trae"
adapter.version = "1.0.0"
adapter.endpoint = "/api/agent/v3/llm_utils_chat"
-- ★★ build_headers:推理请求的 Cloud-IDE-JWT 头族。
--
-- 参考 trae.ts traeSOLOHeaders(逐项对齐)。端点表明确:**对话也是 Cloud-IDE-JWT**,
-- 不是 Bearer。没有这个钩子时内核兜底发 `Authorization: Bearer <api_key>`
-- ⇒ 上游 401「We're sorry, but we are not able to authenticate you」
-- ⇒ 表现为「channel 注册的模型全都不能用」。
--
-- 真实缺陷(2026-10-17):本适配器只有 4 个 transform、**没有 build_headers**,
-- 而同一平台的签到/额度端点也漏了同一套头 —— 同一根因的两个出口。
--
-- meta 里可选带 uid / device_id / machine_id(由 syncAccountSource 从凭据取),
-- 因为适配器沙箱**读不到凭据**。
function adapter.build_headers(meta)
local tok = ""
local uid, dev, mid = "", "", ""
if type(meta) == "table" then
if type(meta.api_key) == "string" then tok = meta.api_key end
local src = type(meta.source) == "table" and meta.source or {}
local m = type(src.meta) == "table" and src.meta or {}
uid = tostring(m.uid or "")
dev = tostring(m.device_id or "")
mid = tostring(m.machine_id or "")
end
-- ★★ 这些值是**准入条件**,不是装饰。逐项照参考 trae-product.ts 的 TRAE 配置:
-- appId = 6eefa01c-1036-4c7e-9ca5-d891f63bfcd8 (完整 UUID,
-- 我第一版抄成 "6eefa6c9" —— 前缀都不对)
-- ideVersionCode = 20260811 (**日期式**,不是 0.1.52)
-- ideVersion = 0.1.52
-- deviceBrand = Apple
-- osVersion = macOS 15.7.4
-- userAgent = Trae/0.1.52 (参考用这个,不是 VSCode 那串)
--
-- 实测错误(2026-10-17):`app_version_code` 传 "0.1.52" 时上游回
-- 4001 bad request: binding: expr_path=app_version_code,
-- cause=parameter type does not match binding data
-- ⇒ 它要的是**日期式数字串**,版本号格式会被类型校验拒掉。
local h = {
["Content-Type"] = "application/json",
["Accept"] = "text/event-stream",
["User-Agent"] = "Trae/0.1.52",
["Authorization"] = "Cloud-IDE-JWT " .. tok,
["X-Cloudide-Token"] = tok,
["X-Ide-Token"] = tok,
["X-App-Id"] = "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
["X-App-Version"] = "default",
["X-Ide-Version"] = "0.1.52",
["X-Ide-Version-Code"] = "20260811",
["X-App-Version-Code"] = "20260811",
["X-Ide-Version-Type"] = "stable",
["X-Device-Type"] = "macos",
["X-OS-Version"] = "macOS 15.7.4",
["X-Device-Brand"] = "Apple",
["Request-Traffic-Type"] = "prod",
}
if uid ~= "" then h["X-Uid"] = uid end
if dev ~= "" then h["X-Device-Id"] = dev end
if mid ~= "" then h["X-Machine-Id"] = mid end
return h
end
local TRAE_DEFAULT_MODEL = "glm-5.2"
local TRAE_FUNCTION = "solo_work_lite"
local TRAE_MAX_MODE_TYPE = 1
local TRAE_MAX_PROMPT_TOKENS = 936000
local TRAE_MAX_OUTPUT_TOKENS = 64000
-- normalizeToolChoice:照参考 normalizeToolChoice(Go payload.go:111-154)
local function normalize_tool_choice(body)
local tc = body.tool_choice
if tc == nil then return end
local function suppress()
body.tools = nil
body.functions = nil
end
if type(tc) == "string" then
if string.lower(tc):gsub("%s+", "") == "none" then
body.tool_choice = nil
suppress()
end
return
end
if type(tc) == "table" then
local typ = type(tc.type) == "string" and string.lower(tc.type):gsub("%s+", "") or ""
if typ == "none" then
body.tool_choice = nil
suppress()
elseif typ == "auto" or typ == "required" then
body.tool_choice = typ
elseif typ == "function" then
local name = ""
local fn = tc["function"]
if type(fn) == "table" and type(fn.name) == "string" then name = fn.name end
if name == "" and type(tc.name) == "string" then name = tc.name end
if #(name:gsub("%s+", "")) > 0 then body.tool_choice = name
else body.tool_choice = "auto" end
else
body.tool_choice = nil
end
return
end
body.tool_choice = nil
end
-- normalizeTools:parameters 必须是**字符串**(SOLO 要求),OpenAI 标准是对象。
local function normalize_tools(body)
local raw = body.tools
if type(raw) ~= "table" or #raw == 0 then return end
local out = {}
for _, item in ipairs(raw) do
if type(item) == "table" then
local fn = item["function"]
if type(fn) == "table" then
if type(fn.parameters) == "table" then
fn.parameters = json.encode(fn.parameters)
end
out[#out + 1] = item
end
end
end
if #out > 0 then body.tools = out end
end
-- transformSOLOMessage:单条消息(content 字符串→parts;tool_calls 字段改名)
local function transform_solo_message(msg)
if type(msg) ~= "table" then return msg end
local result = {}
for k, v in pairs(msg) do result[k] = v end
if result.role == "assistant" and type(result.tool_calls) == "table" then
local kept = {}
for _, tc in ipairs(result.tool_calls) do
if type(tc) == "table" then
-- function → function_call(SOLO 字段名)
if type(tc["function"]) == "table" then
tc.function_call = tc["function"]
tc["function"] = nil
end
local fc = tc.function_call
local name = (type(fc) == "table" and type(fc.name) == "string") and fc.name or ""
-- 剔除没有 name 的条目(孤儿 tool_call 会让整条会话被拒)
if #(name:gsub("%s+", "")) > 0 then kept[#kept + 1] = tc end
end
end
if #kept > 0 then result.tool_calls = kept else result.tool_calls = nil end
end
-- content:字符串 → [{type:"text",text:...}];数组/其它原样透传
if type(result.content) == "string" then
result.content = { { type = "text", text = result.content } }
end
return result
end
-- transform_request:OpenAI 请求体 → SOLO 格式
function adapter.transform_request(raw_body)
local body = __prelude.decode_body(raw_body)
if body == nil then return raw_body end
__prelude.strip_control(body)
body.stream = true
-- ⚠️ `function` 是 Lua 关键字:必须用 body["function"],写 body.function
-- 是语法错误('<name>' expected near 'function'),且只在 load() 时才暴露
-- —— 模块级 local function 不会在编译期报出来。
if body["function"] == nil then body["function"] = TRAE_FUNCTION end
if type(body["function"]) == "string" and body["function"] == "" then
body["function"] = TRAE_FUNCTION
end
-- messages 转换
if type(body.messages) == "table" then
local msgs = {}
for _, m in ipairs(body.messages) do msgs[#msgs + 1] = transform_solo_message(m) end
body.messages = msgs
end
-- model → config_name + model(双字段)。支持 __dev 后缀消除。
local model = type(body.model) == "string" and body.model or ""
local base = model
local sep = string.find(model, "__", 1, true)
if sep then base = string.sub(model, 1, sep - 1) end
local config_name = (base ~= "" and base) or TRAE_DEFAULT_MODEL
body.config_name = config_name
body.model = config_name
normalize_tool_choice(body)
normalize_tools(body)
return json.encode(body)
end
-- aggregate_solo_sse(raw):把 SOLO 的 SSE(event:/data: 混合行)聚合成**末帧语义**
-- 的非流式响应对象。SOLO 对非流式请求也回 SSE(真实缺陷:body 以 event:metadata
-- 开头),而内核只把 data: 行交给流式路径——非流式路径收到的是**完整原始 body**
-- (含 event: 行),直接 json.decode 必然失败(invalid character 'e')。
-- 聚合规则:拼接所有 data: 帧的 response/reasoning_content,取最后的 finish_reason
-- 与 usage。无任何可解析帧时返回 nil(调用方回退原样)。
local function aggregate_solo_sse(raw)
local content, reasoning = {}, {}
local finish, usage = nil, nil
local seen_tool_calls = nil
local seen = false
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
if s:sub(1, 5) == "data:" then
local payload = s:sub(6):gsub("^%s+", "")
if payload ~= "" and payload ~= "[DONE]" then
local ok, d = pcall(json.decode, payload)
if ok and type(d) == "table" then
seen = true
if type(d.response) == "string" and d.response ~= "" then
content[#content + 1] = d.response
end
if type(d.reasoning_content) == "string" and d.reasoning_content ~= "" then
reasoning[#reasoning + 1] = d.reasoning_content
end
if d.finish_reason ~= nil then finish = tostring(d.finish_reason) end
if d.prompt_tokens ~= nil or d.completion_tokens ~= nil or d.total_tokens ~= nil then
usage = { prompt_tokens = tonumber(d.prompt_tokens) or 0,
completion_tokens = tonumber(d.completion_tokens) or 0,
total_tokens = tonumber(d.total_tokens) or 0 }
end
-- SOLO 把 tool_calls 放在帧上;聚合时保留**最后一个非空**数组
if type(d.tool_calls) == "table" and #d.tool_calls > 0 then
seen_tool_calls = d.tool_calls
end
end
end
end
end
if not seen then return nil end
local out = {
choices = { { index = 0,
message = { role = "assistant", content = table.concat(content) },
finish_reason = finish or "stop" } },
}
local rc = table.concat(reasoning)
if rc ~= "" then out.choices[1].message.reasoning_content = rc end
if seen_tool_calls then out.choices[1].message.tool_calls = seen_tool_calls end
if usage then out.usage = usage end
local ok2, enc = pcall(json.encode, out)
if not ok2 then return nil end
return enc
end
function adapter.transform_response(raw_body)
-- SOLO 上游对非流式请求也回 SSE(带 event: 前缀的帧)。识别出 SSE 就地聚合成
-- OpenAI 非流式形状再走统一转换;识别不出(真 JSON)原样透传。
if type(raw_body) == "string"
and (raw_body:match("^%s*event:") or raw_body:match("[\r\n]event:") or raw_body:match("^data:")) then
local aggregated = aggregate_solo_sse(raw_body)
if aggregated ~= nil then raw_body = aggregated end
end
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
-- ★★ TRAE 是 **SOLO 自定义 SSE**,且内核要求适配器返回**扁平的 UnifiedChunk**
-- (`content` / `reasoning_content` / `usage` / `done` / `finish_reason`),
-- **不是** OpenAI 的 `{choices:[{delta:{...}}]}` 外壳。
-- 见 __prelude.openai_stream_chunk 的实现:它把 choices[0].delta 摊平后返回。
--
-- 实测上游真实帧(2026-10-17 直接打 chat 端点抓包):
-- event:output → data:{"response":"###","reasoning_content":null,"tool_calls":null}
-- 正文在 **response**,推理在 **reasoning_content**(思考模式下先出 78 帧推理、
-- 再出 40 帧正文)。
--
-- 真实缺陷(用户报「无法使用 channel 注册的模型」):
-- ① 我原先直接 `return __prelude.openai_stream_chunk(raw_chunk)` ——
-- SOLO 帧没有 `choices` ⇒ 每帧都解析不出内容 ⇒ `empty stream`。
-- ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对:
-- 内核把整个 JSON 解码成 UnifiedChunk(扁平结构),外壳字段全被丢弃 ⇒
-- 91 帧全是空 content。
--
-- ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
-- 所以只能**按内容特征**判别帧类型。
local ok, raw = pcall(json.decode, raw_chunk)
if not ok or type(raw) ~= "table" then return "" end
-- ① done:有 finish_reason、无正文
if raw.finish_reason ~= nil and raw.response == nil then
return json.encode({ done = true, finish_reason = tostring(raw.finish_reason) })
end
-- ② token_usage:整个对象是 usage
if raw.response == nil and raw.reasoning_content == nil and raw.tool_calls == nil
and (raw.prompt_tokens ~= nil or raw.completion_tokens ~= nil or raw.total_tokens ~= nil) then
return json.encode({ done = false, usage = {
prompt = tonumber(raw.prompt_tokens) or 0,
completion = tonumber(raw.completion_tokens) or 0,
total = tonumber(raw.total_tokens) or 0,
} })
end
-- ③ output:正文 / 推理 / 工具调用(**扁平**输出)
local unified = { done = false }
local has = false
if type(raw.response) == "string" and raw.response ~= "" then
unified.content = raw.response
has = true
end
if type(raw.reasoning_content) == "string" and raw.reasoning_content ~= "" then
unified.reasoning_content = raw.reasoning_content
has = true
end
if type(raw.tool_calls) == "table" then
local tcs = {}
for _, c in ipairs(raw.tool_calls) do
if type(c) == "table" then
local copy = {}
for k, v in pairs(c) do copy[k] = v end
-- 参考 normalizeTraeToolCalls:function_call → function,清理 SOLO 专属字段
if type(copy.function_call) == "table" and copy["function"] == nil then
copy["function"] = copy.function_call
copy.function_call = nil
end
if type(copy["function"]) == "table" then
copy["function"].namespace = nil
copy["function"].partial_arguments = nil
end
tcs[#tcs + 1] = copy
end
end
if #tcs > 0 then
unified.tool_calls = tcs
has = true
end
end
if not has then return "" end
return json.encode(unified)
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
return adapter
]==]
function claimLoomy(plat, tok, args)
local cred = (args and args.credential) or nil
local base = (cred and (cred.api_base or cred.apiBase)) or "https://loomy.xunfei.cn"
local root = base:gsub("/chat/completions$", "")
local url = root .. "/points/first-login"
local resp, err = __llmsproxy_http({
url = url, method = "POST",
headers = {
["Authorization"] = "Bearer " .. tok,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
},
body = "{}",
})
if err or not resp then
return { done = true, kind = "failed", message = "领取失败: " .. tostring(err) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
if resp.status ~= 200 then
local msg = rec.message or rec.msg or ""
return { done = true, kind = "failed",
message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) }
end
local data = rec.data or rec
if type(data) ~= "table" then data = {} end
local dailyQuota = tonumber(data.dailyQuota)
local dailyBalance = tonumber(data.dailyBalance)
if data.alreadyProcessed == true then
return { done = true, kind = "already-claimed",
message = dailyQuota and
string.format("今日额度已初始化(每日 %s/%s)",
tostring(dailyBalance or 0), tostring(dailyQuota))
or "今日额度已初始化" }
end
local dailyConsumed = tonumber(data.dailyConsumed)
local granted = 0
if dailyQuota and dailyConsumed then
granted = math.max(0, dailyQuota - dailyConsumed)
end
return { done = true, kind = "claimed", credit = granted, unit = "quota" }
end
local function zcodeHeaders(cred, opts)
opts = opts or {}
local h = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Device-Mid"] = tostring(cred.device_mid or ""),
["X-App-Version"] = tostring(cred.app_version or "1.0.0"),
["X-Client-Platform"] = "win32",
}
if opts.authorization then h["Authorization"] = opts.authorization end
if opts.captcha then
h["X-Captcha-Param"] = opts.captcha.param
h["X-Captcha-Region"] = opts.captcha.region or ""
end
return h
end
local function zcodeClaimablePlans(cred)
local av = tostring(cred.app_version or "1.0.0")
local url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
.. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32"
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = zcodeHeaders(cred, {
authorization = "Bearer " .. tostring(cred.zcode_jwt or cred.access_token or ""),
}),
})
if err or not resp then return nil, "查询可领计划失败: " .. tostring(err) end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then return nil, "响应不是 JSON" end
local plans = rec.plans or (rec.data and rec.data.plans) or {}
local out = {}
for _, pl in ipairs(plans) do
if type(pl) == "table" then
local id = pl.plan_id or pl.planId
if id then out[#out + 1] = tostring(id) end
end
end
return out, nil
end
function claimZcode(plat, tok, args)
local cred = (args and args.credential) or {}
local jwt = cred.zcode_jwt or cred.access_token or tok
cred = cred or {}
if not cred.zcode_jwt and not cred.access_token then cred.zcode_jwt = tok end
__llmsproxy_http({
url = plugin.K.ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
headers = { ["Content-Type"] = "application/json" },
body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }),
})
local plans, perr = zcodeClaimablePlans(cred)
if not plans then
return { done = true, kind = "failed", message = perr }
end
if #plans == 0 then
return { done = true, kind = "inactive",
message = "当前没有可领取的计划(可能活动未投放,或今日已领)" }
end
local captcha = args and args.captcha
if type(captcha) ~= "table" or not captcha.param or captcha.param == "" then
return { done = true, kind = "action_required",
message = "zcode 领取需要验证码参数(captcha param)。"
.. "本机无 DSH Desktop 内部载体,需在面板手动领取(会拉起浏览器);"
.. "自动签到已按参考把它排除。" }
end
local granted, failedMsg = 0, nil
for _, planId in ipairs(plans) do
local resp, err = __llmsproxy_http({
url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
headers = zcodeHeaders(cred, {
authorization = "Bearer " .. tostring(jwt),
captcha = { param = captcha.param, region = captcha.region or "cn" },
}),
body = json.encode({ plan_id = planId }),
})
if err or not resp then
failedMsg = "领取失败: " .. tostring(err)
else
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
local code = (ZD and ZD.parseBusinessCode(rec.code))
or tonumber(rec.code) or tonumber(tostring(rec.code or ""))
local msg = tostring(rec.msg or rec.message or "")
local isRisk = (ZD and ZD.hasUnusualActivity(resp.body or ""))
or (code == 3012) or (msg:find("unusual activity", 1, true) ~= nil)
if isRisk then
failedMsg = (ZD and ZD.formatUnusualActivityMessage({
subject = "领取 " .. tostring(planId or ""),
rawResponse = msg,
})) or "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
elseif code and code ~= 0 and code ~= 200 then
failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code))
else
granted = granted + 1
end
else
failedMsg = "响应不是 JSON(HTTP " .. tostring(resp.status) .. ")"
end
end
end
if granted == 0 then
return { done = true, kind = "failed", message = failedMsg or "全部计划领取失败" }
end
return { done = true, kind = "claimed", credit = granted, unit = "plans",
message = ("已领取 " .. granted .. "/" .. #plans .. " 个计划")
.. (failedMsg and ("(部分失败:" .. failedMsg .. ")") or "") }
end
function claimRaccoon(plat, tok, args)
local cred = (args and args.credential) or nil
local base = (cred and (cred.api_base or cred.apiBase)) or plat.base_url or ""
local root = base:gsub("/api/web/llm/v2$", "")
local url = root .. "/api/web/desktop/v1/login/points/grant"
local h = {
["Authorization"] = "Bearer " .. tok,
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["X-Org-Code"] = (cred and (cred.office_identity or "")) or "",
["X-Raccoon-Language"] = "zh",
}
if cred and cred.device_id and cred.device_id ~= "" then
h["X-Device-Id"] = cred.device_id
end
local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = h, body = "{}" })
if err or not resp then
return { done = true, kind = "failed",
message = "领取请求失败: " .. tostring(err) }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then rec = {} end
local env = rec.data and rec or rec
if resp.status ~= 200 or (rec.code and rec.code ~= 0 and rec.code ~= 200) then
local msg = rec.message or rec.msg or ""
return { done = true, kind = "failed",
message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) }
end
local data = rec.data or env
if type(data) ~= "table" or data.granted ~= true then
return { done = true, kind = "already-claimed",
message = "该账号已领取过桌面端登录奖励(每号一次)" }
end
local points = tonumber((data.popup or {}).points) or 3000
return { done = true, kind = "claimed", credit = points, unit = "points" }
end
local function installTraeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(plugin.K.TRAE_SOLO_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-trae", plugin.K.TRAE_SOLO_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
plugin.K.GEMINI_PRIVATE_ADAPTER_SRC = [==[
local adapter = {}
adapter.name = "channels-gemini"
adapter.version = "1.0.0"
adapter.endpoint = "/v1internal:generateContent"
adapter.endpoint_stream = "/v1internal:streamGenerateContent?alt=sse"
-- 身份五头逐字写死(参考 GEMINI_IDENTITY_HEADERS)。**不许随机**:
-- 上游按客户端指纹识别,随机化会被判异常。
adapter.headers = {
["User-Agent"] = "antigravity/4.3.0 (cmdc-pak)",
["x-client-name"] = "antigravity",
["x-client-version"] = "4.3.0",
["x-machine-id"] = "cmdc-pak",
["x-vscode-sessionid"] = "proxy",
}
-- ⚠️ 刻意**不带** Accept(参考:流式请求不带 Accept,抓包一致)。
-- 也不需要 x-goog-api-key(Cloud Code 用 OAuth Bearer)。
-- 字母序 JSON 序列化(参考 marshalAlphabetical → sortedStringify)。
-- 为什么必须排序:上游按**字节**比对/缓存,键序不同即不同请求。Go 的
-- encoding/json 对 map 自动字母序,Lua 的 json.encode 不排序 —— 故自己排。
local function sorted_json(v)
local t = type(v)
if v == nil then return "null" end
if t == "boolean" then return v and "true" or "false" end
if t == "number" then
if v == math.floor(v) and math.abs(v) < 1e15 then return string.format("%d", v) end
return tostring(v)
end
if t == "string" then return json.encode(v) end
if t == "table" then
-- 数组(1..n 连续整数键)→ 保序;对象 → 键排序
local n = 0
for k in pairs(v) do
if type(k) ~= "number" then n = -1 break end
if k > n then n = k end
end
if n >= 0 then
local parts = {}
for i = 1, n do parts[#parts + 1] = sorted_json(v[i]) end
return "[" .. table.concat(parts, ",") .. "]"
end
local keys = {}
for k in pairs(v) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = json.encode(k) .. ":" .. sorted_json(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
-- 首条 user 文本(sessionId 的派生输入之一)
local function first_user_text(contents)
if type(contents) ~= "table" or #contents == 0 then return "" end
local parts = contents[1] and contents[1].parts
if type(parts) ~= "table" then return "" end
for _, p in ipairs(parts) do
if type(p) == "table" and type(p.text) == "string" then return p.text end
end
return ""
end
-- sessionId = f(project, 首条 user 文本)。参考明言:数值不与原版逐字相同
-- (哈希本体未反推出来),复刻的是**依赖维度与"同输入同输出"**。
-- 这点很重要:不要假装数值一致;判据只能断言确定性(同输入同输出、
-- 换 project/文本会变、换 model 不变)。
local function derive_session_id(project, text)
-- ⚠️ 本 Lua 构建**不支持按位异或 `~`**(load 时报 unexpected symbol near '~')。
-- FNV-1a 的 XOR 用「加等价类」替代:这里要的不是 FNV 的精确值(参考自己
-- 也明言数值不与原版逐字相同,哈希本体未反推出来),而是**确定性 + 依赖
-- 正确的维度**(吃 project 与首条文本、不吃 model)。
local h = 2166136261
local input = tostring(project) .. "\\0" .. tostring(text)
for i = 1, #input do
h = (h + string.byte(input, i) * 2654435761) % 4294967296
h = (h * 16777619) % 4294967296
end
return tostring(h)
end
function adapter.transform_request(raw_body)
local req = __prelude.decode_body(raw_body)
if req == nil then return raw_body end
__prelude.strip_control(req)
-- ① 先把 OpenAI 消息转成 Gemini contents(复用既有逻辑的等价实现)
local contents, system = {}, ""
local call_names = {}
local function to_parts(content)
if type(content) == "string" then
if content == "" then return {} end
return { { text = content } }
end
local out = {}
for _, p in ipairs(content or {}) do
if type(p) == "string" then
if p ~= "" then out[#out + 1] = { text = p } end
elseif type(p) == "table" then
if p.type == "text" and p.text ~= nil and p.text ~= "" then
out[#out + 1] = { text = p.text }
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
if b64 then
out[#out + 1] = { inlineData = { mimeType = mt or "image/png", data = b64 } }
end
end
end
end
return out
end
for _, m in ipairs(req.messages or {}) do
local role = m.role or "user"
if role == "system" then
if type(m.content) == "string" then system = system .. m.content .. "\n" end
elseif role == "assistant" then
local parts = to_parts(m.content)
if type(m.tool_calls) == "table" then
for _, tc in ipairs(m.tool_calls) do
if type(tc) == "table" and type(tc["function"]) == "table" then
call_names[tostring(tc.id)] = tc["function"].name
local args = {}
local ok, decoded = pcall(json.decode, tc["function"].arguments or "{}")
if ok and type(decoded) == "table" then args = decoded end
parts[#parts + 1] = { functionCall = { name = tc["function"].name, args = args } }
end
end
end
if #parts > 0 then contents[#contents + 1] = { role = "model", parts = parts } end
elseif role == "tool" then
local name = call_names[tostring(m.tool_call_id or "")] or ""
contents[#contents + 1] = { role = "user", parts = { {
functionResponse = { name = name, response = { output = tostring(m.content or "") } },
} } }
else
local parts = to_parts(m.content)
if #parts > 0 then contents[#contents + 1] = { role = "user", parts = parts } end
end
end
-- ② 组装 request
local greq = { contents = contents }
if system ~= "" then
greq.systemInstruction = { role = "user", parts = { { text = system } } }
end
if type(req.tools) == "table" then
local decls = {}
for _, t in ipairs(req.tools) do
if type(t) == "table" and type(t["function"]) == "table" then
local fn = t["function"]
local params = fn.parameters
if type(params) ~= "table" then params = { type = "object", properties = {} } end
decls[#decls + 1] = {
name = fn.name or "", description = fn.description or "", parameters = params,
}
end
end
if #decls > 0 then greq.tools = { { functionDeclarations = decls } } end
end
local gen = {}
if req.max_tokens then gen.maxOutputTokens = req.max_tokens end
if req.temperature then gen.temperature = req.temperature end
if req.top_p then gen.topP = req.top_p end
if next(gen) then greq.generationConfig = gen end
local project = (req.project and tostring(req.project)) or "aicode-consumers"
greq.sessionId = derive_session_id(project, first_user_text(contents))
-- ③ 信封(字母序序列化)
local envelope = {
model = req.model or "",
project = project,
request = greq,
requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8),
userAgent = "antigravity",
}
return sorted_json(envelope)
end
-- 响应也是信封:{response:{candidates:[…]}} —— 必须剥一层。
-- 不剥的话下游拿不到 candidates,表现为"模型没回话"。
local function unwrap(raw, decode_fn)
local rec = __prelude.decode_body(raw)
if rec == nil then return raw end
if type(rec.response) == "table" then return rec.response end
return rec
end
function adapter.transform_response(raw_body)
local inner = unwrap(raw_body)
if type(inner) == "string" then return inner end
return __prelude.gemini_response(json.encode(inner))
end
function adapter.transform_stream_chunk(raw_chunk)
local inner = unwrap(raw_chunk)
if type(inner) == "string" then return inner end
return __prelude.gemini_stream_chunk(json.encode(inner))
end
function adapter.transform_error(status, body)
return __prelude.openai_error(status, body)
end
-- 判据用:把派生逻辑暴露出来(不重复实现,避免判据与被测对象脱节)
function adapter.derive_session_id(project, text)
return derive_session_id(project, text)
end
function adapter.sorted_json(v)
return sorted_json(v)
end
return adapter
]==]
plugin.K.QODER_ADAPTER_SRC = [==[
local adapter = { name = "channels-qoder", version = "1.0.0" }
-- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的),
-- 故转换钩子直接复用前导助手。
function adapter.transform_request(raw_body)
local body = __prelude.decode_body(raw_body)
if body == nil then return raw_body end
__prelude.strip_control(body)
return json.encode(body)
end
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
-- 厂商码值只出现在**这个适配器**里(内核零厂商名)。
QODER_QUEUE_CODE = "10605"
-- qoder_unwrap(root):广度优先展开 data/result/message/body。
-- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层
-- 排队参数(参考第一版漏了这条,SSE 通道的修复静默失效、探针显示 sleep=0)。
local function qoder_unwrap(root)
local seen, nodes, queue = {}, {}, { root }
while #queue > 0 do
local node = table.remove(queue, 1)
if type(node) == "table" and not seen[node] then
seen[node] = true
nodes[#nodes + 1] = node
for _, k in ipairs({ "data", "result", "message", "body" }) do
local v = node[k]
if type(v) == "table" then
queue[#queue + 1] = v
elseif type(v) == "string" and v ~= "" then
local okp, parsed = pcall(json.decode, v)
if okp and type(parsed) == "table" then queue[#queue + 1] = parsed end
end
end
end
end
return nodes
end
function adapter.transform_error(status, body)
local okp, root = pcall(json.decode, body or "")
if okp and type(root) == "table" then
-- ⚠️ 判据:命中顶层 code=10605,**或**直接出现排队字段。
-- 内层(SSE 错误帧的 message)**没有 code 字段** —— 若要求必须命中 code,
-- 内层形态会被判成非排队,修复静默失效。
local topHit = tostring(root.code) == QODER_QUEUE_CODE
-- ⚠️ 必须**先找带排队字段的那个节点**,不能一命中就返回:
-- 外层节点(root)本身只有 code,没有 retryAfterSeconds/queueCount,
-- 若在 root 上就返回,等待时长与队列长度**永远取不到**(实测踩到:
-- 只输出"排队中,请稍后重试",参数全丢)。
for _, n in ipairs(qoder_unwrap(root)) do
if n.isQueued ~= nil or n.serviceAvailable ~= nil then
local ra = tonumber(n.retryAfterSeconds) or tonumber(n.retryAfter) or 0
local qc = tonumber(n.queueCount) or 0
if ra > 0 then
return "qoder 模型排队中(前面 " .. tostring(qc) .. " 个),约 "
.. tostring(ra) .. " 秒后重试"
end
return "qoder 模型排队中,请稍后重试"
end
end
-- 只有 code 命中、没有任何排队字段:仍然算排队(码值本身是权威),
-- 但参数确实拿不到,如实说明。
if topHit then return "qoder 模型排队中,请稍后重试" end
-- 额度类:关键词必须**窄**(`quota`/`balance` 泛词会误伤模型正文)
local low = string.lower(body or "")
if low:find("billing daily count exceeded", 1, true)
or low:find("daily count exceeded", 1, true)
or low:find("billing_error", 1, true) then
return "qoder 当日额度已用尽(明日恢复),重试无用"
end
end
return __prelude.openai_error(status, body)
end
return adapter
]==]
local ZCODE_DIAG_SRC = [===[
-- ============================================================================
-- ZCode 3012 判据 / 文案 / 诊断(**唯一出处**,两处消费者共用同一份源码)
--
-- 消费者:
-- ① 插件主体(领取路径 claimZcode);
-- ② 自装适配器 channels-zcode(推理路径的错误分类 + 请求体形态诊断)。
-- 两者由 installZcodeAdapter 把本段**前置**到适配器源码里实现共用
-- (照参考的设计:formatZcodeUnusualActivityMessage 被两条路径引用同一份,
-- 因为 3012 不是"重试就能过",而是**有账号冷却惩罚**——"请勿连续重试"
-- 是这段文案里最重要的一句,绝不能只在其中一条路径上有)。
--
-- ## 为什么不能只看裸数字(参考 2026-10-05 对抗性审计的实测结论)
--
-- `too many requests, retry after 3012 ms` → 仅看数字会**误判成风控**
-- `<h1>Error 3012</h1>upstream connect timeout` → 同上
--
-- 前者后果**不是文案问题**:限流是"稍后重试即可"、风控是"**别重试**",
-- 两者用户动作**完全相反**;且领取路径据此**短路**,跳过本可领取的 plan。
--
-- ## 为什么也不能用"独立词 3012"当门禁
--
-- 上游 3012 的 `msg` 原文就是 `request has been blocked due to unusual
-- activity.` —— **不含任何数字**(数字在 `code` 字段里)。以真实文案做样本时,
-- "必须有独立词 3012"这条门禁会**反过来把它漏掉**。
-- ⇒ 判据以**语义短语**为主,数字只作兜底(且必须与 block 字样紧邻)。
-- ============================================================================
local Z = {}
-- isUnusualActivityCode(code):码是否为 3012。只认 number 与**纯数字字符串**。
--
-- ⚠ 必须认字符串形态(真实缺陷 2026-10-05):上游会回
-- {"code":"3012","msg":"..."},而只认 number 的解析会让风控码整条丢失,
-- 用户看到的正是那句裸上游串(漏判的直接原因)。
-- ⚠ 只认**纯数字**(^%-?%d+$),不把任意文本静默变成业务码。
function Z.isUnusualActivityCode(code)
if type(code) == "number" then return code == 3012 end
if type(code) ~= "string" then return false end
return code:match("^%s*3012%s*$") ~= nil
end
-- parseBusinessCode(value):把上游 code 归一成数字;认 number 与纯数字字符串。
function Z.parseBusinessCode(value)
if type(value) == "number" then
if value ~= value or value == math.huge or value == -math.huge then return nil end
return value
end
if type(value) ~= "string" then return nil end
local t = value:match("^%s*(.-)%s*$")
if not t:match("^%-?%d+$") then return nil end
return tonumber(t)
end
-- looksLikeUnusualActivity(body):正文里以**独立词**出现 3012。
--
-- ⚠ 必须是词边界,不能是 body:find("3012", 1, true)(真实缺陷):裸子串会把
-- 其它码里的数字片段一并命中,实测误判形态:
-- {"code":13012} → 误命中
-- plan-3012-trust → 误命中
-- 后果不是"文案不好看":领取路径据此短路,把"plan 名里恰好含 3012"的普通
-- 失败当成风控,**跳过本可领取的 plan**,并把真实业务码(如 1005 额度用尽)
-- 改写成 3012 —— 两者用户动作完全相反(等额度重置 vs 别重试)。
--
-- ⚠ 边界集必须含 **`-` 与 `_`**:初版只排除数字 ⇒ `plan-3012-trust` 仍命中;
-- 第二版补字母 ⇒ 仍命中(连字符不属 [0-9A-Za-z])。故取 [0-9A-Za-z_-]。
-- `.` `,` `}` `:` 空格 行首行尾都**不**被排除,故上游真实形态
-- {"code":3012,...} 与自然语言 `error: 3012 blocked` 照常命中。
function Z.looksLikeUnusualActivity(body)
if type(body) ~= "string" then return false end
-- Lua 无 lookbehind/lookahead ⇒ 手工检查两个前导/后继字符
local from = 1
while true do
local s, e = body:find("3012", from, true)
if not s then return false end
local before = s > 1 and body:sub(s - 1, s - 1) or ""
local after = e < #body and body:sub(e + 1, e + 1) or ""
local function bad(c)
if c == "" then return false end
return c:match("[0-9A-Za-z_%-]") ~= nil
end
if not bad(before) and not bad(after) then return true end
from = s + 1
end
end
-- mentionsUnusualActivity(body):正文是否**表达**了风控(仅凭正文时的唯一入口)。
--
-- ① 主判据:上游**实测原文**的语义短语(覆盖 `request has been blocked due to
-- unusual activity.` 及其变体);
-- ② 兜底:独立词 3012 与 block 字样**紧邻(≤10 字符)**。
--
-- ⚠ 为什么必须"紧邻"而不是"全文共现"(真实缺陷 N2):第一版写成"独立词 3012
-- 且全文含 block",仍会误判限流正文:
-- `rate limited: request blocked, retry after 3012 ms` → 距 14,误判
-- `temporarily blocked; see error 3012` → 距 12,误判
-- 而 `3012 blocked by waf` → 距 1,应命中
-- 窗口 10 是实测收出来的(40 仍放过上面两个反例)。
function Z.mentionsUnusualActivity(body)
if type(body) ~= "string" then return false end
if body:lower():find("unusual activity", 1, true) then return true end
if body:lower():find("blocked due to", 1, true) then return true end
if not Z.looksLikeUnusualActivity(body) then return false end
-- 紧邻检查:3012 前后 10 字符内出现 block
local low = body:lower()
local from = 1
while true do
local s, e = low:find("3012", from, true)
if not s then return false end
local before = s > 1 and low:sub(math.max(1, s - 10), s - 1) or ""
local after = e < #low and low:sub(e + 1, math.min(#low, e + 10)) or ""
if before:find("block", 1, true) or after:find("block", 1, true) then return true end
from = s + 1
end
end
-- readBusinessCodeFromBody(body):从响应正文读业务码(认 number 与数字字符串)。
function Z.readBusinessCodeFromBody(body)
if type(body) ~= "string" then return nil end
local ok, rec = pcall(json.decode, body)
if not ok or type(rec) ~= "table" then return nil end
return Z.parseBusinessCode(rec.code)
end
-- hasUnusualActivity(body):**有优先级**——正文能解析出业务码就只信码;
-- 无码可读才看正文语义。
--
-- ⚠ 优先级是必须的:{"code":3012} 这种正文**不含语义词**,直接走文本判定会
-- 漏判 ⇒ 错误被归为可重试类 ⇒ 有**冷却惩罚**的风控被白重试多次
-- (qoder 那边记过同型缺陷)。
function Z.hasUnusualActivity(body)
local code = Z.readBusinessCodeFromBody(body)
if code ~= nil then return Z.isUnusualActivityCode(code) end
return Z.mentionsUnusualActivity(body)
end
-- formatUnusualActivityMessage(opts):3012 的**共用文案**。
-- opts.subject(定位信息,如 planId)/ opts.diagnostic / opts.rawResponse。
function Z.formatUnusualActivityMessage(opts)
opts = opts or {}
local subject = (type(opts.subject) == "string" and opts.subject ~= "")
and (" · " .. opts.subject) or ""
local diag = (type(opts.diagnostic) == "string" and opts.diagnostic ~= "")
and ("本机诊断:" .. opts.diagnostic .. "\n") or ""
local raw = (type(opts.rawResponse) == "string" and opts.rawResponse ~= "")
and ("原始响应:" .. opts.rawResponse) or ""
return "上游风控拦截(3012 unusual activity)" .. subject .. "。"
.. "⚠ 该错误有账号冷却惩罚(30 分钟,反复触发会升级到 24 小时乃至停用),"
.. "请勿连续重试。\n" .. diag .. raw
end
-- ============================================================================
-- 进程级账号画像(只给诊断用;**绝不写凭据**)
-- ============================================================================
-- 红线(照参考):账号只以**进程内自增序号**出现,不含 accountId/JWT/账号名
-- 的任何字符片段。
local profiles, nextOrdinal = {}, 1
local function normalizeKey(accountId)
if accountId == nil or accountId == "" then return "-" end
return tostring(accountId)
end
-- accountOrdinal(accountId):进程内序号,**首次见到时分配**(之后稳定)。
-- 同一账号跨请求必须是同一个序号,否则"本次是第几个账号"会随请求变化。
function Z.accountOrdinal(accountId)
local k = normalizeKey(accountId)
local p = profiles[k]
if p then return p.ordinal end
p = { ordinal = nextOrdinal, sent = 0, ok = 0, failed = 0 }
nextOrdinal = nextOrdinal + 1
profiles[k] = p
return p.ordinal
end
-- noteRequestSent(accountId, sentAt):记一次"请求已发出"。
-- ⚠ sentAt 必须在**真正发出之前**取,否则"距上一条"会把本次排队时间算进去。
function Z.noteRequestSent(accountId, sentAt)
Z.accountOrdinal(accountId)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.sent = p.sent + 1
-- ⚠ 旧值**下沉一格**再写新的:诊断要的"距上条"是**上一条**,不是本次。
-- 若拿 lastSentAt 顶替,算出来恒为 0ms,这一项就永远没有信息量
-- (参考做端到端验证时真的打印出过"距上条 0ms")。
p.previousSentAt = p.lastSentAt
p.lastSentAt = sentAt
end
function Z.noteRequestOk(accountId, at)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.ok = p.ok + 1
p.lastOkAt = at
end
function Z.noteRequestFailed(accountId)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.failed = p.failed + 1
end
-- formatGap(ms):把毫秒差写成一眼能读的形态。
--
-- ⚠ 各档**逐档单独算**:参考初版把分钟档写成 floor(seconds % 60),于是 90 秒
-- 被渲染成"30分30秒"(分位取的是**秒的余数**)。该错误在真实探针输出里发现,
-- 纯函数单测当时没覆盖 ≥60 秒的区间 —— 故本实现直接按 totalSeconds 拆。
function Z.formatGap(ms)
if ms == nil or ms < 0 then return "—" end
if ms < 1000 then return tostring(math.floor(ms + 0.5)) .. "ms" end
local totalSeconds = math.floor(ms / 1000)
if totalSeconds < 60 then return string.format("%.1fs", ms / 1000) end
local minutes = math.floor(totalSeconds / 60)
if minutes < 60 then
local rest = totalSeconds % 60
if rest == 0 then return tostring(minutes) .. "分" end
return tostring(minutes) .. "分" .. tostring(rest) .. "秒"
end
local hours = math.floor(minutes / 60)
local restMin = minutes % 60
if restMin == 0 then return tostring(hours) .. "小时" end
return tostring(hours) .. "小时" .. tostring(restMin) .. "分"
end
local function formatSinceLastOk(at, now)
if at == nil then return "从未成功过" end
return Z.formatGap(now - at) .. "前"
end
-- describeRequestShape(system, messages):读**真正要发出去的那个对象**的形态。
--
-- ⚠ 刻意**不读**常量(如"官方身份块 2898 字符"):常量只能证明"源码里写了多少
-- 字",证明不了"线上发出去多少字"。两者一旦不一致(例如某条改动把 system
-- 顶掉了),只有读实际值才看得出来 —— 它会**如实显示 0 字符**。
function Z.describeRequestShape(system, messages)
local blocks = type(system) == "table" and system or {}
local function blockChars(b)
if type(b) ~= "table" then return 0 end
return type(b.text) == "string" and #b.text or 0
end
local n = #blocks
local identityChars = 0
local perBlock = {}
for i = 1, math.min(2, n) do
local c = blockChars(blocks[i])
perBlock[#perBlock + 1] = c
identityChars = identityChars + c
end
local first = type(messages) == "table" and messages[1] or nil
local hasDate = false
if type(first) == "table" then
local c = first.content
if type(c) == "table" and type(c[1]) == "table" and type(c[1].text) == "string" then
hasDate = c[1].text:sub(1, 17) == "<system-reminder>"
elseif type(c) == "string" then
hasDate = c:sub(1, 17) == "<system-reminder>"
end
end
return {
systemBlocks = n,
identityChars = identityChars,
identityBlockChars = perBlock,
cliPrefixChars = n >= 1 and blockChars(blocks[1]) or 0,
hasDateBlock = hasDate,
firstRole = (type(first) == "table" and tostring(first.role)) or "",
}
end
-- formatDiagnostic(input):拼一行现场数据(**不含任何凭据片段**)。
function Z.formatDiagnostic(input)
input = input or {}
local p = profiles[normalizeKey(input.accountId)]
local ordinal = (p and p.ordinal) or Z.accountOrdinal(input.accountId)
local gap = (p and p.previousSentAt ~= nil)
and Z.formatGap((input.sentAt or 0) - p.previousSentAt) or "—"
local parts = {
"账号#" .. tostring(ordinal),
"本进程 成功 " .. tostring(p and p.ok or 0) .. "/失败 " .. tostring(p and p.failed or 0),
"最近成功 " .. formatSinceLastOk(p and p.lastOkAt, input.now or 0),
"距上条 " .. gap,
}
local shape = input.shape
if shape then
-- ⚠ 用 ~= nil 而非真值判断:Lua 里表恒为真,真正的分支是"传了/没传"
local per = #(shape.identityBlockChars or {}) > 0
and table.concat(shape.identityBlockChars, "+") or "无"
parts[#parts + 1] = "身份块 " .. tostring(shape.identityChars or 0)
.. " 字符(" .. per .. ")"
parts[#parts + 1] = "日期块 " .. (shape.hasDateBlock and "有" or "无")
end
parts[#parts + 1] = "HTTP " .. tostring(input.status)
return table.concat(parts, " · ")
end
-- resetDiagnostics():清空全部进程级状态(只给判据用)。
function Z.resetDiagnostics()
profiles = {}
nextOrdinal = 1
end
return Z
]===]
local ZD = (function()
local env = setmetatable({ json = json }, { __index = _G })
local f = load(ZCODE_DIAG_SRC, "zcode-diag")
if not f then return nil end
local ok, mod = pcall(f)
if not ok or type(mod) ~= "table" then return nil end
return mod
end)()
local ZCODE_ADAPTER_SRC = [===[
local CLI_PREFIX = "You are ZCode, an interactive coding agent"
-- ============================================================================
-- ZCode 专用适配器(插件自装:channels-zcode)
--
-- 为什么必须自装而不是复用通用 anthropic:
-- zcode 的 /zcode-plan/anthropic 通道对请求体做**内容检查** —— system 缺少
-- 官方身份块结构时直接返回
-- HTTP 405 + {"code":3012,"msg":"request has been blocked due to unusual activity."}
-- 参考实测矩阵(同账号、同验证码来源):
-- 无 system → 405 + 3012 ✗
-- 仅 cliPrefix(42 字符) → 405 + 3012 ✗
-- cliPrefix + stable(约 2894 字符) → 200 ✓
-- ⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、请求频率、多轮历史、
-- tools 声明、运行时(Electron/curl/Node)**全部无关**(三个账号 17 次实测)。
--
-- ⚠ 参考 2026-10-03 复测修正了两条旧说法,排查时别按旧的来:
-- ① HTTP 状态码是 **405**,不是 403;
-- ② 日期块(<system-reminder>)**不是**判据 —— 去掉照样 200。仍照发是因为
-- 官方如此且零成本,但别把「缺它就 3012」当结论(会让排查耗在错方向)。
--
-- ⚠ 身份块与上游策略**强耦合**:官方客户端升级若改了结构,本文件必须同步
-- (参考有同样的维护警告)。
--
-- 实现方式:复用内核的 anthropic 适配器(读它的源码并执行,拿回 adapter 表),
-- **只覆盖 transform_request** 注入身份块。协议转换(工具调用、图像块、流式帧)
-- 因此始终与 anthropic 那一份一致,不会漂移。
-- ============================================================================
local adapter = { name = "channels-zcode", version = "1.0.0" }
-- ★ 官方 CLI 前缀(准入必需)。逐字复刻,勿改。
-- ★ 官方 stable 段(准入必需;三段以 "\n\n" 连接)。逐字复刻,勿改。
-- 官方完整身份块还含 ~5KB 的 dynamic 段(给 coding agent 的行为指令),与准入
-- 无关,且它会被放在 system 开头从而**压过调用方自己的 prompt**(表现为
-- "啰嗦、慢")—— 故只发准入必需的部分(参考同此)。
local SECTION_A = [==[
You are an interactive ZCode agent that helps users with software engineering tasks.
IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
# Harness
- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.
- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.
- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.
- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.
- Reference code as `file_path:line_number` — it's clickable.
]==]
local SECTION_B = [==[
# ZCode Desktop Context
### Files & URLs
- Return local web URLs as Markdown links (e.g., [label](http://127.0.0.1:8080)).
- File should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Unless otherwise specified, return local file references as Markdown links (e.g., [name.md](/absolute/path/to/name.md)).
### Inline Code Comments
- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.
- Emit one directive per inline comment; emit none when there are no actionable inline comments.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3).
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2}
]==]
local SECTION_C = [==[
# Working style
When you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey. Prefer reading the actual file or running the actual command over reasoning about what it probably contains. When a signal pattern-matches to a known failure, check that the evidence actually supports that specific diagnosis before acting on it.
]==]
local STABLE_JOINED = SECTION_A .. "\n\n" .. SECTION_B .. "\n\n" .. SECTION_C
-- 参考声明的期望长度(2900)。用于自检:文本被改动(转义/截断)时立刻发现,
-- 而不是等到线上吃 3012 才知道。
local EXPECTED_MIN_CHARS = 2800
-- ============================================================================
-- 复用 anthropic 适配器
-- ============================================================================
-- ★★ 基座由**插件主体内联**进来(见下方 §基座占位符),
-- **不能**在适配器里自己读文件。实测(2026-10-16):
-- 适配器运行在**另一个沙箱 VM**,那里 `__llmsproxy_plugin_dir` 与
-- `LLMSPROXY_PLUGIN_DIR` **都不存在**,**也没有文件系统访问**
-- ⇒ 原先那段"就地读 anthropic.lua"必然失败且**静默降级**:
-- 适配器照常装载,但只有它自己定义的 2 个函数,
-- 缺 transform_response / transform_stream_chunk 等 **7 个**。
-- ⇒ 后果:每次调用内核对缺失函数**响亮报错**(不是静默透传)
-- ⇒ 流式请求逐帧报错 ⇒ 该渠道**完全不可用**。
-- 该缺陷一直潜伏:zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
local BASE = (function()
local chunk, lerr = load(__INLINE_ANTHROPIC_SRC__, "anthropic-base.lua")
if not chunk then return nil, lerr end
local ok, base = pcall(chunk)
if not ok or type(base) ~= "table" then return nil, tostring(base) end
return base
end)()
if BASE then
for k, v in pairs(BASE) do
if adapter[k] == nil then adapter[k] = v end
end
end
adapter.name = "channels-zcode"
adapter.version = "1.0.0"
-- ============================================================================
-- 身份块构造
-- ============================================================================
-- isGitRepo(cwd):只看 .git 是否存在(照参考,廉价无副作用)。
local function isGitRepo(cwd)
if type(cwd) ~= "string" or cwd == "" then return false end
local p = cwd:gsub("/+$", "") .. "/.git"
local f = io.open(p, "r")
if f then f:close() return true end
-- .git 也可能是**目录**(普通仓库),io.open 打不开目录:用 popen 判存在
local sh = io.popen and io.popen("test -e " .. p .. " && echo y")
if sh then
local r = sh:read("*a") or ""
sh:close()
if r:match("y") then return true end
end
return false
end
-- formatLocalIsoDate():**本地时区**的 ISO 日期(官方用本地日期,不是 UTC)。
local function formatLocalIsoDate()
local t = os.date("*t")
if not t then return "" end
return string.format("%04d-%02d-%02d", t.year, t.month, t.day)
end
-- buildEnvironmentSection():官方形态的 "# Environment" 段。
-- 不发它的后果:模型答不出自己是谁、不知工作目录与平台(会用相对路径瞎猜、
-- 以为该给 bash 命令)。
local function buildEnvironmentSection(cwd, provider, model, platform)
if type(cwd) ~= "string" or cwd:gsub("%s+", "") == "" then cwd = "." end
local plat = platform or "linux"
local shell = (plat == "win32") and "powershell" or "bash"
local arch = (os.getenv and os.getenv("LLMSPROXY_ARCH")) or "x64"
return table.concat({
"# Environment",
"You have been invoked in the following environment:",
" - Primary working directory: " .. cwd,
" - Is a git repository: " .. (isGitRepo(cwd) and "yes" or "no"),
" - Platform: " .. plat,
" - Shell: " .. shell,
" - OS Version: " .. plat .. " " .. arch,
" - You are powered by the model named " .. tostring(provider or "zcode") .. "/"
.. tostring(model or "glm-5.3-flash") .. ".",
}, "\n")
end
-- buildSystemBlocks(callerSystem, opts):官方形态的 system **块数组**。
-- block[1] = cliPrefix(准入必需)
-- block[2] = stable(准入必需)
-- block[3] = Environment 段
-- block[4] = 调用方 system(**必须追加在最后**:上游看的是前缀结构)
-- 只给**最后一块**打 cache_control:Anthropic 单请求最多 4 个断点,"逐块打"
-- 会用满预算而让 tools 再也打不了点(参考 2026-09-30 改为此形态)。
local function buildSystemBlocks(callerSystem, opts)
opts = opts or {}
local blocks = {
{ type = "text", text = CLI_PREFIX },
{ type = "text", text = STABLE_JOINED },
{ type = "text", text = buildEnvironmentSection(opts.cwd, opts.provider, opts.model, opts.platform) },
}
if type(callerSystem) == "string" and callerSystem:match("%S") then
blocks[#blocks + 1] = { type = "text", text = callerSystem }
end
blocks[#blocks].cache_control = { type = "ephemeral" }
return blocks
end
-- contextPrefixBlock():<system-reminder> 日期块(官方逐字形态)。
-- ⚠ 照发但**不是准入判据**(见文件头)。形态细节勿"优化":
-- 整块是**一个** text 块、插到 content **数组**最前(不是拼进文本字符串,
-- 后者会改变结构、仍被判为裸请求);outro 前有 **6 个空格**缩进。
local function contextPrefixBlock()
local body = table.concat({
"As you answer the user's questions, you can use the following context:",
"# currentDate\nToday's date is " .. formatLocalIsoDate() .. ".",
"",
" IMPORTANT: this context may or may not be relevant to your tasks. "
.. "You should not respond to this context unless it is highly relevant to your task.",
}, "\n")
return { type = "text", text = "<system-reminder>\n" .. body .. "\n</system-reminder>" }
end
-- ============================================================================
-- 覆盖 transform_request:先做 anthropic 转换,再注入身份块
-- ============================================================================
function adapter.transform_request(raw_body)
-- ★ 基座读不到时**响亮失败**,不能静默退化。
--
-- 若在这里"尽力而为"(只注入身份块、不做协议转换),会产出一个身份块看着
-- 完全正确、而工具调用/图像/流式结构全没转换的请求 —— 上游可能 200,
-- 但模型拿不到工具定义、图像丢失,症状是"模型变笨了",与准入毫无关系,
-- 排查会完全跑偏。宁可让请求在网关侧明确报错(可定位),也不要静默发出去。
if type(BASE) ~= "table" or type(BASE.transform_request) ~= "function" then
return error("channels-zcode: anthropic 基座未装载(" .. tostring(BASE_ERR) ..
")—— 协议转换无法进行,拒绝发出未转换的请求")
end
local out = raw_body
do
local ok, res = pcall(BASE.transform_request, raw_body)
if ok and type(res) == "string" then out = res end
end
if type(out) ~= "string" or out == "" then return raw_body end
local ok, req = pcall(json.decode, out)
if not ok or type(req) ~= "table" then return out end
-- anthropic.lua 产出的 system 是**纯字符串**;上游要的是**块数组**,
-- 故在此改写成块数组(上游检查的正是这个结构)。
local caller = req.system
if type(caller) == "table" then
local parts = {}
for _, b in ipairs(caller) do
if type(b) == "table" and type(b.text) == "string" then parts[#parts + 1] = b.text end
end
caller = table.concat(parts, "\n\n")
end
local opts = {}
-- cwd 必须给**真值**:参考声明「cwd is never "unknown" in real traffic」。
opts.cwd = (os.getenv and os.getenv("PWD")) or "."
opts.provider = "zcode"
opts.model = req.model
opts.platform = "linux"
req.system = buildSystemBlocks(caller, opts)
-- ★ 日期块:只处理**第一条**消息,且它必须是 role == "user"(官方行为)。
--
-- 两条容易漏掉的规则(照参考 withContextPrefix,「优化」它们会偏离官方):
-- ① **幂等**:已以 <system-reminder> 开头则不重复插。不判的话,同一请求
-- 在重试/多次 transform 时会把日期块叠成好几层,改变前缀结构;
-- ② **只看第一条**:若首条不是 user(例如 assistant 开场),**不动** ——
-- 不是"往后找一个 user 消息"。我第一版就是往后找,与官方行为不符。
local msgs = req.messages
if type(msgs) == "table" then
local first = msgs[1]
if type(first) == "table" and first.role == "user" then
local c = first.content
-- 已插过(字符串或块数组两种形态都要判)→ 不重复插。
-- ⚠ 长度必须用 #SR 算,不能写死 —— 我第一版写 `sub(1, 16)` 而
-- "<system-reminder>" 是 **17** 字符,判断**永远为假** ⇒ 幂等失效、
-- 重试时日期块叠加(判据当场抓到)。
local SR = "<system-reminder>"
local already = false
if type(c) == "string" then
already = c:sub(1, #SR) == SR
elseif type(c) == "table" and type(c[1]) == "table" then
local t = c[1].text
already = type(t) == "string" and t:sub(1, #SR) == SR
end
if not already then
local blk = contextPrefixBlock()
if type(c) == "table" then
table.insert(c, 1, blk)
elseif type(c) == "string" then
-- 纯文本 content 转成块数组(官方就是数组形态)
first.content = { blk, { type = "text", text = c } }
else
first.content = { blk }
end
end
end
end
return json.encode(req)
end
-- ★ transform_error:把 3012 判成**不可重试**并带上现场诊断。
--
-- 为什么不能只依赖通用文案:3012 有账号冷却惩罚(30 分钟起步),
-- 归成"可重试的服务端错误"会让调用方**白重试**、把冷却越推越长。
-- 诊断行(账号序号/成功失败计数/距上条/身份块实测字符数)是排查的全部现场,
-- 且**不含任何凭据片段**(只出现进程内自增序号)。
function adapter.transform_error(status, body)
local text = tostring(body or "")
if Z and Z.hasUnusualActivity(text) then
return Z.formatUnusualActivityMessage({
diagnostic = Z.formatDiagnostic({
accountId = nil,
sentAt = os.time and os.time() * 1000 or 0,
now = os.time and os.time() * 1000 or 0,
status = status,
}),
rawResponse = text:sub(1, 500),
})
end
-- 非风控:交回 anthropic 基座的通用分类
if type(BASE) == "table" and type(BASE.transform_error) == "function" then
local ok, res = pcall(BASE.transform_error, status, body)
if ok and type(res) == "string" then return res end
end
return text
end
-- 暴露给判据:身份块长度与装载状态("装了但身份块为空"这类问题没有它只能靠猜)
adapter.__identity_probe = function()
return {
base_loaded = BASE ~= nil,
base_err = BASE_ERR,
cli_prefix_len = #CLI_PREFIX,
stable_len = #STABLE_JOINED,
total_len = #CLI_PREFIX + #STABLE_JOINED,
expected_min = EXPECTED_MIN_CHARS,
}
end
return adapter
]===]
plugin.K.CLINE_ADAPTER_SRC = [==[
local adapter = { name = "channels-cline", version = "1.0.0" }
function adapter.transform_request(raw_body) return raw_body end
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
local function cline_wait_ms(text)
if type(text) ~= "string" then return nil end
local total, matched = 0, false
-- 单位按**首字母**判(h/m/s 互不重叠),故一次扫描即可,不会重复计数。
for num, unit in text:gmatch("(%d+)%s*([hms])[a-z]*") do
local n = tonumber(num)
if n then
local u = unit:lower()
if u == "h" then total = total + n * 3600000
elseif u == "m" then total = total + n * 60000
else total = total + n * 1000 end
matched = true
end
end
if not matched then return nil end
return total
end
-- 文案里出现这些标记 ⇒ 是"当日免费额度"而非普通限流。
-- 小写比对(照参考 CLINE_DAILY_FREE_MARKERS)。
local function cline_is_daily_free(text)
local low = string.lower(text or "")
return low:find("daily free limit", 1, true) ~= nil
or low:find("free daily limit", 1, true) ~= nil
or low:find("daily limit reached", 1, true) ~= nil
end
-- 从报文里取文案;必须认多种外壳(`{error:{message}}` / `{error:"…"}` /
-- `{message}` / `{msg}` / 非 JSON 纯文本)。
local function cline_message(body)
local okp, rec = pcall(json.decode, body or "")
if not okp or type(rec) ~= "table" then return tostring(body or "") end
local e = rec.error
if type(e) == "string" and e ~= "" then return e end
if type(e) == "table" and type(e.message) == "string" and e.message ~= "" then
return e.message
end
if type(rec.message) == "string" and rec.message ~= "" then return rec.message end
if type(rec.msg) == "string" and rec.msg ~= "" then return rec.msg end
return ""
end
-- fmt_duration(ms):人话时长("约 19 小时 39 分")。
local function fmt_duration(ms)
if ms == nil or ms <= 0 then return "" end
local h = math.floor(ms / 3600000)
local m = math.floor((ms % 3600000) / 60000)
if h > 0 then return "约 " .. h .. " 小时 " .. m .. " 分" end
if m > 0 then return "约 " .. m .. " 分" end
return "不到 1 分"
end
function adapter.transform_error(status, body)
local msg = cline_message(body)
local wait = cline_wait_ms(msg)
-- 当日免费额度用尽:等下去才会恢复(**不是**普通限流)。
-- ⚠️ 必须把话**说清楚**:内核按关键词识别"额度类"并据此冷却到窗口边界,
-- 若这里只说"rate limited",内核会用 30 秒短冷却 ⇒ 每 30 秒猛敲一次上游。
if cline_is_daily_free(msg) then
local when = wait and ("," .. fmt_duration(wait) .. " 后重置") or ""
-- "额度已用完" 是内核已认的中立短语(quotaKeywords)⇒ 走窗口冷却而非 30s。
return "额度已用完:" .. tostring(status) .. " 当日免费额度已用尽(HTTP "
.. tostring(status) .. ")" .. when .. ",重试无用"
end
-- 402:等下去不会恢复(要充值)
if status == 402 then
return "额度已用完:Insufficient credits(HTTP 402)—— 等待不会恢复,需充值"
end
-- 其它 429:有明确等待时长就如实说(内核仍按普通限流短冷却,但用户知道要等多久)
if status == 429 and wait and wait > 0 then
return "限流(HTTP 429)" .. fmt_duration(wait) .. "后可重试"
end
return __prelude.openai_error(status, body)
end
-- 导出给判据用(不是内核契约的一部分)。
adapter.cline_wait_ms = cline_wait_ms
return adapter
]==]
local function installClineAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(plugin.K.CLINE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-cline", plugin.K.CLINE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-cline", plugin.K.CLINE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
local function installQoderAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(plugin.K.QODER_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-qoder", plugin.K.QODER_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-qoder", plugin.K.QODER_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
local function installGeminiAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(plugin.K.GEMINI_PRIVATE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-gemini", plugin.K.GEMINI_PRIVATE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
local function installZcodeAdapter(dirOverride)
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local adir = dirOverride or plugin.adaptersDir()
if not adir then return false, "无法推导适配器目录" end
local af = io.open(adir .. "/anthropic.lua", "r")
if not af then return false, "读不到 anthropic.lua(" .. adir .. ")" end
local asrc = af:read("*a"); af:close()
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
"\nend)()\n" .. ZCODE_ADAPTER_SRC
local ph = "__INLINE_ANTHROPIC_SRC__"
local qi = full:find(ph, 1, true)
if not qi then return false, "适配器源码缺少基座占位符" end
full = full:sub(1, qi - 1) .. string.format("%q", asrc) .. full:sub(qi + #ph)
local f, lerr = load(full)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-zcode", full)
if ierr then return false, ierr end
end
return true, nil
end
local function installCodeartsAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(plugin.K.CODEARTS_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("codearts", plugin.K.CODEARTS_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("codearts", plugin.K.CODEARTS_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
function plugin.__codearts_adapter_sign_probe(args)
args = args or {}
local f = load(plugin.K.CODEARTS_ADAPTER_SRC)
if not f then return { json = { error = "适配器源码无法编译" } } end
local adapter = f()
local meta = {
url = args.url, method = args.method or "POST",
body = args.body or "", api_key = args.api_key or "",
}
local h = adapter.build_headers(meta)
local out = { authorization = h["Authorization"] }
for k, v in pairs(h) do out["hdr_" .. k] = v end
local cred = nil
local raw = tostring(meta.api_key or "")
if raw:sub(1, 1) == "{" then
local ok2, rec = pcall(json.decode, raw)
if ok2 and type(rec) == "table" then cred = rec end
end
if cred and adapter.sign_parts then
local _, canon, sts = adapter.sign_parts(meta.method, meta.url, cred, meta.body)
out.canonical = canon
out.string_to_sign = sts
end
return { json = out }
end
function plugin.__codearts_cred_probe(args)
local refs = {}
local list = plugin.account_list({ platform = "codearts" })
local j = list and list.json
for _, a in ipairs((j and j.accounts) or {}) do
if a.credentialRef then refs[#refs + 1] = a.credentialRef end
end
refs[#refs + 1] = "channels:codearts"
for _, ref in ipairs(refs) do
local cred = credentialFull(ref)
if type(cred) == "table" then
return { json = {
ref = ref,
access_key_id = cred.access_key_id,
refresh_token = cred.refresh_token,
code_verifier = cred.code_verifier,
dpop_private_key_jwk = cred.dpop_private_key_jwk,
} }
end
end
return { json = { error = "未找到 codearts 凭据", refs = refs } }
end
function plugin.__codearts_dpop_probe(args)
args = args or {}
local key, kerr = codeartsNewDpop()
if not key then return { json = { error = tostring(kerr) } } end
local jws, serr = codeartsDpopSign(key, tostring(args.htm or "POST"),
tostring(args.htu or "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"))
if not jws then return { json = { error = tostring(serr) } } end
return { json = { jws = jws } }
end
function plugin.__qoder_refresh_probe(args)
local ms = tonumber((type(args) == "table" and args.now_ms) or 0) or 0
return { json = { refreshed = qoderCampaignRefreshed(ms) } }
end
function plugin.__qoder_machine_probe(args)
local ph = nil
for _, p in ipairs(PLATFORMS) do
if p.id == "qoder" then ph = p break end
end
if not ph or not ph.headers_fn then return { json = { error = "qoder headers_fn 缺失" } } end
local h = ph.headers_fn("TOK", {})
return { json = { token = h["Cosy-MachineToken"], type = h["Cosy-MachineType"],
client_type = h["Cosy-ClientType"] } }
end
function plugin.__codearts_sign_probe(args)
args = args or {}
local date = tostring(args.date or "")
local saved = codeartsDateStamp
if date ~= "" then codeartsDateStamp = function() return date end end
local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""),
{ access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""),
security_token = tostring(args.st or "") }, tostring(args.body or ""))
codeartsDateStamp = saved
if not req then return { json = { error = err } } end
return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign,
signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign),
signed_headers = req.signed_headers,
authorization = req.headers["Authorization"] } }
end
function plugin.__trae_derive_probe(args)
local uid = tostring((type(args) == "table" and args.uid) or "")
return { json = { devid = traeDeriveDeviceId15(uid),
market = traeDeriveMarketUserId(uid),
sess = traeDeriveSessionId(uid) } }
end
plugin.on_unload = function()
reapBrowser()
for id in pairs(plugin.state.loopbacks or {}) do
reapLoopback(id)
end
end
plugin.ui = {
page = {
page_id = "channels",
title = "账号接入",
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.6"/><circle cx="18" cy="6" r="2.6"/><circle cx="12" cy="18" r="2.6"/><path d="M8.2 7.6 10.6 15.4M15.8 7.6 13.4 15.4M8.6 6h6.8"/></svg>]==],
order = 40,
mount = [[
<div id="channels-page">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">渠道控制台</h2>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<input id="ch-search" placeholder="搜索渠道或供应商…" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);min-width:200px">
<div id="ch-filters" style="display:flex;gap:6px"></div>
<button class="small" onclick="chLedger()">Token 用量</button>
<button class="small" onclick="chBackup(1)">导出备份</button>
<button class="small" onclick="document.getElementById('ch-import-file').click()">导入备份</button>
<input type="file" id="ch-import-file" hidden accept=".json,application/json" onchange="chImportFile(event)">
<label style="display:flex;gap:6px;align-items:center;font-size:12px">
<input type="checkbox" id="ch-auto" onchange="chAutoToggle(this.checked)"> 每日自动签到
</label>
</div>
</div>
<div id="ch-summary" style="display:flex;gap:18px;margin:10px 0;font-size:13px;color:var(--muted,#888)"></div>
<div id="ch-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px"></div>
</div>
<!-- 渠道详情:账号池 + 模型开关(对齐参考 provider-detail) -->
<div class="card" id="ch-detail" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center">
<button class="small" onclick="chSelect(undefined)">← 返回全部渠道</button>
<span class="muted" style="font-size:12px">账号凭据加密保存在服务端,页面永远拿不到明文。</span>
</div>
<h2 id="ch-detail-name" style="margin:10px 0 4px"></h2>
<p class="muted" id="ch-detail-org" style="margin:0 0 12px;font-size:13px"></p>
<div id="ch-detail-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"></div>
<p id="ch-notice" class="muted" style="font-size:13px;min-height:18px"></p>
<div id="ch-accts"></div>
<div style="display:flex;gap:8px;align-items:center;margin:14px 0 6px">
<b style="font-size:13px">模型</b>
<span id="ch-model-count" class="muted" style="font-size:12px"></span>
<button class="small" onclick="chModelAll(false)">全部启用</button>
<button class="small" onclick="chModelAll(true)">全部关闭</button>
<button class="small" onclick="chModelRefresh()">从上游拉取</button>
<span id="ch-model-src" class="muted" style="font-size:11px"></span>
</div>
<div id="ch-models" style="display:flex;flex-direction:column;gap:4px"></div>
<!-- 登录区:发起登录后显示(device-poll 轮询 / callback URL / vnc / paste / manual) -->
<div id="ch-login-box" style="display:none;margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<h3 style="margin:4px 0">登录</h3>
<div id="ch-login-body"></div>
<div id="ch-vnc-wrap" style="display:none;margin-top:10px">
<p class="muted" style="font-size:12px">请在下面的窗口里完成登录,完成后本页会自动继续。</p>
<iframe id="ch-vnc" style="width:100%;height:640px;border:1px solid var(--line);border-radius:8px" allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<!-- 运行日志:**全局**,不是某个渠道的。
★ 为什么必须独立成卡片(而不是留在上面「点开某渠道才显示」的渠道详情里):
日志源 plugin.state.log 是**单一全局数组**(logEvent 写入的条目原先连
platform 字段都没有),把它渲染在渠道详情卡里属于**归属错误** ——
用户点开 buddy,看到的却是 trae/minimax/qoder 的日志混在一起,无从区分。
现在:本卡片常驻;条目带 platform 时按当前选中渠道过滤,不带 platform 的
全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
</div>
<div class="card" id="ch-log-card">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<b style="font-size:15px">运行日志</b>
<span class="grow"></span>
<label class="muted" style="font-size:12px;display:inline-flex;align-items:center;gap:5px;white-space:nowrap">
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()" style="margin:0"> 显示例行任务
</label>
<select id="ch-log-level" onchange="chLogRender()"
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
<option value="all">全部级别</option>
<option value="warn">警告及以上</option>
<option value="error">仅错误</option>
</select>
</div>
<div class="muted" style="font-size:12px;margin:8px 0" id="ch-log-hint"></div>
<!-- ★ 文本**允许换行**(word-break),否则长诊断在窄卡片里被截断,
而截断的尾巴正是最该看到的部分(如 HTTP 状态/具体原因)。 -->
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto;word-break:break-word;line-height:1.5"></ul>
</div>
</div>
</div>
<script>
(function () {
const q = (s) => document.querySelector(s);
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
async function call(path, method, body) {
const r = await fetch("/api/plugins/http/" + path, {
method: method || "GET",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
return await r.json().catch(() => ({}));
}
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
let state = { statuses: [], filter: "all", search: "", selected: undefined,
accounts: undefined, models: undefined, login: null, pollTimer: null,
// 日志:原始条目 + 用户的两个视图开关(级别门槛、是否显示例行任务)
log: [], logLevel: "all", logVerbose: false };
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
// ── 运行日志的渲染(级别门槛 + 例行任务折叠 + 按渠道过滤)──
//
// 三个过滤维度,各自解决一个实测问题:
// ① **级别**:`task` 是定时任务的常规结果。实测 50 条里 48 条(96%)都是它,
// 默认**不显示**,否则真正的用户动作被淹没。warn/error 永远显示。
// ② **按渠道**:条目带 `platform` 时只在对应渠道视图显示。这是因为
// 日志本身是全局的(单一数组),若不加区分,点开 buddy 会看到 trae 的事。
// 不带 platform 的条目(跨平台汇总,如"模型目录已刷新(N 个平台)")
// 只在「全部渠道」视图出现 —— 它们本来就不属于任何单一渠道。
// ③ **同文折叠计数**:后端把紧邻同文合并成一条并给 n>1(见 logEvent),
// 这里显示成 `×N`,既保留"重复了多少次"的信息又不刷屏。
function chLogRender() {
const ul = q("#ch-log"); if (!ul) return;
const verbose = q("#ch-log-verbose");
const sel = q("#ch-log-level");
state.logVerbose = verbose ? verbose.checked : state.logVerbose;
state.logLevel = sel ? sel.value : state.logLevel;
const rank = { info: 1, task: 1, warn: 2, error: 3 };
const minRank = state.logLevel === "error" ? 3 : state.logLevel === "warn" ? 2 : 1;
const selPlat = state.selected;
const rows = (state.log || []).filter((e) => {
const lvl = e.level || "info";
// 「显示例行任务」勾上时,task 视同 info 一并显示
const eff = (lvl === "task" && state.logVerbose) ? "info" : lvl;
if ((rank[eff] || 1) < minRank) return false;
// 渠道过滤:选中某渠道时,只显示归属于它、或没有归属的全局条目?
// 不 —— 选中渠道时应**只**显示该渠道的条目,否则"点 buddy 看到 trae"的
// 归属错误会以另一种形式回来。全局条目留给「全部渠道」视图。
if (selPlat) return e.platform === selPlat;
return true;
});
const hidden = (state.log || []).length - rows.length;
const hint = q("#ch-log-hint");
if (hint) {
const parts = [];
parts.push(selPlat ? ("当前渠道:" + esc(selPlat)) : "全部渠道");
if (!state.logVerbose) parts.push("例行任务已折叠");
if (hidden > 0) parts.push("另有 " + hidden + " 条被过滤");
hint.innerHTML = parts.join(" · ");
}
ul.innerHTML = rows.map((e) => {
const lvl = e.level || "info";
const ts = e.at ? new Date(e.at * 1000).toLocaleString() : "";
const badge = lvl === "error" ? '<b style="color:#e5534b">错误</b> '
: lvl === "warn" ? '<b style="color:#d29922">警告</b> '
: lvl === "task" ? '<span class="muted">例行</span> ' : "";
const n = (e.n || 1) > 1 ? ' <span class="muted">×' + e.n + "</span>" : "";
const del = String(e.msg || "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
return '<li style="margin-bottom:3px">' + badge +
'<span class="muted" style="font-size:11px">' + ts + "</span> " + del + n + "</li>";
}).join("") || '<li class="muted">(无匹配日志)</li>';
}
window.chLogRender = chLogRender;
async function loadStatuses() {
const j = await call("channels/provider/status", "POST", {});
state.statuses = j.statuses || [];
renderSummary(); renderGrid();
const sj = await call("channels/status");
state.log = sj.log || [];
chLogRender();
const aj = await call("channels/auto_checkin", "POST", {});
q("#ch-auto").checked = aj.autoCheckin !== false;
}
function renderSummary() {
const total = state.statuses.length;
const connected = state.statuses.filter((s) => !s.closed && s.accounts.enabled > 0).length;
const accounts = state.statuses.reduce((n, s) => n + (s.accounts.total || 0), 0);
q("#ch-summary").innerHTML =
"<span>已接入渠道 <b>" + connected + " / " + total + "</b></span>" +
"<span>本机账号 <b>" + accounts + "</b></span>";
const counts = { all: total, connected, pending: total - connected };
const defs = [{ id: "all", label: "全部" }, { id: "connected", label: "已接入" }, { id: "pending", label: "待接入" }];
q("#ch-filters").innerHTML = defs.map((d) =>
'<button class="small" data-f="' + d.id + '" style="' + (state.filter===d.id?"font-weight:bold;":"") + '" onclick="chFilter(\'' + d.id + '\')">' + d.label + ' (' + counts[d.id] + ')</button>').join("");
}
function renderGrid() {
const list = state.statuses.filter((s) => {
const hit = (s.id + " " + s.name).toLowerCase().includes(state.search);
const on = !s.closed && s.accounts.enabled > 0;
return hit && (state.filter === "all" || (state.filter === "connected") === on);
});
q("#ch-grid").innerHTML = list.map((s) => {
const on = !s.closed && s.accounts.enabled > 0;
const st = s.closed ? '<span class="tag">已关闭</span>'
: on ? '<span class="tag tag-green">已接入</span>' : '<span class="tag">待接入</span>';
return '<div class="card" style="margin:0;padding:12px;cursor:pointer" onclick="chSelect(\'' + esc(s.id) + '\')">' +
'<div style="display:flex;justify-content:space-between;align-items:center">' +
'<b>' + esc(s.name) + '</b>' + st + '</div>' +
'<div class="muted" style="font-size:12px;margin:6px 0">' + esc(s.id) + ' · ' + esc(s.flow) + '</div>' +
'<div style="font-size:12px">账号 <b>' + (s.accounts.enabled||0) + '/' + (s.accounts.total||0) + '</b>' +
' · 模型 <b>' + ((s.models.total||0)-(s.models.disabled||0)) + '/' + (s.models.total||0) + '</b></div>' +
'</div>';
}).join("") || '<p class="muted">没有匹配的渠道</p>';
}
async function renderDetail() {
const id = state.selected;
const box = q("#ch-detail");
if (!id) { box.style.display = "none"; q("#ch-grid").parentElement.style.display = "block"; return; }
box.style.display = "block";
q("#ch-grid").parentElement.style.display = "none";
const s = state.statuses.find((x) => x.id === id) || {};
q("#ch-detail-name").textContent = s.name || id;
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
// ★ 切渠道立即清空旧渠道的账号/模型/余额状态。
// 此前不清:renderAccounts/renderModels 是异步的,await 前的空窗里 DOM 仍挂着
// 上一个渠道的账号行(实测:从 qoder 切到 opencode,qoder 的账号带着
// "上次领取 · 未开通"文案闪现在 opencode 卡片下,被当成 opencode 的账号)。
// 账号行还挂操作按钮(测试/删除),误点会打到错误平台。
state.accounts = []; state.models = [];
q("#ch-accts").innerHTML = '<p class="muted" style="font-size:13px">加载中…</p>';
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
const acts = [];
acts.push('<button class="small" onclick="chStartLogin()">添加账号(登录)</button>'
+ (["buddy","qoder"].includes(id)
? ' <button class="small" title="登录国际区(WorkBuddy / qoder.com)" onclick="chStartLogin(\'intl\')">+国际区</button>'
: ''));
if ((s.accounts.total||0) > 0) acts.push('<button class="small" onclick="chRefreshAll()">刷新全部账号</button>');
acts.push('<button class="small" onclick="chClaimAll()">一键领取额度</button>');
acts.push('<button class="small" onclick="chResetAll()">重置限流标记</button>');
acts.push('<button class="small" onclick="chClearDead()">清死模型</button>');
acts.push('<button class="small" onclick="chProviderToggle(' + (!s.closed) + ')">' + (s.closed ? "启用渠道" : "关闭渠道") + '</button>');
q("#ch-detail-actions").innerHTML = acts.join(" ");
q("#ch-notice").textContent = "";
await Promise.all([renderAccounts(id), renderModels(id)]);
}
async function renderAccounts(id) {
const j = await call("channels/account/list", "POST", { platform: id });
// 竞态防护:await 期间用户可能已切到别的渠道,旧响应不得覆盖新渲染
if (state.selected !== id) return;
const accts = j.accounts || [];
state.accounts = accts;
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
let balances = {};
try {
const bj = await call("channels/credits/balances", "POST", { platform: id });
for (const row of (bj.accounts || [])) balances[row.accountId] = row;
} catch {}
q("#ch-accts").innerHTML = accts.length === 0
? '<p class="muted" style="font-size:13px">暂无账号。点「添加账号(登录)」开始。</p>'
: accts.map((a) => {
const badge = a.has_credential
? (a.enabled !== false ? '<span class="tag tag-green">启用</span>' : '<span class="tag">停用</span>')
: '<span class="tag">待凭据</span>';
const claimTxt = a.last_claim ? ('上次领取 ' + new Date(a.last_claim*1000).toLocaleString() + (a.last_claim_summary ? ' · ' + esc(a.last_claim_summary) : '')) : '';
const b = balances[a.id];
const balTxt = !b ? '' : b.balance && typeof b.balance.total === 'number'
? '<span class="tag tag-green">' + esc(b.balance.unit || 'credits') + ' ' + b.balance.total + '</span>'
: '<span class="muted" title="' + esc(b.error || '') + '" style="font-size:12px">余额不可用</span>';
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line);flex-wrap:wrap">' +
'<b style="min-width:120px">' + esc(a.nickname || a.id) + '</b>' + badge + balTxt +
'<span class="muted" style="font-size:12px">' + claimTxt + '</span>' +
'<span style="flex:1"></span>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'test\')">测试</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'refresh\')">刷新</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'toggle\')">' + (a.enabled !== false ? "停用" : "启用") + '</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'priority\')">优先</button>' +
'<button class="small" onclick="chAcct(\'' + esc(a.id) + '\',\'delete\')">删除</button>' +
'</div>';
}).join("");
}
window.chModelRefresh = async () => {
const id = state.sel && state.sel.id;
if (!id) return;
const r = await call("channels/model/refresh", "POST", { platform: id });
const res = (r.results || [])[0] || {};
if (res.ok) alert("已从上游拉取 " + res.count + " 个模型");
else alert("拉取失败:" + (res.error || "未知错误") + "(保留既有目录)");
await renderModels(id);
};
async function renderModels(id) {
const j = await call("channels/model/list", "POST", { platform: id });
const ms = j.models || [];
state.models = ms;
const on = ms.filter((m) => !m.disabled).length;
q("#ch-model-count").textContent = on + "/" + ms.length + " 启用";
// 目录来源:远端拉到的目录以远端为准、兜底补位;没拉过则只有兜底。
const src = q("#ch-model-src");
if (src) {
if (j.remote) {
const t = j.fetched_at ? new Date(j.fetched_at * 1000).toLocaleString() : "";
src.textContent = "上游目录(" + t + ")";
} else {
src.textContent = "内置兜底目录(未从上游拉取)";
}
}
q("#ch-models").innerHTML = ms.map((m) =>
'<div style="display:flex;gap:8px;align-items:center;padding:4px 0">' +
'<span style="flex:1;font-size:13px" title="' + esc(m.id) + '">' + esc(m.name || m.id) + '</span>' +
'<button class="small" onclick="chModel(\'' + esc(m.id) + '\',' + (!m.disabled) + ')">' + (m.disabled ? "开启" : "关闭") + '</button>' +
'</div>').join("") || '<p class="muted">—</p>';
}
// ---- 动作 ----
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
window.chSelect = async (id) => {
state.selected = id;
await renderDetail();
// 切渠道要重渲染日志:日志条目带 platform,视图按当前选中渠道过滤
// (不重渲染的话,点开 buddy 仍显示上一个渠道的日志)。
chLogRender();
};
window.chAcct = async (aid, act) => {
const id = state.selected; if (!id) return;
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
if (act === "toggle") {
const a = (state.accounts || []).find((x) => x.id === aid);
await call("channels/account/update", "POST", { platform: id, account_id: aid, patch: { enabled: !(a && a.enabled !== false) } });
} else if (act === "priority") {
await call("channels/account/reorder", "POST", { platform: id, ordered_ids: [aid].concat((state.accounts||[]).map((x)=>x.id).filter((x)=>x!==aid)) });
} else {
await call("channels/account/" + act, "POST", { platform: id, account_id: aid });
}
await renderAccounts(id); await loadStatuses();
};
window.chModel = async (mid, disabled) => {
const id = state.selected; if (!id) return;
await call("channels/model/set_disabled", "POST", { platform: id, model_id: mid, disabled });
await renderModels(id); await loadStatuses();
};
window.chModelAll = async (disabled) => {
const id = state.selected; if (!id) return;
await call("channels/model/set_all_disabled", "POST", { platform: id, disabled });
await renderModels(id); await loadStatuses();
};
window.chProviderToggle = async (enable) => {
const id = state.selected; if (!id) return;
if (!enable && !confirm("关闭渠道会停用全部账号与模型,确认?")) return;
await call("channels/provider/set_enabled", "POST", { platform: id, enabled: enable });
await loadStatuses(); await renderDetail();
};
window.chRefreshAll = async () => {
const id = state.selected; if (!id) return;
for (const a of state.accounts || []) await call("channels/account/refresh", "POST", { platform: id, account_id: a.id });
notice("已刷新全部账号"); await renderAccounts(id);
};
window.chClaimAll = async () => {
const id = state.selected; if (!id) return;
notice("领取中…");
const j = await call("channels/credits/claim_all", "POST", { platform: id });
const res = j.results || [];
const okN = res.filter((r) => r.outcome && r.outcome.kind === "success").length;
const failN = res.filter((r) => r.outcome && r.outcome.kind === "failed");
notice("领取完成:成功 " + okN + " 个账号" + (failN.length ? ";失败:" + failN.map((r) => r.outcome.message).join(";") : ""));
await renderAccounts(id);
};
window.chAutoToggle = async (on) => { await call("channels/auto_checkin", "POST", { enabled: on }); notice(on ? "自动签到已开启" : "自动签到已关闭"); };
window.chResetAll = async () => {
const id = state.selected; if (!id) return;
await call("channels/account/reset", "POST", { platform: id, account_id: "__all__" });
notice("已重置全部账号限流标记");
};
window.chClearDead = async () => {
const id = state.selected; if (!id) return;
const j = await call("channels/model/clear_dead", "POST", { platform: id });
const removed = (j.removed || []).length;
notice(removed ? ("已清理 " + removed + " 个死模型") : "没有死模型");
await renderModels(id);
};
// 用量快照(usage.tokenLedger 对齐参考):弹窗展示 prompt/completion/请求数
window.chLedger = async () => {
const j = await call("channels/usage/token_ledger", "POST", { limit: 100 });
const s = j.snapshot || {};
if (!s.available) { alert("用量不可用:" + (s.error || "未知")); return; }
alert("本网关 token 用量\nPrompt: " + (s.tokens?.prompt ?? 0) + "\nCompletion: " + (s.tokens?.completion ?? 0) +
"\nTotal: " + (s.tokens?.total ?? 0) + "\nRequests: " + (s.requests ?? 0));
};
window.chBackup = async () => {
const j = await call("channels/backup/export", "POST", {});
const blob = new Blob([JSON.stringify(j.payload, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "channels-backup-" + new Date().toISOString().slice(0,10) + ".json";
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
};
window.chImportFile = async (ev) => {
const f = ev.target.files[0]; ev.target.value = ""; if (!f) return;
if (f.size > 8*1024*1024) { alert("备份文件不能超过 8MB"); return; }
if (!confirm("导入会合并备份中的账号凭据、模型开关与自动签到设置,确认?")) return;
const payload = JSON.parse(await f.text());
const j = await call("channels/backup/import", "POST", { payload });
alert(j.error ? ("导入失败:" + j.error) : ("已导入,恢复 " + (j.restored||0) + " 条凭据"));
await loadStatuses();
};
function notice(msg) { q("#ch-notice").textContent = msg; }
// ---- 登录(对齐参考两步式:立即拿 URL/弹窗,后台轮询完成)----
window.chStartLogin = async (region) => {
const id = state.selected; if (!id) return;
// ★ 双区:按钮按住 Shift 点 = 登录国际区(buddy→WorkBuddy / qoder→qoder.com)。
// 简单可靠:不需要为双区改版式,一行 title 提示即可。默认国内/国际为主区。
const r = (region === "intl") ? "intl" : "cn";
// 池路径:先建占位账号,登录完成后回填凭据到这个账号
const aj = await call("channels/account/add", "POST", { platform: id });
if (aj.error) { alert(aj.error); return; }
const aid = aj.account_id;
const j = await call("channels/login", "POST", { platform: id, account_id: aid, region: r });
if (j.error) { alert(j.error); return; }
const box = q("#ch-login-box"); box.style.display = "block";
const vnc = q("#ch-vnc-wrap");
if (j.view_url) {
q("#ch-login-body").innerHTML = "<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
vnc.style.display = "block"; q("#ch-vnc").src = j.view_url;
} else if (j.manual_hint) {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p class='muted'>" + esc(j.manual_hint) + "</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='把凭据粘贴到这里'></textarea></p>" +
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else if (j.session && j.session.flow === "paste") {
vnc.style.display = "none";
q("#ch-login-body").innerHTML =
"<p>把该平台的 API key / token 粘贴到这里:</p>" +
"<p><textarea id='ch-cred' rows='3' style='width:100%' placeholder='API key / token'></textarea></p>" +
"<p><button class='small' onclick='chPaste(\"" + esc(aid) + "\")'>提交凭据到该账号</button></p>" +
"<p class='muted' id='ch-login-state'></p>";
} else {
vnc.style.display = "none";
// 扫码类流程(qr-poll)的地址是「用微信扫」的落地页,直接点开也能拿到
// 页面里的二维码,故文案要说清楚,别让用户以为点开就自动登录了。
const isQr = !!(j.session && j.session.flow === "qr-poll");
q("#ch-login-body").innerHTML =
"<p>" + (isQr
? "用<b>微信扫码</b>登录(本机点开下面地址,页面里也有二维码):"
: "请在你自己的浏览器打开下面的地址完成登录:") + "</p>" +
"<p><a href='" + esc(j.url) + "' target='_blank' rel='noopener'>" + esc(j.url) + "</a></p>" +
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
"<p class='muted' id='ch-login-state'>等待完成…</p>";
}
stopPoll();
if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) {
state.pollTimer = setInterval(async () => {
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state + (st.account ? "(" + st.account + ")" : "");
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
box.style.display = "none";
if (st.state === "failed") notice("登录未完成:" + (st.message || st.state));
await renderAccounts(id); await loadStatuses();
}
}, 2000);
}
};
// paste / manual 的凭据提交:落到占位账号(池路径),完成即同步源
window.chPaste = async (aid) => {
const id = state.selected; if (!id || !aid) return;
const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim();
if (!cred) { alert("凭据为空"); return; }
const el = q("#ch-login-state"); if (el) el.textContent = "提交中…";
const j = await call("channels/login/complete", "POST", { platform: id, credential: cred, account_id: aid });
if (j.error) { if (el) el.textContent = "失败:" + j.error; return; }
q("#ch-login-box").style.display = "none"; q("#ch-cred").value = "";
notice("账号已接入:" + aid);
await renderAccounts(id); await loadStatuses();
};
loadStatuses();
})();
</script>
]],
},
}
function plugin.__store_dir_probe()
return { json = { store_dir = storeDir() } }
end
function plugin.__set_loopback_probe(args)
args = args or {}
local sid = tostring(args.session_id or "")
local file = tostring(args.file or "")
if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end
if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end
local cur = plugin.state.loopbacks[sid]
if type(cur) ~= "table" then cur = {} end
cur.file = file
plugin.state.loopbacks[sid] = cur
return { json = { ok = true, session_id = sid, file = file, kept_pid = cur.pid or "" } }
end
function plugin.term_error_kind(msg)
if type(msg) ~= "string" or msg == "" then return nil end
local low = msg:lower()
if low:find("insufficient", 1, true) or low:find("余额不足", 1, true)
or low:find("请充值", 1, true) or low:find("insufficient_balance", 1, true)
or low:find("额度已用完", 1, true) then
return "余额不足(需充值)"
end
if low:find("banned", 1, true) or low:find("封禁", 1, true)
or low:find("账号已停用", 1, true) then
return "账号被封禁/停用"
end
if low:find("refresh token has been used", 1, true)
or low:find("invalid_grant", 1, true) then
return "凭据已永久失效(需重新登录)"
end
return nil
end
function plugin.mark_account_term_error(provider, kind)
local plat = platformById(provider)
if not plat then return end
local cur = plugin.state.chosen and plugin.state.chosen[provider]
if not cur then return end
local acct = poolGet(provider, cur)
if not acct then return end
if acct.termErr == kind then return end
acct.termErr = kind
acct.termErrAt = os.time and os.time() or 0
poolAdd(provider, acct)
markDirty()
logEvent("账号 " .. tostring(cur) .. " 标记失效:" .. tostring(kind)
.. " —— 下次选号将跳过它", "warn", provider)
end
plugin.hooks = { request_end = "on_request_end" }
function plugin.on_request_end(payload)
if type(payload) ~= "table" then return nil end
local src = tostring(payload.source or "")
if src:sub(1, 9) ~= "channels-" then return nil end
local provider = src:sub(10)
local cur = plugin.state.chosen and plugin.state.chosen[provider]
if not cur then return nil end
local acct = poolGet(provider, cur)
if not acct then return nil end
if payload.ok then
if acct.termErr then
acct.termErr = nil
acct.termErrAt = nil
poolAdd(provider, acct)
markDirty()
logEvent("账号 " .. tostring(cur) .. " 恢复正常(请求成功)", "info", provider)
end
return nil
end
local msg = tostring(payload.error or payload.message or payload.error_message or "")
local kind = plugin.term_error_kind(msg)
if kind then plugin.mark_account_term_error(provider, kind) end
if msg ~= "" then
local modelId = tostring(payload.model or payload.model_id or "")
if modelId ~= "" and isModelGone(msg) then
local isnew = recordDeadModel(provider, modelId, msg)
if isnew then
logEvent("模型已失效,已标记 " .. provider .. "/" .. modelId, "warn", provider)
markDirty()
end
end
end
return nil
end
function plugin.credits_tier_probe(args)
args = args or {}
local provider = tostring(args.platform or "")
local accountId = tostring(args.account_id or "")
local raw = accountBalanceRaw(provider, accountId)
if raw == nil then return { json = { error = "余额不可用" } } end
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
local allowPermanent = not lockPermanent
local tier = balanceTier(raw.expiring_balance, raw.permanent_balance, allowPermanent)
return { json = {
expiring = raw.expiring_balance or 0,
permanent = raw.permanent_balance or 0,
tier = tier,
usable = tierUsable(tier),
locked = lockPermanent and true or false,
window_days = expiringWindowDays(),
} }
end
function plugin.__set_lock_permanent_probe(args)
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.lock_permanent_credits = (args or {}).lock and true or false
return { json = { ok = true, locked = plugin.state.settings.lock_permanent_credits } }
end
function plugin.__set_expiring_window_probe(args)
local d = tonumber((args or {}).days)
if d == nil then return { json = { error = "缺 days" } } end
plugin.state.settings = plugin.state.settings or {}
plugin.state.settings.expiring_window_days = d
ttlCacheInvalidate("balance")
return { json = { ok = true, days = expiringWindowDays() } }
end
function plugin.__cred_headers_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local cred = {}
local okc, dec = pcall(json.decode, tostring(args.credential or ""))
if okc and type(dec) == "table" then cred = dec end
local tok = cred.access_token or ""
local out = {}
for _, which in ipairs({ "checkin_status", "balance" }) do
local spec = plat[which]
if spec then
local h = nil
local hfn = spec.headers_fn or plat.headers_fn
if hfn then
local okf, got = pcall(hfn, tok, cred)
if okf and type(got) == "table" then h = got end
end
if not h then h = { Authorization = "Bearer " .. tok, ["Content-Type"] = "application/json" } end
out[which] = h
end
end
return { json = out }
end
function plugin.__set_pool_cred_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local ref = "channels:" .. plat.id .. ":" .. tostring(args.account_id or "")
local _, serr = __llmsproxy_secrets.set(ref, tostring(args.credential or ""))
if serr then return { json = { error = serr } } end
return { json = { ok = true, ref = ref } }
end
function plugin.__set_legacy_cred_probe(args)
args = args or {}
local ref = "channels:" .. tostring(args.platform or "")
__llmsproxy_secrets.set(ref, tostring(args.credential or ""))
return { json = { ok = true, ref = ref } }
end
function plugin.__read_cred_probe(args)
args = args or {}
local v = __llmsproxy_secrets.get(tostring(args.ref or ""))
return { json = { credential = v, len = v and #v or 0 } }
end
function plugin.__clear_all_creds_probe()
local ks = __llmsproxy_secrets.keys and __llmsproxy_secrets.keys() or {}
for _, k in ipairs(ks) do __llmsproxy_secrets.delete(k) end
return { json = { ok = true } }
end
plugin.state.ttlProbeLoads = plugin.state.ttlProbeLoads or {}
function plugin.__ttl_reset_probe()
plugin.state.ttlProbeLoads = {}
plugin.state.ttlCache = {}
plugin.state.ttlInflight = {}
plugin.state.serialLock = {}
return { json = { ok = true } }
end
function plugin.__ttl_probe(args)
args = args or {}
local ns, k = tostring(args.ns or ""), tostring(args.key or "")
local ttl = tonumber(args.ttl) or 60
local lk = ns .. ":" .. k
local v = ttlCacheGet(ns, k, ttl, function()
plugin.state.ttlProbeLoads[lk] = (plugin.state.ttlProbeLoads[lk] or 0) + 1
return "v" .. tostring(plugin.state.ttlProbeLoads[lk])
end)
return { json = { value = v, loads = plugin.state.ttlProbeLoads[lk] or 0 } }
end
function plugin.__ttl_invalidate_probe(args)
args = args or {}
ttlCacheInvalidate(tostring(args.ns or ""), args.key and tostring(args.key) or nil)
return { json = { ok = true } }
end
function plugin.__serial_probe(args)
args = args or {}
local ok = serialTryAcquire(tostring(args.ns or ""), tonumber(args.ttl) or 60)
return { json = { acquired = ok } }
end
function plugin.__serial_release_probe(args)
args = args or {}
serialRelease(tostring(args.ns or ""))
return { json = { ok = true } }
end
function plugin.__parse_capability_probe(args)
args = args or {}
local ok, rec = pcall(json.decode, tostring(args.body or ""))
if not ok or type(rec) ~= "table" then return { json = { error = "解析失败" } } end
local key = tostring(args.key or "opencode")
local src = rec[key]
if type(src) ~= "table" then return { json = { error = "无该键" } } end
local models = type(src.models) == "table" and src.models or src
local total, free, image, reasoning, with_ctx = 0, 0, 0, 0, 0
local sample = nil
for id, raw in pairs(models) do
local cap = normalizeCapability(tostring(id), raw)
if cap then
total = total + 1
if cap.isFree then free = free + 1 end
for _, m in ipairs(cap.modalities) do
if m == "image" then image = image + 1 break end
end
if cap.reasoning then reasoning = reasoning + 1 end
if (cap.contextWindow or 0) > 0 then with_ctx = with_ctx + 1 end
if not sample then sample = { id = id, isFree = cap.isFree,
modalities = cap.modalities, contextWindow = cap.contextWindow } end
end
end
return { json = { total = total, free = free, image = image,
reasoning = reasoning, with_ctx = with_ctx, sample = sample } }
end
function plugin.__inject_refresh_platform_probe(args)
args = args or {}
local id = tostring(args.id or "")
plugin.state.injected_platforms = plugin.state.injected_platforms or {}
local lst = plugin.state.injected_platforms
local repl = nil
for i, p in ipairs(lst) do if type(p) == "table" and p.id == id then repl = i break end end
local entry = {
id = id,
label = id,
flow = "manual",
adapter = "openai",
base_url = "http://127.0.0.1:1/v1",
endpoint = "/chat/completions",
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
models = { "m1" },
hosts = { "127.0.0.1" },
}
if repl then lst[repl] = entry else lst[#lst + 1] = entry end
local ref = "channels:" .. id
__llmsproxy_secrets.set(ref, tostring(args.credential or "{}"))
local expAt = 0
local dok, dcred = pcall(json.decode, tostring(args.credential or "{}"))
if dok and type(dcred) == "table" then
local ms = credentialExpiresAtMs(dcred)
if ms then expAt = math.floor(ms / 1000) end
end
poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = expAt })
return { json = { ok = true } }
end
function plugin.__is_free_probe(args)
args = args or {}
local plat = platformById(tostring(args.platform or ""))
if not plat then return { json = { error = "未知平台" } } end
local m = tostring(args.model_id or "")
local free = {}
for _, x in ipairs(plat.models_free or {}) do free[x] = true end
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local cap = capabilityOf(plat.id, m)
local is_free = false
if plat.free_source == "capability" then
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false
else is_free = free[m] and true or false end
else
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
return { json = { is_free = is_free, model_id = m } }
end
function plugin.__qoder_claim_outcome_probe(args)
args = args or {}
local refreshed = args.refreshed == true
local claimedBefore = true
if claimedBefore then
if not refreshed then
return { json = { kind = "inactive", covers_today = false } }
end
return { json = { kind = "already-claimed", covers_today = true } }
end
return { json = { kind = "inactive", covers_today = true } }
end
function plugin.__claim_covers_probe(args)
args = args or {}
local outcome = { kind = tostring(args.kind or "") }
if args.covers_today ~= nil then outcome.covers_today = args.covers_today end
local resp = { json = { ok = (args.kind == "success"), outcome = outcome } }
return { json = { covers = claimCoversToday(resp) } }
end
function plugin.__schedule_probe()
local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false }
for _, t in ipairs(plugin.schedule or {}) do
if t.fn == "auto_claim" then
out.auto_claim_every = t.every or 0
out.first_due_now = t.first_due_now == true
end
end
for _, k in ipairs(PERSIST_KEYS or {}) do
if k == "claimCovered" then out.has_coverage_key = true end
end
return { json = out }
end
function plugin.__clear_capabilities_probe()
plugin.state.capabilities = nil
plugin.state.capabilitiesMeta = nil
return { json = { ok = true } }
end
function plugin.__inject_capability_probe(args)
args = args or {}
local plat = tostring(args.platform or "")
plugin.state.capabilities = plugin.state.capabilities or {}
plugin.state.capabilities[plat] = plugin.state.capabilities[plat] or {}
plugin.state.capabilities[plat][tostring(args.model_id or "")] = {
id = tostring(args.model_id or ""),
name = tostring(args.model_id or ""),
isFree = args.is_free and true or false,
modalities = { "text" },
contextWindow = 100000, maxOutputTokens = 8000,
reasoning = false, efforts = {}, toolCall = true,
}
return { json = { ok = true } }
end
function plugin.__rank_accounts_probe(args)
args = args or {}
local r = rankAccounts(tostring(args.platform or ""))
local ids = {}
for _, a in ipairs(r.all or {}) do ids[#ids+1] = a.id end
return { json = { expiring = r.expiring, permanent = r.permanent,
none = r.none, locked = r.locked, all = ids,
usable_n = #(r.usable or {}) } }
end
function plugin.__chosen_account_probe(args)
args = args or {}
local provider = tostring(args.platform or "")
return { json = { account_id = (plugin.state.chosen or {})[provider] or "" } }
end
function plugin.__expiring_window_probe()
return { json = { days = expiringWindowDays() } }
end
function plugin.__is_model_gone_probe(args)
return { json = { gone = isModelGone(tostring((args or {}).message or "")) } }
end
function plugin.__record_dead_probe(args)
args = args or {}
local isnew = recordDeadModel(tostring(args.platform or ""), tostring(args.model_id or ""),
tostring(args.reason or ""))
return { json = { recorded = isnew } }
end
function plugin.__catalog_gate_probe(args)
args = args or {}
local now = os.time and os.time() or 0
local cooling = catalogGateCooling(tostring(args.platform or ""), now)
local began = false
if not cooling then began = catalogGateBegin(tostring(args.platform or ""), now) end
return { json = { began = began, cooling = cooling } }
end
function plugin.__catalog_gate_fail_probe(args)
args = args or {}
local now = os.time and os.time() or 0
local p = tostring(args.platform or "")
catalogGateBegin(p, now)
catalogGateEnd(p, false, now)
return { json = { ok = true } }
end
function plugin.credits_capability(args)
args = args or {}
local cap = CHANNEL_CREDITS[args.platform]
if not cap then return { json = { error = "无能力声明" } } end
local out = {}
for k, v in pairs(cap) do
if type(v) ~= "function" then out[k] = v end
end
return { json = { capability = out } }
end
function plugin.claims_capable(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local has_checkin = plat.checkin ~= nil and plat.checkin.url ~= nil
local has_fn = plat.claim_fn ~= nil
local cap = CHANNEL_CREDITS[plat.id] or {}
return { json = {
capable = (has_checkin or has_fn),
has_checkin = has_checkin,
has_claim_fn = has_fn,
table_says_claim = cap.claim == true,
auto_exclude = cap.auto_exclude == true,
reason = (not (has_checkin or has_fn)) and
("既无 checkin 表也无 claim_fn(claim_fn=" .. tostring(plat.claim_fn ~= nil) .. ")") or "",
} }
end
function plugin.__clear_priorities_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local n = 0
for _, a in ipairs(poolList(plat.id)) do
a.priority = nil
poolAdd(plat.id, a)
n = n + 1
end
return { json = { ok = true, n = n } }
end
function plugin.__resync_sources_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local n = 0
for _, a in ipairs(poolList(plat.id)) do
syncAccountSource(plat.id, a.id)
n = n + 1
end
return { json = { ok = true, n = n } }
end
function plugin.__utf8_clip_probe(args)
args = args or {}
return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } }
end
function plugin.__event_log_probe()
local out = {}
for _, e in ipairs(plugin.state.log or {}) do
if type(e) == "table" and type(e.msg) == "string" then out[#out + 1] = e.msg end
end
return { json = { log = out } }
end
function plugin.__list_sources_probe()
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = ls and ls.json or ls
if type(arr) ~= "table" then arr = {} end
local out = {}
for _, x in ipairs(arr) do
if type(x) == "table" then out[#out + 1] = { name = x.name, adapter = x.adapter } end
end
return { json = { sources = out } }
end
function plugin.__trae_headers_probe(args)
local plat = platformById("trae")
local cred = {
access_token = "jwt-test", uid = "1000",
device_id = "dev-32", machine_id = "mid-32",
}
local ide = traeIdeHeaders(plat, cred, "application/json")
local chk = traeCheckinHeaders(plat, cred, cred.uid)
local function keyset(h)
local out = {}
for k in pairs(h or {}) do out[#out + 1] = k end
table.sort(out)
return out
end
return { json = { ide = keyset(ide), checkin = keyset(chk) } }
end
function plugin.__trae_catalog_probe(args)
args = args or {}
local plat = platformById("trae")
if not plat or not plat.models_remote then
return { json = { error = "trae 平台未声明 models_remote" } }
end
local ep = (plat.models_remote.endpoints or {})[1] or {}
local cred
for _, a in ipairs(poolList("trae")) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and cj.access_token then cred = cj break end
end
end
if not cred then return { json = { error = "无可用 TRAE 凭据" } } end
if type(traeCheckinHeaders) ~= "function" then
return { json = { error = "traeCheckinHeaders 未就绪" } }
end
local h, herr = traeCheckinHeaders(plat, cred, cred.uid or "")
if herr then return { json = { error = "构造头部失败: " .. tostring(herr) } } end
if type(args.accept) == "string" and args.accept ~= "" then
h["Accept"] = args.accept
end
if type(args.headers) == "table" then
for k, v in pairs(args.headers) do
if type(k) == "string" and type(v) == "string" then h[k] = v end
end
end
if type(args.ide_version) == "string" and args.ide_version ~= "" then
h["X-Ide-Version"] = args.ide_version
h["X-App-Version-Code"] = args.ide_version
h["X-Ide-Version-Code"] = args.ide_version
end
local resp, err = __llmsproxy_http({
url = (type(args.url) == "string" and args.url ~= "" and args.url) or ep.url,
method = ep.method or "POST",
headers = h, body = (type(args.body) == "string" and args.body ~= "" and args.body) or ep.body or "{}",
})
if err then return { json = { error = tostring(err) } } end
local body = (resp and resp.body) or ""
return { json = {
status = resp and resp.status or 0,
bytes = #body,
head = body:sub(1, math.min(tonumber(args.clip) or 1200, #body)),
parsed = #parseRemoteModels("trae_batch", body, "trae"),
} }
end
function plugin.__trae_entry_probe(args)
args = args or {}
local id = tostring(args.model or "")
local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae
local e = meta and meta[id]
if not e then return { json = { error = "无该模型元数据" } } end
local rc = e.reasoning
local opts = type(rc) == "table" and (rc.options or rc.Options) or nil
return { json = {
id = id, chan = e.chan, usage = e.usage,
enabled = e.enabled, hidden = e.hidden, custom = e.custom,
has_reasoning = rc ~= nil,
n_options = type(opts) == "table" and #opts or 0,
support_thinking = type(rc) == "table"
and (rc.support_thinking or rc.supportThinking) or nil,
} }
end
function plugin.__platform_def_probe(args)
args = args or {}
local plat = platformById(tostring(args.id or ""))
if not plat then return { json = { error = "未知平台" } } end
return { json = {
id = plat.id,
base_url = plat.base_url or "",
endpoint = plat.endpoint or "",
endpoint_stream = plat.endpoint_stream or "",
adapter = plat.adapter or "openai",
has_refresh_fn = type(plat.refresh_fn) == "function",
has_refresh_decl = type(plat.refresh) == "table" and plat.refresh.url ~= nil,
} }
end
function plugin.__b64_probe(args)
args = args or {}
local bin = b64urlDecode(tostring(args.s or ""))
local hx = {}
for i = 1, #bin do hx[#hx + 1] = string.format("%02x", bin:byte(i)) end
return { json = { len = #bin, hex = table.concat(hx) } }
end
function plugin.__codearts_jwk_pem_probe(args)
args = args or {}
local jwk = args.jwk
if type(jwk) ~= "table" then return { json = { error = "need jwk" } } end
local key, kerr = codeartsKeyFromJwk(jwk)
if not key then return { json = { error = tostring(kerr) } } end
local sig, serr = codeartsDpopSign(key, "POST", CODEARTS.sts_token)
return { json = {
pem = key.pem,
signed = sig ~= nil,
sign_error = serr and tostring(serr) or nil,
} }
end
function plugin.__zcode_diag_probe(args)
args = args or {}
local fn = tostring(args.fn or "")
if not ZD then return { json = { error = "诊断模块未装载" } } end
local f = ZD[fn]
if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end
if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end
if fn == "formatUnusualActivityMessage" then
return { json = { result = f(args.opts or {}) } }
end
if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end
if fn == "describeRequestShape" then
return { json = { result = f(args.system, args.messages) } }
end
if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end
if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end
if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity"
or fn == "looksLikeUnusualActivity" then
return { json = { result = f(args.a) } }
end
return { json = { error = "探针未暴露该函数: " .. fn } }
end
function plugin.__adapter_install_probe(args)
args = args or {}
if args.adapters_dir then
local ok, err = installZcodeAdapter(args.adapters_dir)
return { json = { zcode_reinstall = { ok = ok and true or false, err = err } } }
end
return { json = {
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
zcode = plugin._zcode_adapter,
} }
end
function plugin.__sync_source_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
local acct = poolGet(args.platform, args.account_id)
if not acct then return { json = { error = "账号不存在" } } end
local raw = __llmsproxy_secrets.get(acct.credentialRef)
local tok = nil
if plat.credential_shape == "full" then tok = raw else tok = credentialOf(acct.credentialRef) end
syncAccountSource(args.platform, args.account_id)
local ls = __llmsproxy_kernel.call("list_sources", {})
local found, ad = false, nil
local want = providerSourceName(args.platform)
local arr = ls
if type(arr) == "table" and arr.json then arr = arr.json end
if type(arr) == "table" and arr.sources then arr = arr.sources end
if type(arr) ~= "table" then arr = {} end
for _, x in ipairs(arr) do
if x.name == want then found = true ad = x.adapter end
end
local models = {}
local disabled = disabledModelsOf(args.platform)
for _, m in ipairs(effectiveModels(args.platform)) do
if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end
end
local test_src = {
name = want, base_url = plat.base_url, adapter = plat.adapter or "openai",
api_key = tok, max_concurrent = 1, models = models,
}
if plat.endpoint then test_src.endpoint = plat.endpoint end
local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src })
return { json = { credential_shape = plat.credential_shape or "token",
tok_len = tok and #tok or -1,
source_name = want, found = found, adapter = ad,
adapter_decl = plat.adapter,
add_ok = rr and rr.json and rr.json.ok or false,
add_err = tostring(rerr),
add_json = rr and json.encode(rr.json or {}) or "nil",
models_n = #models } }
end
do
for _, p in ipairs(PLATFORMS) do
if p.id == "zcode" then
p.claim_fn = function(plat, tok, args) return claimZcode(plat, tok, args) end
break
end
end
end
do
for _, p in ipairs(PLATFORMS) do
if p.id == "loomy" then
p.claim_fn = function(plat, tok, args) return claimLoomy(plat, tok, args) end
break
end
end
end
do
for _, p in ipairs(PLATFORMS) do
if p.id == "raccoon" then
p.claim_fn = function(plat, tok, args) return claimRaccoon(plat, tok, args) end
break
end
end
end
do
local ok, err = installCodeartsAdapter()
plugin._codearts_adapter = { ok = ok, err = err }
local tok2, terr2 = installTraeAdapter()
plugin._trae_adapter = { ok = tok2, err = terr2 }
local gok, gerr = installGeminiAdapter()
plugin._gemini_adapter = { ok = gok, err = gerr }
local qok, qerr = installQoderAdapter()
plugin._qoder_adapter = { ok = qok, err = qerr }
local cok, cerr = installClineAdapter()
plugin._cline_adapter = { ok = cok, err = cerr }
local zok, zerr = installZcodeAdapter()
plugin._zcode_adapter = { ok = zok, err = zerr }
if not zok then logEvent("ZCode 适配器装载失败: " .. tostring(zerr)) end
if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end
return plugin