Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion backend/internal/usagedrv/lua/dsl.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ local function aigate_join_url(base_url, path)
return base_url
end
if string.match(path, "^https?://") then
return path
local normalized = string.gsub(path, "(/v1)/v1/", "%1/", 1)
return (string.gsub(normalized, "(/v1)/v1$", "%1", 1))
end
local base = string.gsub(base_url or "", "/+$", "")
local suffix = string.gsub(path, "^/+", "")
Expand Down Expand Up @@ -297,6 +298,88 @@ local function aigate_execute_simple_usage(adapter, ctx, payload)
}
end

local function aigate_format_ccs_balance(remaining, unit)
if type(remaining) ~= "number" then
return "--"
end
if unit == "USD" then
return "$" .. string.format("%.2f", remaining)
end
if unit == "CNY" or unit == "RMB" then
return "¥" .. string.format("%.2f", remaining)
end
if unit == nil or unit == "" then
return string.format("%.2f", remaining)
end
return string.format("%.2f %s", remaining, tostring(unit))
end

local function aigate_execute_ccs_usage(adapter, ctx, payload)
if type(adapter.extractor) ~= "function" then
return {
ok = false,
error = {
kind = "config_error",
message = "ccs_usage requires extractor(response)"
}
}
end
local extracted = adapter.extractor(payload)
if type(extracted) ~= "table" then
return {
ok = false,
error = {
kind = "config_error",
message = "ccs_usage extractor(response) must return a table"
}
}
end

local remaining = extracted.remaining
local unit = extracted.unit
local is_valid = extracted.isValid
if is_valid == nil then
is_valid = extracted.is_valid
end
if is_valid == nil then
is_valid = true
end
if unit == nil then
unit = "USD"
end

local formatted = aigate_format_ccs_balance(remaining, unit)
local display = adapter.display
if display == nil then
display = {
summary = { label = "余额", value = formatted },
detail_stats = {
{ label = "余额", value = formatted }
},
detail_items = {
{ label = "计费单位", value = tostring(unit) }
}
}
else
display = aigate_resolve_any(display, payload)
end

return {
ok = true,
source = adapter.source or "remote",
confidence = adapter.confidence or "high",
limits = {
balance = remaining
},
meta = {
unit = unit,
is_valid = is_valid
},
display = display,
payload = payload
}
end

local function aigate_execute_usage_adapter(adapter, ctx)
local payload = aigate_fetch_json(adapter, ctx)
if type(payload) == "table" and payload.ok == false and payload.error ~= nil then
Expand All @@ -305,6 +388,9 @@ local function aigate_execute_usage_adapter(adapter, ctx)
if adapter.__aigate_simple_usage == true then
return aigate_execute_simple_usage(adapter, ctx, payload)
end
if adapter.__aigate_ccs_usage == true then
return aigate_execute_ccs_usage(adapter, ctx, payload)
end

local extracted = aigate_resolve_map(adapter.extract, payload)
if type(adapter.result) == "function" then
Expand Down Expand Up @@ -332,4 +418,9 @@ function simple_usage(adapter)
adapter.__aigate_simple_usage = true
usage_adapter(adapter)
end

function ccs_usage(adapter)
adapter.__aigate_ccs_usage = true
usage_adapter(adapter)
end
`
64 changes: 64 additions & 0 deletions backend/internal/usagedrv/lua/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,70 @@ simple_usage({
}
}

func TestRuntimeExecuteCCSCompatibleUsageDSL(t *testing.T) {
t.Parallel()

scriptPath := writeTempScript(t, `
ccs_usage({
request = {
url = "{{baseUrl}}/v1/usage",
method = "GET",
headers = { Authorization = "Bearer {{apiKey}}" }
},
extractor = function(response)
local quota = type(response.quota) == "table" and response.quota or {}
local is_valid = response.is_active
if is_valid == nil then
is_valid = response.isValid
end
if is_valid == nil then
is_valid = true
end
return {
isValid = is_valid,
remaining = response.remaining or quota.remaining or response.balance,
unit = response.unit or quota.unit or "CNY"
}
end
})
`)
runtime := luadrv.NewRuntime(&http.Client{Transport: roundTripFunc(func(req *http.Request) *http.Response {
if req.URL.String() != "https://www.codex2api.com/v1/usage" {
t.Fatalf("request url = %q, want codex2api usage endpoint", req.URL.String())
}
if req.Header.Get("Authorization") != "Bearer sk-ccs" {
t.Fatalf("Authorization = %q, want CCS-style bearer token", req.Header.Get("Authorization"))
}
return jsonResponse(`{"quota":{"remaining":49.75,"unit":"CNY"},"isValid":false}`)
})}, filepath.Dir(scriptPath))
result, err := runtime.Execute(
context.Background(),
filepath.Base(scriptPath),
accounts.Account{BaseURL: "https://www.codex2api.com/v1"},
accountdrv.ResolvedCredential{APIKey: "sk-ccs"},
map[string]any{},
)
if err != nil {
t.Fatalf("Execute returned error: %v", err)
}
if result.Limits.Balance == nil || *result.Limits.Balance != 49.75 {
t.Fatalf("Balance = %#v, want 49.75", result.Limits.Balance)
}
if result.Meta["unit"] != "CNY" {
t.Fatalf("unit meta = %#v, want CNY", result.Meta["unit"])
}
if result.Meta["is_valid"] != false {
t.Fatalf("is_valid meta = %#v, want false", result.Meta["is_valid"])
}
summary, ok := result.Display["summary"].(map[string]any)
if !ok {
t.Fatalf("display.summary = %#v, want object", result.Display["summary"])
}
if summary["label"] != "余额" || summary["value"] != "¥49.75" {
t.Fatalf("display.summary = %#v, want formatted CNY balance", summary)
}
}

func TestRuntimeHTTPPostRetriesOptedInRateLimit(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "aigate-desktop",
"private": true,
"version": "1.7.0",
"version": "1.7.1",
"type": "module",
"scripts": {
"dev": "tauri dev",
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aigate-desktop"
version = "1.7.0"
version = "1.7.1"
edition = "2021"

[build-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "AI Gate",
"version": "1.7.0",
"version": "1.7.1",
"identifier": "com.aigate.desktop",
"build": {
"frontendDist": "../../frontend/dist",
Expand Down
36 changes: 36 additions & 0 deletions docs/lua-usage-dsl.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@

## 推荐入口

从 CCS Switch 迁移的余额脚本优先使用 `ccs_usage`。它保留 CCS 的 `request` 和 `extractor(response)` 字段语义,只需将 JavaScript 函数改写为 Lua:

```lua
ccs_usage({
request = {
url = "{{baseUrl}}/v1/usage",
method = "GET",
headers = { Authorization = "Bearer {{apiKey}}" }
},
extractor = function(response)
local quota = type(response.quota) == "table" and response.quota or {}
local is_valid = response.is_active
if is_valid == nil then
is_valid = response.isValid
end
if is_valid == nil then
is_valid = true
end
return {
isValid = is_valid,
remaining = response.remaining or quota.remaining or response.balance,
unit = response.unit or quota.unit or "USD"
}
end
})
```

`ccs_usage` 自动完成以下标准化映射:

- `remaining` -> `limits.balance`
- `unit` -> `meta.unit`
- `isValid` -> `meta.is_valid`
- `remaining + unit` -> 默认余额摘要和详情展示

`{{baseUrl}}` / `{{base_url}}` 和 `{{apiKey}}` / `{{api_key}}` 均可使用。账户地址已包含 `/v1` 时,模板中的重复 `/v1/v1` 会自动去重。

简单余额接口使用 `simple_usage`:

```lua
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "aigate-frontend",
"private": true,
"version": "1.7.0",
"version": "1.7.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/features/accounts/AccountsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ describe("AccountsPage", () => {
const editModal = await screen.findByRole("dialog", { name: "编辑账户" });
expect(await within(editModal).findByText("Lua Usage 配置")).toBeInTheDocument();
await waitFor(() => {
expect(within(editModal).getByDisplayValue(/simple_usage/)).toBeInTheDocument();
expect(within(editModal).getByDisplayValue(/ccs_usage/)).toBeInTheDocument();
});
expect(within(editModal).queryByDisplayValue(/lab\.sansi\.io/)).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -2056,6 +2056,7 @@ describe("AccountsPage", () => {
expect(clipboardWriteText).toHaveBeenCalledTimes(1);
});
expect(clipboardWriteText.mock.calls[0][0]).toContain("simple_usage");
expect(clipboardWriteText.mock.calls[0][0]).toContain("ccs_usage");
expect(clipboardWriteText.mock.calls[0][0]).toContain("兼容旧入口");
});

Expand Down
50 changes: 30 additions & 20 deletions frontend/src/features/accounts/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,30 @@ function inferLuaScriptKeyFromBaseURL(baseURL: string): string {
}
}

const ccsCompatibleLuaTemplate = `ccs_usage({
request = {
url = "{{baseUrl}}/v1/usage",
method = "GET",
headers = { Authorization = "Bearer {{apiKey}}" }
},
extractor = function(response)
local quota = type(response.quota) == "table" and response.quota or {}
local is_valid = response.is_active
if is_valid == nil then
is_valid = response.isValid
end
if is_valid == nil then
is_valid = true
end
return {
isValid = is_valid,
remaining = response.remaining or quota.remaining or response.balance,
unit = response.unit or quota.unit or "USD"
}
end
})
`;

function getKnownLuaDSLTemplate(account: Pick<AccountRecord, "base_url" | "source_icon">): string {
const baseURL = account.base_url.trim().toLowerCase();
if (/nodeseek\.in/i.test(baseURL)) {
Expand Down Expand Up @@ -288,26 +312,7 @@ function getKnownLuaDSLTemplate(account: Pick<AccountRecord, "base_url" | "sourc
})
`;
}
return `simple_usage({
get = "/v1/usage",
auth = "bearer",
remaining = pick("remaining", "quota.remaining", "balance"),
unit = pick("unit", "quota.unit", default("USD")),
valid = pick("is_active", "isValid", default(true)),
display = {
summary = { label = "余额", value = function(payload)
local remaining = payload.remaining or payload.balance
if remaining == nil and type(payload.quota) == "table" then
remaining = payload.quota.remaining
end
if type(remaining) == "number" then
return "$" .. string.format("%.2f", remaining)
end
return "--"
end }
}
})
`;
return ccsCompatibleLuaTemplate;
}

function stringifyLuaConfigDraft(raw: string, scriptKey: string): string {
Expand Down Expand Up @@ -349,6 +354,11 @@ ${JSON.stringify(accountContext, null, 2)}
4. 若第三方 usage 接口需要额外字段,请直接写入 \`usage_config_json\`。

## Lua DSL 规范(优先使用)
- CCS Switch 风格脚本优先使用 \`ccs_usage({...})\`。字段语义与 CCS 的 \`request + extractor(response)\` 一致,仅将 JavaScript 函数语法改为 Lua:
\`\`\`lua
${ccsCompatibleLuaTemplate.trimEnd()}
\`\`\`
- \`ccs_usage\` 会自动把 \`remaining\` 映射为 \`limits.balance\`,把 \`unit\` 和 \`isValid\` 映射为标准 meta,并生成余额展示;无需手写 AI Gate 返回结构。
- 简单余额接口优先使用 \`simple_usage({...})\`:
\`\`\`lua
simple_usage({
Expand Down
Loading
Loading