mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-10 03:07:25 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3069cfce4e | |||
| cb9c025952 | |||
| 83e6d88813 | |||
| 6f1c806591 | |||
| 98c08d51c4 | |||
| ca81048637 | |||
| b9f2486037 | |||
| 8c5279b416 | |||
| 23bd9e5552 | |||
| d75d8411c0 | |||
| f67831c3c2 | |||
| e356885130 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,3 +17,6 @@ cmd/gui/*.log
|
|||||||
.pi-glla/
|
.pi-glla/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
# local ops scripts (machine-specific, not for sharing)
|
||||||
|
scripts/
|
||||||
|
|||||||
18
Makefile
18
Makefile
@ -9,6 +9,12 @@ export GUI_DIR
|
|||||||
GUI_DIR=cmd/gui
|
GUI_DIR=cmd/gui
|
||||||
VERSION ?= $(shell git describe --tags --dirty 2>/dev/null || echo "0.1.0")
|
VERSION ?= $(shell git describe --tags --dirty 2>/dev/null || echo "0.1.0")
|
||||||
|
|
||||||
|
# Clean env for GUI packaging: the host may have LD_LIBRARY_PATH polluted by
|
||||||
|
# third-party runtimes (e.g. /opt/cangjie) which electron-builder bakes into
|
||||||
|
# the produced binary's DT_RPATH/dep resolution -> the packaged app then
|
||||||
|
# SIGSEGVs inside ld.so on clean machines. Strip it for every pack step.
|
||||||
|
CLEAN_PACK_ENV = env -u LD_LIBRARY_PATH
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
# ---- core binary (server users / embedded core) ----
|
# ---- core binary (server users / embedded core) ----
|
||||||
@ -26,23 +32,25 @@ gui-dev:
|
|||||||
|
|
||||||
# build the embedded core into the GUI, then run electron-builder
|
# build the embedded core into the GUI, then run electron-builder
|
||||||
# targets: deb + AppImage (linux), rpm needs system rpmbuild
|
# targets: deb + AppImage (linux), rpm needs system rpmbuild
|
||||||
|
# NOTE: run in a clean env (no LD_LIBRARY_PATH pollution) so the packaged
|
||||||
|
# binary does not link against host-only libs and crash on user machines.
|
||||||
gui-dist:
|
gui-dist:
|
||||||
@cd $(GUI_DIR) && npm run dist:linux
|
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:linux
|
||||||
|
|
||||||
# deb only (for local sharing), rpm needs rpmbuild
|
# deb only (for local sharing), rpm needs rpmbuild
|
||||||
gui-deb:
|
gui-deb:
|
||||||
@cd $(GUI_DIR) && npm run dist:debian
|
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:debian
|
||||||
|
|
||||||
# windows nsis installer: host-built core (needs apt install gcc-mingw-w64-x86-64 + wine)
|
# windows nsis installer: host-built core (needs apt install gcc-mingw-w64-x86-64 + wine)
|
||||||
gui-win:
|
gui-win:
|
||||||
@cd $(GUI_DIR) && npm run dist:win
|
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:win
|
||||||
|
|
||||||
# windows nsis installer: full cross-build in docker (self-sufficient, no host mingw)
|
# windows nsis installer: full cross-build in docker (self-sufficient, no host mingw)
|
||||||
gui-win-docker:
|
gui-win-docker:
|
||||||
@cd $(GUI_DIR) && ./scripts/dist-win-docker.sh
|
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) ./scripts/dist-win-docker.sh
|
||||||
|
|
||||||
gui-dist-dir:
|
gui-dist-dir:
|
||||||
@cd $(GUI_DIR) && npm run dist:dir
|
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:dir
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
12
README.md
12
README.md
@ -89,8 +89,7 @@ GOMODCACHE=... GOPROXY=off go build -tags luajit -o llmsproxy ./cmd/llmsproxy
|
|||||||
|
|
||||||
> **配置不入库**:仓库不携带任何 config 文件(配置文件含密钥)。二进制首次运行时
|
> **配置不入库**:仓库不携带任何 config 文件(配置文件含密钥)。二进制首次运行时
|
||||||
> 会在 `-config` 指定的位置生成默认配置:随机 admin key(打印在启动日志中)、
|
> 会在 `-config` 指定的位置生成默认配置:随机 admin key(打印在启动日志中)、
|
||||||
> 仅绑定 `127.0.0.1:8080`、内置一个免 key 的 opencode zen 源可直接对话。
|
> 仅绑定 `127.0.0.1:8080`。首次登录后请在 WebUI「密钥」页更换管理员密钥。
|
||||||
> 首次登录后请在 WebUI「密钥」页更换管理员密钥。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 无 key -> 401
|
# 无 key -> 401
|
||||||
@ -281,19 +280,12 @@ return {
|
|||||||
|
|
||||||
### 内置适配器
|
### 内置适配器
|
||||||
|
|
||||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`
|
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。
|
||||||
`opencode`。
|
|
||||||
|
|
||||||
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若
|
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若
|
||||||
源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为
|
源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为
|
||||||
`disabled`。
|
`disabled`。
|
||||||
|
|
||||||
**opencode** 适配器面向 opencode.ai zen 免费池(`https://opencode.ai/zen/v1`):
|
|
||||||
zen 按 User-Agent 指纹识别官方客户端并分流——非官方 UA 的请求(curl、Go 默认 UA)
|
|
||||||
会被丢进匿名池触发 `FreeUsageLimitError`。该适配器固定发送 opencode 客户端 UA,
|
|
||||||
配合 `api_key: "public"`(官方无 key 客户端实际发送 `Bearer public`)即可走免费池,
|
|
||||||
零成本接入 `deepseek-v4-flash-free` 等免费模型。
|
|
||||||
|
|
||||||
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
|
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
|
||||||
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
|
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
|
||||||
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。
|
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。
|
||||||
|
|||||||
11
README_EN.md
11
README_EN.md
@ -77,7 +77,7 @@ GOMODCACHE=... GOPROXY=off go build -tags luajit -o llmsproxy ./cmd/llmsproxy
|
|||||||
> **No config is shipped in the repo** (config files carry real keys). On
|
> **No config is shipped in the repo** (config files carry real keys). On
|
||||||
> first run the binary generates a default config at the `-config` path: a
|
> first run the binary generates a default config at the `-config` path: a
|
||||||
> random admin key (printed to the startup log), loopback-only `127.0.0.1:8080`,
|
> random admin key (printed to the startup log), loopback-only `127.0.0.1:8080`,
|
||||||
> and a keyless opencode zen source ready to chat. Rotate the admin key in the
|
> and a keyless source ready to chat. Rotate the admin key in the
|
||||||
> WebUI after first login.
|
> WebUI after first login.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -289,19 +289,12 @@ Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`,
|
|||||||
### Built-in adapters
|
### Built-in adapters
|
||||||
|
|
||||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
|
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
|
||||||
`kimicode` `opencode`.
|
`kimicode`.
|
||||||
|
|
||||||
`anthropic`/`gemini`/`ollama` include multimodal conversion
|
`anthropic`/`gemini`/`ollama` include multimodal conversion
|
||||||
(`image_url` → their native format); with `disable_thinking` the `deepseek`
|
(`image_url` → their native format); with `disable_thinking` the `deepseek`
|
||||||
adapter sets `extra_body.thinking.type` to `disabled`.
|
adapter sets `extra_body.thinking.type` to `disabled`.
|
||||||
|
|
||||||
**opencode** targets the opencode.ai zen free pool
|
|
||||||
(`https://opencode.ai/zen/v1`): zen fingerprints clients by User-Agent and
|
|
||||||
routes non-official UAs (curl, Go's default) into an anonymous pool that
|
|
||||||
hits `FreeUsageLimitError`. The adapter always sends the opencode client UA;
|
|
||||||
combined with `api_key: "public"` (the keyless official client actually sends
|
|
||||||
`Bearer public`) it gets the free pool, e.g. `deepseek-v4-flash-free`.
|
|
||||||
|
|
||||||
**kimicode** demonstrates `build_headers`: the cloud validates the calling
|
**kimicode** demonstrates `build_headers`: the cloud validates the calling
|
||||||
app, so you HMAC-sign timestamp+URL+body with `meta.app_secret` and add
|
app, so you HMAC-sign timestamp+URL+body with `meta.app_secret` and add
|
||||||
`X-App-Sign`-style headers. Configure `sources[].meta.{app_id, app_secret,
|
`X-App-Sign`-style headers. Configure `sources[].meta.{app_id, app_secret,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const {
|
|||||||
Tray,
|
Tray,
|
||||||
shell,
|
shell,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
|
nativeTheme,
|
||||||
} = require("electron");
|
} = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@ -46,6 +47,15 @@ const CORE_EXE = path.join(
|
|||||||
"bin",
|
"bin",
|
||||||
process.platform === "win32" ? "llmsproxy.exe" : "llmsproxy",
|
process.platform === "win32" ? "llmsproxy.exe" : "llmsproxy",
|
||||||
);
|
);
|
||||||
|
// Packaged window icon: must be a REAL file on disk (extraResource), not an
|
||||||
|
// asar path — Electron/GTK pulls BrowserWindow icons from the filesystem and
|
||||||
|
// crashes (SIGSEGV) when given an asar virtual path on Linux.
|
||||||
|
// Dev: cmd/gui/build/icon.png.
|
||||||
|
const WINDOW_ICON = path.join(
|
||||||
|
app.isPackaged ? process.resourcesPath : __dirname,
|
||||||
|
"build",
|
||||||
|
"icon.png",
|
||||||
|
);
|
||||||
// default port for the embedded core (configurable via settings)
|
// default port for the embedded core (configurable via settings)
|
||||||
const DEFAULT_PORT = 8787;
|
const DEFAULT_PORT = 8787;
|
||||||
|
|
||||||
@ -53,6 +63,7 @@ let mainWindow = null;
|
|||||||
let tray = null;
|
let tray = null;
|
||||||
let coreProc = null;
|
let coreProc = null;
|
||||||
let coreStartedAt = 0;
|
let coreStartedAt = 0;
|
||||||
|
let coreReady = false; // true only after the embedded core actually answers HTTP
|
||||||
let settings = {}; // { port, autoStart, minimizeToTray }
|
let settings = {}; // { port, autoStart, minimizeToTray }
|
||||||
let authRules = [];
|
let authRules = [];
|
||||||
|
|
||||||
@ -120,9 +131,9 @@ function coreStarted() {
|
|||||||
|
|
||||||
function writeDefaultEmbeddedConfig() {
|
function writeDefaultEmbeddedConfig() {
|
||||||
// Generate a self-contained embedded profile with a fresh random admin key.
|
// Generate a self-contained embedded profile with a fresh random admin key.
|
||||||
// (Like Clash Verge generates its profile; the core's EnsureDefault default
|
// No upstream sources are preconfigured — the desktop user adds them via
|
||||||
// is 127.0.0.1:8080 — we want our own port + key on first run.) If a config
|
// the WebUI's sources page (like Clash Verge's empty-by-default profile).
|
||||||
// already exists we do NOT overwrite it (keep user's edits).
|
// If a config already exists we do NOT overwrite it (keep user's edits).
|
||||||
if (fs.existsSync(CONFIG_FILE)) return;
|
if (fs.existsSync(CONFIG_FILE)) return;
|
||||||
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
|
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
|
||||||
const yaml = [
|
const yaml = [
|
||||||
@ -132,16 +143,6 @@ function writeDefaultEmbeddedConfig() {
|
|||||||
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
||||||
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
|
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
|
||||||
"",
|
"",
|
||||||
"sources:",
|
|
||||||
" - name: zen",
|
|
||||||
" base_url: https://opencode.ai/zen/v1",
|
|
||||||
" api_key: public",
|
|
||||||
" adapter: opencode",
|
|
||||||
" models:",
|
|
||||||
" - id: deepseek-v4-flash-free",
|
|
||||||
" priority: 100",
|
|
||||||
" kind: chat",
|
|
||||||
"",
|
|
||||||
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
|
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
|
||||||
// shows the replace-the-initial-key warning. gateway_keys entries are
|
// shows the replace-the-initial-key warning. gateway_keys entries are
|
||||||
// marked seed:true by the core.
|
// marked seed:true by the core.
|
||||||
@ -211,6 +212,7 @@ function startCore() {
|
|||||||
coreProc.on("exit", (code) => {
|
coreProc.on("exit", (code) => {
|
||||||
const wasManaged = coreStartedAt > 0;
|
const wasManaged = coreStartedAt > 0;
|
||||||
coreStartedAt = 0;
|
coreStartedAt = 0;
|
||||||
|
coreReady = false;
|
||||||
coreProc = null;
|
coreProc = null;
|
||||||
console.log("core exited:", code);
|
console.log("core exited:", code);
|
||||||
// auto-restart if still running GUI (crash recovery, max every ~5s)
|
// auto-restart if still running GUI (crash recovery, max every ~5s)
|
||||||
@ -223,6 +225,12 @@ function startCore() {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Broadcast starting (NOT ready) immediately. The renderer must not
|
||||||
|
// attempt to load the iframe until the core actually answers HTTP —
|
||||||
|
// otherwise the frame load races the listener and fails permanently.
|
||||||
|
// (Fix for blank-screen: running=true was previously broadcast while the
|
||||||
|
// core was still booting, so the iframe ERR_CONNECTION_REFUSED and the
|
||||||
|
// renderer never retried.)
|
||||||
notifyCoreState();
|
notifyCoreState();
|
||||||
// pump until the core answers, then inject the admin key and tell the
|
// pump until the core answers, then inject the admin key and tell the
|
||||||
// renderer the web UI is ready (the iframe must load only after auth is set)
|
// renderer the web UI is ready (the iframe must load only after auth is set)
|
||||||
@ -230,6 +238,7 @@ function startCore() {
|
|||||||
.then((ok) => {
|
.then((ok) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setEmbeddedAuth();
|
setEmbeddedAuth();
|
||||||
|
coreReady = true;
|
||||||
notifyCoreState();
|
notifyCoreState();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -255,6 +264,7 @@ let coreLastExit = 0;
|
|||||||
|
|
||||||
function stopCore() {
|
function stopCore() {
|
||||||
app.isQuitting = true;
|
app.isQuitting = true;
|
||||||
|
coreReady = false;
|
||||||
if (coreProc && coreProc.exitCode === null) {
|
if (coreProc && coreProc.exitCode === null) {
|
||||||
try {
|
try {
|
||||||
coreProc.kill();
|
coreProc.kill();
|
||||||
@ -357,17 +367,27 @@ function createTray() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
tray = new Tray(img);
|
tray = new Tray(img);
|
||||||
|
// Rebuild the context menu when the OS theme flips (dark/light) so menu
|
||||||
|
// text colors follow the desktop theme; nativeTheme is the Electron API
|
||||||
|
// that tracks GTK/appindicator dark mode on Linux.
|
||||||
|
const themeRebuild = () => {
|
||||||
|
try {
|
||||||
|
if (global.__rebuildTray) global.__rebuildTray();
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
nativeTheme.on("updated", themeRebuild);
|
||||||
|
} catch (e) {}
|
||||||
|
// Status rows used to be enabled:false, which GTK renders in a fixed light
|
||||||
|
// grey that never flips with the system dark/light theme — invisible on
|
||||||
|
// light desktops. They are now enabled (normal theme-aware fg) with a
|
||||||
|
// no-op click so they stay display-only but inherit the menu text color.
|
||||||
|
const statusRow = (text) => ({ label: text, enabled: true, click: () => {} });
|
||||||
const rebuild = () => {
|
const rebuild = () => {
|
||||||
const running = coreStarted();
|
const running = coreStarted();
|
||||||
const menu = Menu.buildFromTemplate([
|
const menu = Menu.buildFromTemplate([
|
||||||
{
|
statusRow("ModelRouter Desktop"),
|
||||||
label: "ModelRouter Desktop",
|
statusRow(running ? "● 内核运行中" : "○ 内核已停止"),
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: running ? "● 内核运行中" : "○ 内核已停止",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "显示主窗口",
|
label: "显示主窗口",
|
||||||
@ -438,7 +458,8 @@ function createTray() {
|
|||||||
function notifyCoreState() {
|
function notifyCoreState() {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send("core:state", {
|
mainWindow.webContents.send("core:state", {
|
||||||
running: coreStarted(),
|
running: coreStarted() && coreReady,
|
||||||
|
ready: coreReady,
|
||||||
startedAt: coreStartedAt,
|
startedAt: coreStartedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -496,6 +517,12 @@ function createWindow() {
|
|||||||
const menu = Menu.buildFromTemplate([]);
|
const menu = Menu.buildFromTemplate([]);
|
||||||
Menu.setApplicationMenu(menu);
|
Menu.setApplicationMenu(menu);
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
|
// Window icon (taskbar/dock). Packaged: real file under resourcesPath (see
|
||||||
|
// WINDOW_ICON) — an asar path here makes GTK segfault on Linux.
|
||||||
|
icon: WINDOW_ICON,
|
||||||
|
// GNOME taskbar icons match the window's WM_CLASS (lowercase
|
||||||
|
// "modelrouter-gui") against StartupWMClass in the .desktop file.
|
||||||
|
backgroundColor: "#1f2937",
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 860,
|
height: 860,
|
||||||
minWidth: 900,
|
minWidth: 900,
|
||||||
@ -560,7 +587,8 @@ ipcMain.handle(
|
|||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle("core:state", () => ({
|
ipcMain.handle("core:state", () => ({
|
||||||
running: coreStarted(),
|
running: coreStarted() && coreReady,
|
||||||
|
ready: coreReady,
|
||||||
startedAt: coreStartedAt,
|
startedAt: coreStartedAt,
|
||||||
baseUrl: embeddedBaseUrl(),
|
baseUrl: embeddedBaseUrl(),
|
||||||
port: settings.port || DEFAULT_PORT,
|
port: settings.port || DEFAULT_PORT,
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.modelrouter.gui",
|
"appId": "com.modelrouter.gui",
|
||||||
"productName": "ModelRouter",
|
"productName": "ModelRouter",
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"deb",
|
"deb",
|
||||||
"rpm",
|
"rpm",
|
||||||
@ -32,10 +32,19 @@
|
|||||||
"icon": "build/icon.png",
|
"icon": "build/icon.png",
|
||||||
"maintainer": "ModelRouter",
|
"maintainer": "ModelRouter",
|
||||||
"synopsis": "ModelRouter Desktop",
|
"synopsis": "ModelRouter Desktop",
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"StartupWMClass": "modelrouter-gui"
|
||||||
|
}
|
||||||
|
},
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "bin/llmsproxy",
|
"from": "bin/llmsproxy",
|
||||||
"to": "bin/llmsproxy"
|
"to": "bin/llmsproxy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "build/icon.png",
|
||||||
|
"to": "build/icon.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -44,7 +53,13 @@
|
|||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
"icon": "build/icon.png"
|
"icon": "build/icon.png",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "build/icon.png",
|
||||||
|
"to": "build/icon.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
@ -57,6 +72,10 @@
|
|||||||
{
|
{
|
||||||
"from": "bin/lua51.dll",
|
"from": "bin/lua51.dll",
|
||||||
"to": "bin/lua51.dll"
|
"to": "bin/lua51.dll"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "build/icon.png",
|
||||||
|
"to": "build/icon.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
core: {
|
core: {
|
||||||
running: false,
|
running: false,
|
||||||
|
ready: false,
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
port: 8787,
|
port: 8787,
|
||||||
profileDir: "",
|
profileDir: "",
|
||||||
@ -18,7 +19,9 @@ const state = {
|
|||||||
minimizeToTray: true,
|
minimizeToTray: true,
|
||||||
},
|
},
|
||||||
theme: localStorage.getItem("mr-theme") || "light",
|
theme: localStorage.getItem("mr-theme") || "light",
|
||||||
loadedOnce: false,
|
loadedOnce: false, // iframe has been pointed at the web UI at least once
|
||||||
|
frameLoaded: false, // last loadFrame actually finished loading (did-finish-load)
|
||||||
|
loadTries: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (s) => document.querySelector(s);
|
const $ = (s) => document.querySelector(s);
|
||||||
@ -55,7 +58,11 @@ function renderCoreStatus() {
|
|||||||
const core = state.core;
|
const core = state.core;
|
||||||
const dot = $("#conn-dot");
|
const dot = $("#conn-dot");
|
||||||
dot.className = "status-dot" + (core.running ? " ok" : " bad");
|
dot.className = "status-dot" + (core.running ? " ok" : " bad");
|
||||||
dot.title = core.running ? "内核运行中" : "内核未运行";
|
dot.title = !core.running
|
||||||
|
? "内核未运行"
|
||||||
|
: core.ready
|
||||||
|
? "内核运行中"
|
||||||
|
: "内核启动中…";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOffline() {
|
function showOffline() {
|
||||||
@ -70,11 +77,47 @@ function showLoading() {
|
|||||||
$("#console-loading").style.display = "flex";
|
$("#console-loading").style.display = "flex";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probe the core's web root directly (fetch from the renderer). Using
|
||||||
|
// fetch() instead of relying on the iframe's own load means the core's
|
||||||
|
// readiness is confirmed by an actual HTTP round-trip before we point the
|
||||||
|
// iframe at it — this is what prevents the blank-screen race where the
|
||||||
|
// frame load happens while the listener is still coming up.
|
||||||
|
function coreReachable() {
|
||||||
|
return fetch(
|
||||||
|
state.core.baseUrl || "http://127.0.0.1:" + (state.core.port || 8787),
|
||||||
|
{ method: "GET", redirect: "follow", cache: "no-store" },
|
||||||
|
)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// point the iframe at the WebUI, but only after the core is reachable.
|
||||||
|
// If the load fails (core still booting / raced), we retry with backoff
|
||||||
|
// instead of giving up forever (the old code set loadedOnce blindly and
|
||||||
|
// never retried -> permanent blank screen).
|
||||||
|
let frameRetryTimer = null;
|
||||||
|
function scheduleFrameLoad() {
|
||||||
|
if (frameRetryTimer) return; // already scheduled
|
||||||
|
frameRetryTimer = setTimeout(async () => {
|
||||||
|
frameRetryTimer = null;
|
||||||
|
if (!state.core.running) return; // core stopped meanwhile
|
||||||
|
if (!(await coreReachable())) {
|
||||||
|
// core not reachable yet: wait a bit and try again
|
||||||
|
state.loadTries += 1;
|
||||||
|
showLoading();
|
||||||
|
scheduleFrameLoad();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadFrame(state.core.baseUrl || "http://127.0.0.1:" + state.core.port);
|
||||||
|
}, state.loadTries === 0 ? 800 : Math.min(3000, 400 * state.loadTries));
|
||||||
|
}
|
||||||
|
|
||||||
function loadFrame(url) {
|
function loadFrame(url) {
|
||||||
const f = frame();
|
const f = frame();
|
||||||
f.style.display = "block";
|
f.style.display = "block";
|
||||||
$("#offline").style.display = "none";
|
$("#offline").style.display = "none";
|
||||||
$("#console-loading").style.display = "none";
|
$("#console-loading").style.display = "none";
|
||||||
|
if (state.loadedOnce && f.src === url) return; // already pointing there
|
||||||
f.src = url;
|
f.src = url;
|
||||||
state.loadedOnce = true;
|
state.loadedOnce = true;
|
||||||
}
|
}
|
||||||
@ -82,8 +125,9 @@ function loadFrame(url) {
|
|||||||
async function ensureCore() {
|
async function ensureCore() {
|
||||||
await refreshCore();
|
await refreshCore();
|
||||||
if (state.core.running) {
|
if (state.core.running) {
|
||||||
loadFrame(state.core.baseUrl);
|
scheduleFrameLoad();
|
||||||
} else {
|
} else {
|
||||||
|
state.loadedOnce = false;
|
||||||
showOffline();
|
showOffline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,10 +185,21 @@ async function saveSettings() {
|
|||||||
toast("设置已保存");
|
toast("设置已保存");
|
||||||
await refreshCore();
|
await refreshCore();
|
||||||
if (state.core.running) {
|
if (state.core.running) {
|
||||||
loadFrame(state.core.baseUrl);
|
scheduleFrameLoad();
|
||||||
|
} else {
|
||||||
|
state.loadedOnce = false;
|
||||||
|
showOffline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== theme =====
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.dataset.theme = state.theme;
|
||||||
|
try {
|
||||||
|
localStorage.setItem("mr-theme", state.theme);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== init =====
|
// ===== init =====
|
||||||
function init() {
|
function init() {
|
||||||
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
|
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
|
||||||
@ -171,15 +226,30 @@ function init() {
|
|||||||
showLoading();
|
showLoading();
|
||||||
await window.modelrouter.core.start();
|
await window.modelrouter.core.start();
|
||||||
await refreshCore();
|
await refreshCore();
|
||||||
if (state.core.running) loadFrame(state.core.baseUrl);
|
if (state.core.running) scheduleFrameLoad();
|
||||||
else showOffline();
|
else showOffline();
|
||||||
};
|
};
|
||||||
frame().onload = () => refreshCore();
|
// when the iframe finishes loading, mark loaded and refresh core status so
|
||||||
|
// the conn-dot reflects the actual WebUI (not just the core reachability)
|
||||||
|
frame().onload = () => {
|
||||||
|
state.frameLoaded = true;
|
||||||
|
refreshCore();
|
||||||
|
};
|
||||||
|
// load failure (e.g. the frame was pointed at the core just before it
|
||||||
|
// finished booting) -> clear loadedOnce and let scheduleFrameLoad retry
|
||||||
|
frame().onerror = () => {
|
||||||
|
state.loadedOnce = false;
|
||||||
|
state.frameLoaded = false;
|
||||||
|
scheduleFrameLoad();
|
||||||
|
};
|
||||||
window.modelrouter.core.onState((d) => {
|
window.modelrouter.core.onState((d) => {
|
||||||
state.core = Object.assign({}, state.core, d);
|
state.core = Object.assign({}, state.core, d);
|
||||||
renderCoreStatus();
|
renderCoreStatus();
|
||||||
if (d.running && !state.loadedOnce) loadFrame(state.core.baseUrl);
|
if (d.running && !state.loadedOnce) scheduleFrameLoad();
|
||||||
if (!d.running) showOffline();
|
if (!d.running) {
|
||||||
|
state.loadedOnce = false;
|
||||||
|
showOffline();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Escape closes overlay
|
// Escape closes overlay
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
@ -189,6 +259,7 @@ function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
|
applyTheme();
|
||||||
init();
|
init();
|
||||||
state.settings = await window.modelrouter.settings.get();
|
state.settings = await window.modelrouter.settings.get();
|
||||||
renderRail();
|
renderRail();
|
||||||
|
|||||||
@ -103,9 +103,10 @@ func EnsureDefault(path string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeDefaultConfig writes a minimal, safe-by-default config: loopback-only
|
// writeDefaultConfig writes a minimal, safe-by-default config: loopback-only
|
||||||
// listen, a fresh random admin key, and a no-key zen source that works out of
|
// listen and a fresh random admin key. No upstream sources are preconfigured
|
||||||
// the box. adapter_dir / runtime_file live next to the config file so the
|
// (the repo ships none — add them via WebUI or config.yaml). adapter_dir /
|
||||||
// binary works regardless of the working directory it is started from.
|
// runtime_file live next to the config file so the binary works regardless of
|
||||||
|
// the working directory it is started from.
|
||||||
func writeDefaultConfig(path string) error {
|
func writeDefaultConfig(path string) error {
|
||||||
key, err := NewGatewayKey()
|
key, err := NewGatewayKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -122,15 +123,6 @@ func writeDefaultConfig(path string) error {
|
|||||||
DefaultModel: "AUTO",
|
DefaultModel: "AUTO",
|
||||||
AdapterDir: filepath.Join(abs, "adapters"),
|
AdapterDir: filepath.Join(abs, "adapters"),
|
||||||
RuntimeFile: filepath.Join(abs, "runtime.json"),
|
RuntimeFile: filepath.Join(abs, "runtime.json"),
|
||||||
Sources: []Source{
|
|
||||||
{
|
|
||||||
Name: "zen",
|
|
||||||
BaseURL: "https://opencode.ai/zen/v1",
|
|
||||||
APIKey: "public", // zen 免费池:官方无 key 客户端实际发送 Bearer public
|
|
||||||
Adapter: "opencode",
|
|
||||||
Models: []Model{{ID: "deepseek-v4-flash-free", Priority: 100, Kind: "chat"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
out, err := yaml.Marshal(&cfg)
|
out, err := yaml.Marshal(&cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -91,14 +91,8 @@ func TestEnsureDefaultGeneratesOnMissingFile(t *testing.T) {
|
|||||||
if cfg.Listen != "127.0.0.1:8080" {
|
if cfg.Listen != "127.0.0.1:8080" {
|
||||||
t.Fatalf("listen = %q, want loopback-only", cfg.Listen)
|
t.Fatalf("listen = %q, want loopback-only", cfg.Listen)
|
||||||
}
|
}
|
||||||
if len(cfg.Sources) != 1 || cfg.Sources[0].Name != "zen" || cfg.Sources[0].Adapter != "opencode" {
|
if len(cfg.Sources) != 0 {
|
||||||
t.Fatalf("default sources = %+v", cfg.Sources)
|
t.Fatalf("default config must ship no sources (user adds them), got %+v", cfg.Sources)
|
||||||
}
|
|
||||||
if cfg.Sources[0].APIKey != "public" {
|
|
||||||
t.Fatalf("zen api_key = %q", cfg.Sources[0].APIKey)
|
|
||||||
}
|
|
||||||
if cfg.Sources[0].Models[0].ID != "deepseek-v4-flash-free" {
|
|
||||||
t.Fatalf("default model = %+v", cfg.Sources[0].Models)
|
|
||||||
}
|
}
|
||||||
// adapter_dir / runtime_file resolve next to the config file
|
// adapter_dir / runtime_file resolve next to the config file
|
||||||
if !strings.HasPrefix(cfg.AdapterDir, dir) || !strings.HasPrefix(cfg.RuntimeFile, dir) {
|
if !strings.HasPrefix(cfg.AdapterDir, dir) || !strings.HasPrefix(cfg.RuntimeFile, dir) {
|
||||||
|
|||||||
@ -53,11 +53,14 @@ type RespMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatChunk struct {
|
type ChatChunk struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
Created int64 `json:"created"`
|
Created int64 `json:"created"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Choices []ChunkChoice `json:"choices"`
|
Choices []ChunkChoice `json:"choices"`
|
||||||
|
// Usage is sent in the final chunk of a stream (empty choices) so
|
||||||
|
// OpenAI-compatible clients can read token usage.
|
||||||
|
Usage *types.TokenUsage `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChunkChoice struct {
|
type ChunkChoice struct {
|
||||||
@ -577,6 +580,32 @@ func (g *Gateway) writeRec(rec *Req) {
|
|||||||
g.stats.Record(*rec)
|
g.stats.Record(*rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeUsage combines token usage across stream chunks additively. Some
|
||||||
|
// providers split usage across chunks (e.g. Anthropic reports prompt tokens
|
||||||
|
// in message_start and the final completion tokens in message_delta); a plain
|
||||||
|
// "last non-nil wins" would discard the prompt half. Non-zero fields from cur
|
||||||
|
// override prev; total is recomputed from the merged parts so a partial later
|
||||||
|
// chunk can't shrink it. For the common single-chunk case (OpenAI's terminal
|
||||||
|
// empty-choices+usage chunk) upstream totals are preserved exactly.
|
||||||
|
func mergeUsage(prev, cur *types.TokenUsage) *types.TokenUsage {
|
||||||
|
if prev == nil {
|
||||||
|
u := *cur
|
||||||
|
if u.Total == 0 && (u.Prompt > 0 || u.Completion > 0) {
|
||||||
|
u.Total = u.Prompt + u.Completion
|
||||||
|
}
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
out := *prev
|
||||||
|
if cur.Prompt > 0 {
|
||||||
|
out.Prompt = cur.Prompt
|
||||||
|
}
|
||||||
|
if cur.Completion > 0 {
|
||||||
|
out.Completion = cur.Completion
|
||||||
|
}
|
||||||
|
out.Total = out.Prompt + out.Completion
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
||||||
rec.LatMs = 0
|
rec.LatMs = 0
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
@ -626,7 +655,11 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
|||||||
}) {
|
}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var lastUsage *types.TokenUsage
|
||||||
for ck := range chunks {
|
for ck := range chunks {
|
||||||
|
if ck.Usage != nil {
|
||||||
|
lastUsage = mergeUsage(lastUsage, ck.Usage)
|
||||||
|
}
|
||||||
chunk := ChatChunk{
|
chunk := ChatChunk{
|
||||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
|
||||||
}
|
}
|
||||||
@ -653,6 +686,27 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
|||||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
|
||||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
||||||
})
|
})
|
||||||
|
// Final usage chunk (OpenAI standard: empty choices + usage before [DONE]).
|
||||||
|
// Prefer the upstream's exact usage if the stream carried it; fall back to
|
||||||
|
// the gateway's estimate otherwise.
|
||||||
|
var tut *types.TokenUsage
|
||||||
|
if lastUsage != nil {
|
||||||
|
tut = lastUsage
|
||||||
|
} else if rec.Prompt+rec.Compl > 0 {
|
||||||
|
u := types.TokenUsage{
|
||||||
|
Prompt: int(rec.Prompt),
|
||||||
|
Completion: int(rec.Compl),
|
||||||
|
Total: int(rec.Prompt + rec.Compl),
|
||||||
|
}
|
||||||
|
tut = &u
|
||||||
|
}
|
||||||
|
if tut != nil {
|
||||||
|
send(ChatChunk{
|
||||||
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
|
||||||
|
Choices: []ChunkChoice{},
|
||||||
|
Usage: tut,
|
||||||
|
})
|
||||||
|
}
|
||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@ -771,7 +825,11 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
|||||||
}) {
|
}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var lastUsage *types.TokenUsage
|
||||||
for ck := range chunks {
|
for ck := range chunks {
|
||||||
|
if ck.Usage != nil {
|
||||||
|
lastUsage = mergeUsage(lastUsage, ck.Usage)
|
||||||
|
}
|
||||||
chunk := ChatChunk{
|
chunk := ChatChunk{
|
||||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||||
}
|
}
|
||||||
@ -798,6 +856,27 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
|
|||||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
||||||
})
|
})
|
||||||
|
// Final usage chunk (OpenAI standard: empty choices + usage before [DONE]).
|
||||||
|
// Prefer the upstream's exact usage if the stream carried it; fall back to
|
||||||
|
// the gateway's estimate otherwise.
|
||||||
|
var tut *types.TokenUsage
|
||||||
|
if lastUsage != nil {
|
||||||
|
tut = lastUsage
|
||||||
|
} else if rec.Prompt+rec.Compl > 0 {
|
||||||
|
u := types.TokenUsage{
|
||||||
|
Prompt: int(rec.Prompt),
|
||||||
|
Completion: int(rec.Compl),
|
||||||
|
Total: int(rec.Prompt + rec.Compl),
|
||||||
|
}
|
||||||
|
tut = &u
|
||||||
|
}
|
||||||
|
if tut != nil {
|
||||||
|
send(ChatChunk{
|
||||||
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||||
|
Choices: []ChunkChoice{},
|
||||||
|
Usage: tut,
|
||||||
|
})
|
||||||
|
}
|
||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|||||||
@ -343,10 +343,10 @@ func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "gw_key",
|
Name: "gw_key",
|
||||||
Value: body.Key,
|
Value: body.Key,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: 86400 * 30,
|
MaxAge: 86400 * 30,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
@ -456,7 +456,15 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
"gateway_keys": ks,
|
"gateway_keys": ks,
|
||||||
}
|
}
|
||||||
if reqRole(r.Context()) == "admin" {
|
if reqRole(r.Context()) == "admin" {
|
||||||
resp["sources"] = g.core.Registry().Status()
|
sts := g.core.Registry().Status()
|
||||||
|
recent := g.stats.SourceRecent(300)
|
||||||
|
for i := range sts {
|
||||||
|
if v, ok := recent[sts[i].Name]; ok {
|
||||||
|
sts[i].RecentOK = v[0]
|
||||||
|
sts[i].RecentErr = v[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp["sources"] = sts
|
||||||
resp["adapters"] = g.core.ListAdapters()
|
resp["adapters"] = g.core.ListAdapters()
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ type Req struct {
|
|||||||
Compl int64 `json:"completion_tokens"`
|
Compl int64 `json:"completion_tokens"`
|
||||||
// LatMs total handling time ms
|
// LatMs total handling time ms
|
||||||
LatMs int64 `json:"latency_ms"`
|
LatMs int64 `json:"latency_ms"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
// Status http status code
|
// Status http status code
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
// Err short error message
|
// Err short error message
|
||||||
@ -445,6 +445,33 @@ func (s *Stats) AuditRecords(from, to int64, key string) []Req {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SourceRecent counts real gateway requests per source within the last
|
||||||
|
// window (unix seconds). It reads only the in-memory ring, so it is cheap and
|
||||||
|
// reflects live traffic — used by /api/status so the source status column is
|
||||||
|
// driven by what actually happens, not just a probe.
|
||||||
|
func (s *Stats) SourceRecent(windowSec int64) map[string][2]int64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
cut := time.Now().Unix() - windowSec
|
||||||
|
out := map[string][2]int64{}
|
||||||
|
for _, r := range s.recs {
|
||||||
|
if r.Time/1000 < cut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Source == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := out[r.Source]
|
||||||
|
if r.OK {
|
||||||
|
v[0]++
|
||||||
|
} else {
|
||||||
|
v[1]++
|
||||||
|
}
|
||||||
|
out[r.Source] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot returns the whole dashboard payload; when key != "" the records
|
// Snapshot returns the whole dashboard payload; when key != "" the records
|
||||||
// and aggregate views are restricted to that gateway key.
|
// and aggregate views are restricted to that gateway key.
|
||||||
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||||
|
|||||||
@ -1393,6 +1393,63 @@
|
|||||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
};
|
};
|
||||||
let statsKeyF = ""; // active key filter for records ('' = all)
|
let statsKeyF = ""; // active key filter for records ('' = all)
|
||||||
|
|
||||||
|
// ---- Source status column (refreshable) ----
|
||||||
|
// The column is driven by a combination of signals so a source that is
|
||||||
|
// actually serving traffic can never be shown as "down/backoff" just
|
||||||
|
// because /models probing got rate-limited by the upstream (common with
|
||||||
|
// free pools). Priority: real recent traffic > live probe > scheduling
|
||||||
|
// cooldown.
|
||||||
|
function srcStatusCell(x) {
|
||||||
|
const ok = x.recent_ok || 0;
|
||||||
|
const err = x.recent_err || 0;
|
||||||
|
const cooled = x.backoff_until && x.backoff_until * 1000 > Date.now();
|
||||||
|
const probeOK = !!x.live_available;
|
||||||
|
const lastErr = x.last_error ? esc(x.last_error) : "";
|
||||||
|
let cell;
|
||||||
|
if (ok + err > 0) {
|
||||||
|
// real traffic: show a success rate, probe only annotates
|
||||||
|
const rate = Math.round((ok * 100) / (ok + err));
|
||||||
|
const cls =
|
||||||
|
rate >= 95 ? "tag-green" : rate >= 50 ? "tag-amber" : "tag-red";
|
||||||
|
const tip = `最近流量 ${ok} 成功 / ${err} 失败 (探测: ${probeOK ? "可达" : "探测失败"} ${cooled ? "· 冷却中" : ""})`;
|
||||||
|
cell = `<span class="tag ${cls}" title="${escAttr(tip)}"><i class="net-dot"></i>${rate}%(${ok}/${ok + err})${cooled ? "·冷却" : ""}</span>`;
|
||||||
|
} else if (probeOK) {
|
||||||
|
// no recent traffic but probe alive
|
||||||
|
const tip = `探测正常 ${cooled ? "· 调度冷却中(可自动恢复)" : ""}`;
|
||||||
|
cell = `<span class="tag tag-green" title="${escAttr(tip)}"><i class="net-dot"></i>${t("online")}${cooled ? "·冷却" : ""}</span>`;
|
||||||
|
} else if (cooled) {
|
||||||
|
cell = `<span class="tag tag-amber" title="${escAttr("调度冷却中(不影响探测)" + (lastErr ? " " + lastErr : ""))}"><i class="net-dot"></i>冷却中</span>`;
|
||||||
|
} else {
|
||||||
|
cell = `<span class="tag tag-red" title="${escAttr(lastErr || "探测失败")}"><i class="net-dot"></i>${t("offline")}</span>`;
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch /api/status and repaint only the source status column. Called
|
||||||
|
// from the 5s stats loop and the manual refresh button so the column is
|
||||||
|
// never frozen at the first render.
|
||||||
|
async function paintSourceStatus() {
|
||||||
|
const tb = $("#src-body");
|
||||||
|
if (!tb) return;
|
||||||
|
try {
|
||||||
|
const s = await api("/api/status");
|
||||||
|
const srcs = s.sources || [];
|
||||||
|
tb.innerHTML = srcs
|
||||||
|
.map(
|
||||||
|
(x) =>
|
||||||
|
`<tr><td>${srcStatusCell(x)}</td>
|
||||||
|
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
||||||
|
<td><div class="src-models">${(x.models || []).map((m) => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join("")}</div></td>
|
||||||
|
<td><span class="muted">${esc(x.base_url || "")}</span></td>
|
||||||
|
<td>${x.max_concurrent}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} catch (e) {
|
||||||
|
// status fetch failing (e.g. session) is surfaced by other loops
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderStatus() {
|
async function renderStatus() {
|
||||||
const s = await api("/api/status");
|
const s = await api("/api/status");
|
||||||
const base = (window._base = s.base_url || location.origin + "/v1");
|
const base = (window._base = s.base_url || location.origin + "/v1");
|
||||||
@ -1402,24 +1459,16 @@
|
|||||||
const pane = $("#tab-status");
|
const pane = $("#tab-status");
|
||||||
if (pane && pane.dataset.built) {
|
if (pane && pane.dataset.built) {
|
||||||
if (statsTimerId) clearInterval(statsTimerId);
|
if (statsTimerId) clearInterval(statsTimerId);
|
||||||
|
await paintSourceStatus();
|
||||||
await paintStats();
|
await paintStats();
|
||||||
statsTimerId = setInterval(() => {
|
statsTimerId = setInterval(() => {
|
||||||
if (!document.hidden) paintStats();
|
if (!document.hidden) {
|
||||||
|
paintStats();
|
||||||
|
paintSourceStatus();
|
||||||
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const srcRows = s.sources
|
|
||||||
? s.sources
|
|
||||||
.map(
|
|
||||||
(x) =>
|
|
||||||
`<tr><td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t("online")}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || "")}"><i class="net-dot"></i>${t("offline")}</span>`}</td>
|
|
||||||
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
|
||||||
<td><div class="src-models">${x.models.map((m) => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join("")}</div></td>
|
|
||||||
<td><span class="muted">${esc(x.base_url || "")}</span></td>
|
|
||||||
<td>${x.max_concurrent}</td></tr>`,
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: "";
|
|
||||||
pane.innerHTML = `
|
pane.innerHTML = `
|
||||||
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
|
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
|
||||||
5,
|
5,
|
||||||
@ -1441,7 +1490,8 @@
|
|||||||
${
|
${
|
||||||
s.sources
|
s.sources
|
||||||
? `<div class="card"><h2>${t("srcTitle")} (${s.sources.length})</h2>
|
? `<div class="card"><h2>${t("srcTitle")} (${s.sources.length})</h2>
|
||||||
<div class="tbl-wrap"><table><tr><th>${t("tConn")}</th><th>${t("tName")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th>${t("tURL")}</th><th>${t("tConc")}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
|
<div class="tbl-wrap"><table><tr><th>${t("tConn")}</th><th>${t("tName")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th>${t("tURL")}</th><th>${t("tConc")}</th></tr><tbody id="src-body"></tbody></table></div>
|
||||||
|
<p class="muted" style="padding:6px 2px 0"><button class="ghost small" onclick="paintSourceStatus()">${t("refreshSrc") || "刷新源状态"}</button> 状态列每 5 秒自动刷新</p>
|
||||||
</div>`
|
</div>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
@ -1475,9 +1525,13 @@
|
|||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
if (statsTimerId) clearInterval(statsTimerId);
|
if (statsTimerId) clearInterval(statsTimerId);
|
||||||
|
await paintSourceStatus();
|
||||||
await paintStats();
|
await paintStats();
|
||||||
statsTimerId = setInterval(() => {
|
statsTimerId = setInterval(() => {
|
||||||
if (!document.hidden) paintStats();
|
if (!document.hidden) {
|
||||||
|
paintStats();
|
||||||
|
paintSourceStatus();
|
||||||
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
function renderKeySelect(keys, keyNames) {
|
function renderKeySelect(keys, keyNames) {
|
||||||
|
|||||||
@ -99,9 +99,37 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if chunk.type == "message_start" then return "" end
|
if chunk.type == "message_start" then
|
||||||
|
local uses = nil
|
||||||
|
if chunk.message and type(chunk.message.usage) == "table" then
|
||||||
|
local u = chunk.message.usage
|
||||||
|
local p = u.input_tokens or 0
|
||||||
|
local c = u.output_tokens or 0
|
||||||
|
if p > 0 or c > 0 then
|
||||||
|
uses = { prompt = p, completion = c, total = p + c }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if uses ~= nil then
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
if chunk.type == "message_delta" then
|
if chunk.type == "message_delta" then
|
||||||
return json.encode({ content = "", done = (chunk.delta and chunk.delta.stop_reason ~= nil) })
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
local u = chunk.usage
|
||||||
|
local p = u.input_tokens or 0
|
||||||
|
local c = u.output_tokens or 0
|
||||||
|
if p > 0 or c > 0 then
|
||||||
|
uses = { prompt = p, completion = c, total = p + c }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local done = (chunk.delta and chunk.delta.stop_reason ~= nil)
|
||||||
|
if uses ~= nil then
|
||||||
|
-- completion is final here; prompt is merged from message_start
|
||||||
|
return json.encode({ content = "", done = done, usage = uses })
|
||||||
|
end
|
||||||
|
return json.encode({ content = "", done = done })
|
||||||
end
|
end
|
||||||
if chunk.type == "content_block_start" and chunk.content_block
|
if chunk.type == "content_block_start" and chunk.content_block
|
||||||
and chunk.content_block.type == "tool_use" then
|
and chunk.content_block.type == "tool_use" then
|
||||||
|
|||||||
@ -82,7 +82,27 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
|
||||||
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -90,6 +110,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -91,7 +91,24 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
|
|
||||||
if not chunk.candidates or #chunk.candidates == 0 then return "" end
|
-- Gemini attaches usageMetadata to the final chunk (alongside or after
|
||||||
|
-- candidates). Keys map to Go's TokenUsage json tags (prompt/...).
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usageMetadata) == "table" then
|
||||||
|
local p = chunk.usageMetadata.promptTokenCount or 0
|
||||||
|
local c = chunk.usageMetadata.candidatesTokenCount or 0
|
||||||
|
local t = chunk.usageMetadata.totalTokenCount or 0
|
||||||
|
if p > 0 or c > 0 or t > 0 then
|
||||||
|
uses = { prompt = p, completion = c, total = t }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.candidates or #chunk.candidates == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local cand = chunk.candidates[1]
|
local cand = chunk.candidates[1]
|
||||||
local unified = { content = "", done = (cand.finishReason ~= nil) }
|
local unified = { content = "", done = (cand.finishReason ~= nil) }
|
||||||
local reasoning = ""
|
local reasoning = ""
|
||||||
@ -117,6 +134,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
end
|
end
|
||||||
if reasoning ~= "" then unified.reasoning_content = reasoning end
|
if reasoning ~= "" then unified.reasoning_content = reasoning end
|
||||||
if #tools > 0 then unified.tool_calls = tools end
|
if #tools > 0 then unified.tool_calls = tools end
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
return json.encode(unified)
|
return json.encode(unified)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,27 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
|
||||||
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -71,6 +91,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -62,7 +62,27 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
|
||||||
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -70,6 +90,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -95,7 +95,27 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
|
||||||
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -103,6 +123,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -62,7 +62,27 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
|
||||||
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -70,6 +90,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -53,11 +53,14 @@ function adapter.transform_response(raw_body)
|
|||||||
local ok, resp = pcall(json.decode, raw_body)
|
local ok, resp = pcall(json.decode, raw_body)
|
||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
|
|
||||||
|
local p = resp.prompt_eval_count or 0
|
||||||
|
local c = resp.eval_count or 0
|
||||||
local unified = {
|
local unified = {
|
||||||
content = "",
|
content = "",
|
||||||
finish_reason = resp.done_reason or "",
|
finish_reason = resp.done_reason or "",
|
||||||
tool_calls = {},
|
tool_calls = {},
|
||||||
usage = { prompt = 0, completion = 0, total = 0 }
|
-- key must be token_usage to match Go's UnifiedResponse json tag
|
||||||
|
token_usage = { prompt = p, completion = c, total = p + c }
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.message then
|
if resp.message then
|
||||||
@ -70,12 +73,32 @@ end
|
|||||||
function adapter.transform_stream_chunk(raw_chunk)
|
function adapter.transform_stream_chunk(raw_chunk)
|
||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
if not chunk.message then return "" end
|
|
||||||
|
-- Ollama's terminal chunk (done=true) carries token counts but may omit
|
||||||
|
-- message; pass them through so the gateway emits real usage.
|
||||||
|
local uses = nil
|
||||||
|
if chunk.done then
|
||||||
|
local p = chunk.prompt_eval_count or 0
|
||||||
|
local c = chunk.eval_count or 0
|
||||||
|
if p > 0 or c > 0 then
|
||||||
|
uses = { prompt = p, completion = c, total = p + c }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.message then
|
||||||
|
if uses ~= nil then
|
||||||
|
return json.encode({ content = "", done = true, usage = uses })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
local unified = {
|
local unified = {
|
||||||
content = chunk.message.content or "",
|
content = chunk.message.content or "",
|
||||||
done = chunk.done or false
|
done = chunk.done or false
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if chunk.message.reasoning_content then
|
if chunk.message.reasoning_content then
|
||||||
unified.reasoning_content = chunk.message.reasoning_content
|
unified.reasoning_content = chunk.message.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -67,7 +67,26 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
|
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -75,11 +94,13 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
if delta.tool_calls then
|
if delta.tool_calls then
|
||||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
|
||||||
unified.tool_calls = delta.tool_calls
|
unified.tool_calls = delta.tool_calls
|
||||||
end
|
end
|
||||||
return json.encode(unified)
|
return json.encode(unified)
|
||||||
|
|||||||
@ -112,7 +112,27 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||||
if not ok then return "" end
|
if not ok then return "" end
|
||||||
|
|
||||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||||
|
-- (the final usage chunk). Preserve it; the gateway emits it as the
|
||||||
|
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
|
||||||
|
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||||
|
local uses = nil
|
||||||
|
if type(chunk.usage) == "table" then
|
||||||
|
uses = {
|
||||||
|
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||||
|
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||||
|
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not chunk.choices or #chunk.choices == 0 then
|
||||||
|
if uses ~= nil then
|
||||||
|
-- usage-only chunk is not a content/finish signal; the gateway
|
||||||
|
-- emits its own terminal stop chunk and merges this usage.
|
||||||
|
return json.encode({ usage = uses, done = false })
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
local delta = chunk.choices[1].delta or {}
|
local delta = chunk.choices[1].delta or {}
|
||||||
local fr = chunk.choices[1].finish_reason
|
local fr = chunk.choices[1].finish_reason
|
||||||
|
|
||||||
@ -120,6 +140,9 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
content = delta.content or "",
|
content = delta.content or "",
|
||||||
done = (fr ~= nil)
|
done = (fr ~= nil)
|
||||||
}
|
}
|
||||||
|
if uses ~= nil then
|
||||||
|
unified.usage = uses
|
||||||
|
end
|
||||||
if delta.reasoning_content then
|
if delta.reasoning_content then
|
||||||
unified.reasoning_content = delta.reasoning_content
|
unified.reasoning_content = delta.reasoning_content
|
||||||
end
|
end
|
||||||
|
|||||||
@ -135,6 +135,39 @@ func TestOpenCodeAdapterFingerprint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeNormalizesRoles(t *testing.T) {
|
||||||
|
vm := NewVM(freshAdapterDir(t))
|
||||||
|
if err := vm.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer vm.Stop()
|
||||||
|
// zen 上游只接受 system/user/assistant/tool/latest_reminder;
|
||||||
|
// OpenAI 系客户端发 `developer`(Claude Code 还会用 `function`)必须归一化成 system。
|
||||||
|
body := `{"model":"x","messages":[
|
||||||
|
{"role":"developer","content":"you are helpful"},
|
||||||
|
{"role":"system","content":"sys"},
|
||||||
|
{"role":"user","content":"hi"},
|
||||||
|
{"role":"assistant","content":"ok"},
|
||||||
|
{"role":"tool","tool_call_id":"t1","content":"{\"ok\":true}"}
|
||||||
|
]}`
|
||||||
|
out, err := vm.Transform("opencode", "transform_request", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `"developer"`) {
|
||||||
|
t.Fatalf("developer role must be normalized: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"system"`) || !strings.Contains(out, `"user"`) {
|
||||||
|
t.Fatalf("allowed roles must survive: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"assistant"`) {
|
||||||
|
t.Fatalf("assistant role must survive: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"tool"`) {
|
||||||
|
t.Fatalf("tool role must survive: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenCodeStripsMultiModalParts(t *testing.T) {
|
func TestOpenCodeStripsMultiModalParts(t *testing.T) {
|
||||||
vm := NewVM(freshAdapterDir(t))
|
vm := NewVM(freshAdapterDir(t))
|
||||||
if err := vm.Start(); err != nil {
|
if err := vm.Start(); err != nil {
|
||||||
|
|||||||
@ -901,20 +901,52 @@ func standardSSEChunk(data string) string {
|
|||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
FinishReason *string `json:"finish_reason"`
|
FinishReason *string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
UpstreamUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
Prompt int `json:"prompt"`
|
||||||
|
Completion int `json:"completion"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"usage"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if len(raw.Choices) == 0 {
|
if len(raw.Choices) == 0 && raw.UpstreamUsage.Total == 0 && raw.UpstreamUsage.TotalTokens == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
var usage *types.TokenUsage
|
||||||
|
pu := raw.UpstreamUsage
|
||||||
|
if pu.Total > 0 || pu.TotalTokens > 0 {
|
||||||
|
usage = &types.TokenUsage{
|
||||||
|
Prompt: pickFirst(pu.PromptTokens, pu.Prompt),
|
||||||
|
Completion: pickFirst(pu.CompletionTokens, pu.Completion),
|
||||||
|
Total: pickFirst(pu.TotalTokens, pu.Total),
|
||||||
|
}
|
||||||
|
}
|
||||||
out, _ := json.Marshal(types.UnifiedChunk{
|
out, _ := json.Marshal(types.UnifiedChunk{
|
||||||
Content: raw.Choices[0].Delta.Content,
|
Content: func() string {
|
||||||
Done: raw.Choices[0].FinishReason != nil,
|
if len(raw.Choices) > 0 {
|
||||||
|
return raw.Choices[0].Delta.Content
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
Done: len(raw.Choices) > 0 && raw.Choices[0].FinishReason != nil,
|
||||||
|
Usage: usage,
|
||||||
})
|
})
|
||||||
return string(out)
|
return string(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickFirst returns a if non-zero, else b (for usage keys that may appear in
|
||||||
|
// either standard *_tokens or legacy short form).
|
||||||
|
func pickFirst(a, b int) int {
|
||||||
|
if a != 0 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
func truncate(s string, n int) string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@ -211,6 +211,12 @@ type SourceStatus struct {
|
|||||||
FailCount int `json:"fail_count,omitempty"`
|
FailCount int `json:"fail_count,omitempty"`
|
||||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||||
Permanent bool `json:"permanent,omitempty"`
|
Permanent bool `json:"permanent,omitempty"`
|
||||||
|
// RecentOK / RecentErr count real gateway requests served by this source
|
||||||
|
// within the last 300s (drives the status column so a source that is
|
||||||
|
// actually serving traffic can never be shown as down just because a
|
||||||
|
// probe was rate-limited by the upstream).
|
||||||
|
RecentOK int64 `json:"recent_ok,omitempty"`
|
||||||
|
RecentErr int64 `json:"recent_err,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProbeAll runs a live reachability check for every provider (in parallel).
|
// ProbeAll runs a live reachability check for every provider (in parallel).
|
||||||
|
|||||||
@ -85,6 +85,28 @@ type TokenUsage struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalJSON emits both the legacy short keys (prompt/completion/total, used
|
||||||
|
// by the internal unified representation and older clients) and the OpenAI
|
||||||
|
// standard keys (prompt_tokens/completion_tokens/total_tokens). Standard
|
||||||
|
// clients such as DSH and DevEco Code read the *_tokens fields.
|
||||||
|
func (t TokenUsage) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
Prompt int `json:"prompt"`
|
||||||
|
Completion int `json:"completion"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}{
|
||||||
|
PromptTokens: t.Prompt,
|
||||||
|
CompletionTokens: t.Completion,
|
||||||
|
TotalTokens: t.Total,
|
||||||
|
Prompt: t.Prompt,
|
||||||
|
Completion: t.Completion,
|
||||||
|
Total: t.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type ImageData struct {
|
type ImageData struct {
|
||||||
B64JSON string `json:"b64_json,omitempty"`
|
B64JSON string `json:"b64_json,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
@ -116,6 +138,11 @@ type UnifiedChunk struct {
|
|||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
|
// Usage carries the upstream token usage when the stream chunk provides
|
||||||
|
// it (OpenAI-style streams attach usage to some chunks; the final one
|
||||||
|
// often has empty choices). Gateway uses it to emit exact usage in the
|
||||||
|
// final stream chunk instead of estimates.
|
||||||
|
Usage *TokenUsage `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta passed to Lua build_headers hook
|
// Meta passed to Lua build_headers hook
|
||||||
|
|||||||
Reference in New Issue
Block a user