🐛 fix: surface model list fetch failures (#15753)

This commit is contained in:
YuTengjing
2026-06-13 23:05:44 +08:00
committed by GitHub
parent 55a969a3c1
commit 39bce329fd
49 changed files with 1668 additions and 732 deletions
+72 -12
View File
@@ -19,9 +19,23 @@ also run as full cloud automation. Every test session follows the same
four-step contract:
```
Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
```
## Step -1 — Plan approval for non-trivial tests
Skip directly to Step 0 if: the test is a single re-run after a fix, the plan
was already agreed on, or the user gave exact commands.
Otherwise, propose a test plan (surface, cases, expected evidence, assumptions)
and use the runtime structured question tool (`request_user_input` /
ask-user-question equivalent) with two fixed choices:
1. `开始执行 (Recommended)` — 测试方案没问题,开始执行
2. `先讨论下` — 方案有问题,先讨论下
Wait for the user's choice before proceeding.
## Step 0 — Environment setup + auth check (mandatory)
Step 0 is about getting the environment ready: **dependencies are healthy**
@@ -29,6 +43,36 @@ and **auth is green**. A test run that dies halfway on a missing dependency or
a login wall wastes the whole session — clear both gates BEFORE writing a
single test step.
### 0.0 Resolve the current test environment
Before starting a dev server, checking auth, opening agent-browser, or writing
test steps, print and confirm the current local test environment:
```bash
./.agents/skills/agent-testing/scripts/test-env.sh
```
This command is the source of truth for local test ports. It reads the current
shell plus `.env` files using the same precedence as `scripts/runWithEnv.mts`,
then prints:
- `APP_URL`
- `PORT`
- `SERVER_URL`
- `AUTH_TRUSTED_ORIGINS`
- `SPA_PORT`
- `MOBILE_SPA_PORT`
- `DESKTOP_PORT`
For commands that need these values, export them from the same resolver:
```bash
eval "$(./.agents/skills/agent-testing/scripts/test-env.sh --exports)"
```
Do not rely on hard-coded port tables. If the printed values do not match the
running dev server, fix/export the env first, then continue.
### 0.1 Dependencies are installed — root AND standalone apps
The root pnpm workspace does **NOT** cover every app: `pnpm-workspace.yaml`
@@ -142,27 +186,35 @@ eval "$(../.agents/skills/agent-testing/scripts/init-dev-env.sh env)"
BASE_URL=http://localhost:3010 HEADLESS=true bun run test:smoke
```
### 0.4 Auth is green
### 0.4 Auth is green for the selected surface
**Auth is the gate for all automated testing.**
**Auth is the gate for automated testing, but the gate is surface-scoped.**
Pick the intended surface first when it is already clear from the task, then
check only that surface. Do not block a Web test on CLI device-code auth or an
Electron login state unless the test spans those surfaces.
```bash
./.agents/skills/agent-testing/scripts/setup-auth.sh status
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
```
| Surface | Mechanism | One-key path | Standard check |
| -------- | ------------------------------------------------- | ------------------------------ | -------------------------- |
| CLI | OIDC Device Code Flow (`apps/cli/.lobehub-dev`) | `setup-auth.sh cli` | `setup-auth.sh status` |
| Web | better-auth cookie injection into `agent-browser` | `pbpaste \| setup-auth.sh web` | `setup-auth.sh web-verify` |
| Electron | App's own persistent login state | Log in once in the app | `app-probe.sh auth` |
| Bot | Native apps already logged in | — | per-platform screenshot |
Use `status` with no `--surface` only for cross-surface test plans.
| Surface | Mechanism | One-key path | Standard check |
| -------- | ------------------------------------------------- | ------------------------------ | ----------------------------------------- |
| CLI | OIDC Device Code Flow (`apps/cli/.lobehub-dev`) | `setup-auth.sh cli` | `setup-auth.sh status --surface cli` |
| Web | better-auth cookie injection into `agent-browser` | `pbpaste \| setup-auth.sh web` | `setup-auth.sh status --surface web` |
| Electron | App's own persistent login state | Log in once in the app | `setup-auth.sh status --surface electron` |
| Bot | Native apps already logged in | — | per-platform screenshot |
Login-state checks are standardized — do NOT hand-roll `window.__LOBE_STORES`
eval snippets; use `scripts/app-probe.sh auth` (returns `{ isSignedIn, userId }`,
works for Electron CDP and web sessions via `AB_TARGET`).
If `status` is not all green, fix auth first (the steps that need a human must be
requested from the user explicitly). Full background and failure modes:
For Web tests, the test surface is always `agent-browser --session lobehub-dev`.
The user's normal Chrome is only a source for copying the Cookie header when
`status --surface web` fails. If Chrome is already logged in, do not open a
login page; verify agent-browser first, then request the Network `Cookie:`
header only if that verification fails. Full background and failure modes:
[references/auth.md](./references/auth.md).
## Step 1 — Pick the surface by change scope
@@ -237,6 +289,7 @@ All under `.agents/skills/agent-testing/scripts/`:
| Script | Usage |
| ------------------------- | ---------------------------------------------------------------------------- |
| `test-env.sh` | Print/export the resolved local test env and ports |
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
| `init-dev-env.sh` | Self-contained local dev env (`setup-db` / `seed-user` / `dev-next` / `dev`) |
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
@@ -284,6 +337,13 @@ Two hard rules worth front-loading:
must use Markdown image syntax like `![case 1](assets/case1.png)`. Do not
use bare file paths, Markdown links, or local file links as the primary
visual evidence; those make the report unreadable without opening each asset.
- **Final replies must include visual evidence links.** When a run includes UI
screenshots or GIFs, include the report directory and the most important
visual artifacts in the final chat response. Each item must include a stable
label, an evidence caption describing the observed UI outcome, and a
repo-relative path, for example:
`[Image #1 - error toast shows provider auth failure](<report-dir>/assets/foo.png)`.
Use repo-relative paths, not absolute paths.
- **Time-based behavior needs a GIF, not a screenshot.** If a case asserts
change over time (streaming output, a ticking timer, loading states,
animations), record it with `scripts/record-gif.sh` and embed the GIF —
+39 -36
View File
@@ -1,20 +1,31 @@
# Auth Setup for Local Agent Testing
**Auth is the gate for all automated testing.** Prepare and verify it before
writing any test step. The one-stop entry point is:
**Auth is the gate for all automated testing.** Complete
[Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) first so
`SERVER_URL` and ports are resolved, then verify auth before writing any test
step.
Initialize helpers first:
```bash
SCRIPT=".agents/skills/agent-testing/scripts/setup-auth.sh"
$SCRIPT status # check server + CLI + web auth readiness
$SCRIPT cli # interactive CLI device-code login (must be run by the user)
pbpaste | $SCRIPT web # inject a copied Cookie header into the agent-browser session
$SCRIPT web-verify # live-check that the agent-browser session is authenticated
SCRIPT="./.agents/skills/agent-testing/scripts/setup-auth.sh"
TEST_ENV="./.agents/skills/agent-testing/scripts/test-env.sh"
eval "$($TEST_ENV --exports)"
```
`SERVER_URL` defaults to `http://localhost:3010` (this repo's `dev:next` port).
Override it when testing against another server (e.g. `SERVER_URL=http://localhost:3011`
in the cloud repo).
Quick reference after initialization:
| Command | Purpose |
| ------------------------------ | -------------------------------------------------- |
| `$SCRIPT status` | Check all surfaces (server + CLI + web + Electron) |
| `$SCRIPT status --surface web` | Check only the Web surface gate |
| `$SCRIPT cli` | Interactive CLI device-code login (user must run) |
| `$SCRIPT open-chrome` | Open Chrome at `SERVER_URL` with DevTools |
| `pbpaste \| $SCRIPT web` | Inject a copied Cookie header into agent-browser |
| `$SCRIPT web-verify` | Live-check agent-browser session auth |
Use `localhost` for Web auth; better-auth cookies are stored for `localhost`,
not `127.0.0.1`.
## Per-surface overview
@@ -45,6 +56,11 @@ cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server htt
## Web — better-auth cookie injection (agent-browser)
The Web test surface is `agent-browser --session lobehub-dev`. The user's
ordinary Chrome is only a cookie source; Chrome screenshots, Chrome Network
records, and Chrome logged-in state do not prove the agent-browser test session
is authenticated.
`agent-browser --headed` on macOS often creates the Chromium window off-screen —
the user can't see or interact with it, so manual login inside the agent-browser
session fails. Instead, copy the **better-auth session cookie** out of the
@@ -53,31 +69,18 @@ user's own logged-in Chrome and inject it as a Playwright-style state file.
Do **not** use this on production URLs — only local dev. Treat the cookie as a
secret: don't paste it into shared logs, PRs, or commit it anywhere.
### One-key path
### Web — decision flow
1. Ask the user to copy the Cookie header **from a Network request, NOT
`document.cookie`** (`document.cookie` cannot see HttpOnly cookies, which is
exactly where better-auth puts its session):
- Open the logged-in tab (`http://localhost:<port>/…`) in Chrome.
- `Cmd+Option+I`**Network** tab → refresh → click any same-origin request.
- Under **Request Headers**, right-click the `Cookie:` line → **Copy value**.
2. Inject and verify in one shot:
```bash
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web
```
The script filters the header down to the better-auth cookies
(`better-auth.session_token`, `better-auth.state`), builds the Playwright
`storageState` JSON, loads it into the `agent-browser` session (default name
`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
2. Not green → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
3. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
4. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
### Using the authenticated session
```bash
agent-browser --session lobehub-dev open "http://localhost:3010/"
agent-browser --session lobehub-dev open "$SERVER_URL/"
agent-browser --session lobehub-dev snapshot -i | head -20
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
```
### Notes
@@ -90,12 +93,12 @@ agent-browser --session lobehub-dev snapshot -i | head -20
### Common failure modes
| Symptom | Cause | Fix |
| --------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| Script reports `no better-auth cookies found` | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
| Symptom | Cause | Fix |
| --------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| Script reports `no better-auth cookies found` | User pasted the wrong value, or the cookie parser regressed | Keep the raw `Cookie:` header as-is; run `scripts/setup-auth.test.sh` if the input looks valid |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
## Electron
@@ -3,22 +3,44 @@
Single source of truth for starting / restarting the backend that all test
surfaces (CLI, Electron, Web) hit.
## Resolve ports first
Run `test-env.sh` as described in
[SKILL.md Step 0.0](../SKILL.md#00-resolve-the-current-test-environment)
before starting or probing any local test surface.
## Ports & modes
| Command | What it runs | Port |
| ------------------- | --------------------------------------------------------- | --------------------------------- |
| `pnpm run dev:next` | Next.js backend (API + auth) | `3010` |
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `3010` (API) + SPA on `9876` |
| `bun run dev:spa` | Vite SPA only, proxies API to `3010` | `9876` (prints a Debug Proxy URL) |
| Command | What it runs | Port source |
| ------------------- | --------------------------------------------------------- | ------------------- |
| `pnpm run dev:next` | Next.js backend (API + auth) | `PORT` |
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `PORT` + `SPA_PORT` |
| `bun run dev:spa` | Vite SPA only, proxies API to `PORT` | `SPA_PORT` |
In the **cloud repo** (where this repo is the `lobehub/` submodule) the dev
server conventionally runs on `3011` — set `SERVER_URL=http://localhost:3011`
for the scripts in this skill when testing there.
In the **cloud repo** (where this repo is the `lobehub/` submodule), local
worktree names map to fallback defaults only when `.env` and shell env do not
provide values:
| Workspace directory | Default `SERVER_URL` |
| ------------------- | -------------------------------- |
| `lobehub` | `http://localhost:3010` |
| `lobehub-cloud` | `http://localhost:3020` |
| `lobehub-cloud-1` | `http://localhost:3021` |
| `lobehub-cloud-N` | `http://localhost:$((3020 + N))` |
`test-env.sh` and `setup-auth.sh` both use the resolved env first and these
worktree defaults only as fallback. Treat the dev-server terminal output as the
final source of truth when testing a non-standard port, then export it for every
agent-testing command:
```bash
export SERVER_URL=http://localhost:<port-from-dev-output>
```
## Health check
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3010/
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```
## Start / restart
@@ -39,7 +61,7 @@ bun run dev
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
# Restart — required to pick up server-side code changes
lsof -ti:3010 | xargs kill
lsof -ti:"$PORT" | xargs kill
pnpm run dev:next
# or, when no root .env exists:
# ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
@@ -64,7 +64,10 @@ output):
not acceptable as primary visual evidence.
4. **Set the verdict** in both `report.md` and `result.json`, then link the
report directory in your final answer to the user.
report directory in your final answer to the user. If UI evidence exists,
list the key screenshot/GIF links in the final chat response. Use Markdown
link text as the evidence caption, for example:
`[Image #1 - observed outcome](<report-dir>/assets/case1.png)`.
## Report language (hard rule)
@@ -5,29 +5,101 @@
# test step. Background and failure modes: ../references/auth.md
#
# Usage:
# setup-auth.sh status # check server + CLI + web auth readiness
# setup-auth.sh status # check server + CLI + web + Electron readiness
# setup-auth.sh status --surface web # check only the Web surface gate
# setup-auth.sh cli # interactive CLI device-code login (run by a human)
# setup-auth.sh open-chrome # open SERVER_URL in Chrome and show DevTools
# setup-auth.sh web # stdin = Cookie header -> inject into agent-browser session
# setup-auth.sh web-verify # live-check the agent-browser session is authenticated
#
# Env:
# SERVER_URL (default http://localhost:3010) dev server under test
# SERVER_URL (default from test-env.sh) dev server under test
# SESSION (default lobehub-dev) agent-browser session name
# AUTH_DIR (default ~/.lobehub-agent-testing) where web state is persisted
set -euo pipefail
SERVER_URL="${SERVER_URL:-http://localhost:3010}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
workspace_root_for_port() {
local root="$REPO_ROOT"
local name
name="$(basename "$root")"
if [[ "$name" == "lobehub" ]]; then
local parent
parent="$(cd "$root/.." && pwd)"
local parent_name
parent_name="$(basename "$parent")"
if [[ "$parent_name" == lobehub-cloud* ]]; then
root="$parent"
fi
fi
printf '%s\n' "$root"
}
default_server_url() {
local env_resolver resolved
env_resolver="$(dirname "${BASH_SOURCE[0]}")/test-env.sh"
if [[ -x "$env_resolver" ]]; then
resolved="$("$env_resolver" --value SERVER_URL 2> /dev/null || true)"
if [[ -n "$resolved" ]]; then
printf '%s\n' "$resolved"
return 0
fi
fi
local root name suffix port
root="$(workspace_root_for_port)"
name="$(basename "$root")"
case "$name" in
lobehub-cloud)
port=3020
;;
lobehub-cloud-*)
suffix="${name#lobehub-cloud-}"
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
port=$((3020 + 10#$suffix))
else
port=3010
fi
;;
*)
port=3010
;;
esac
printf 'http://localhost:%s\n' "$port"
}
SERVER_URL="${SERVER_URL:-$(default_server_url)}"
SESSION="${SESSION:-lobehub-dev}"
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
STATE_FILE="$AUTH_DIR/web-state.json"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
CLI_HOME="$REPO_ROOT/apps/cli/.lobehub-dev"
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
note() { printf ' %s\n' "$1"; }
usage() {
cat << EOF
Usage:
$0 status [--surface all|cli|web|electron]
$0 cli
$0 open-chrome [--dry-run]
$0 web
$0 web-verify
Env:
SERVER_URL=$SERVER_URL
SESSION=$SESSION
AUTH_DIR=$AUTH_DIR
EOF
}
check_server() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/" 2> /dev/null || true)
@@ -54,13 +126,23 @@ check_cli() {
check_web() {
if [[ -f "$STATE_FILE" ]]; then
ok "web auth state saved ($STATE_FILE)"
note "live-verify: $0 web-verify"
else
bad "no web auth state for agent-browser"
note "copy the Cookie header from Chrome DevTools (Network tab), then:"
note "pbpaste | $0 web (see references/auth.md)"
return 1
fi
cmd_web_verify --skip-server-check
}
check_agent_browser() {
if command -v agent-browser > /dev/null 2>&1; then
ok "agent-browser available"
else
bad "agent-browser command not found"
note "install or expose agent-browser before Web/Electron UI testing"
return 1
fi
}
check_electron() {
@@ -84,16 +166,75 @@ check_electron() {
}
cmd_status() {
echo "agent-testing auth status (SERVER_URL=$SERVER_URL):"
local surface="all"
while [[ $# -gt 0 ]]; do
case "$1" in
--surface)
if [[ $# -lt 2 ]]; then
echo "--surface requires one of: all, cli, web, electron" >&2
return 2
fi
surface="${2:-}"
shift 2
;;
--surface=*)
surface="${1#*=}"
shift
;;
all|cli|web|electron)
surface="$1"
shift
;;
-h|--help)
usage
return 0
;;
*)
echo "unknown status option: $1" >&2
usage >&2
return 2
;;
esac
done
case "$surface" in
all|cli|web|electron) ;;
"")
echo "--surface requires one of: all, cli, web, electron" >&2
return 2
;;
*)
echo "unknown surface: $surface" >&2
usage >&2
return 2
;;
esac
echo "agent-testing auth status (surface=$surface, SERVER_URL=$SERVER_URL):"
local rc=0
check_server || rc=1
check_cli || rc=1
check_web || rc=1
check_electron || rc=1
case "$surface" in
all)
check_server || rc=1
check_cli || rc=1
check_web || rc=1
check_electron || rc=1
;;
cli)
check_server || rc=1
check_cli || rc=1
;;
web)
check_server || rc=1
check_web || rc=1
;;
electron)
check_electron || rc=1
;;
esac
if [[ $rc -eq 0 ]]; then
echo "all green — safe to start automated testing."
echo "$surface auth green — safe to start automated testing on this surface."
else
echo "auth NOT ready — fix the ✘ items before writing any test step."
echo "$surface auth NOT ready — fix the ✘ items before writing any test step."
fi
return $rc
}
@@ -105,23 +246,90 @@ cmd_cli() {
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server "$SERVER_URL"
}
cmd_open_chrome() {
local mode="${1:-}"
if [[ "$mode" != "" && "$mode" != "--dry-run" ]]; then
echo "unknown open-chrome option: $mode" >&2
usage >&2
return 2
fi
if [[ "$mode" == "--dry-run" ]]; then
echo "would open Google Chrome at $SERVER_URL/"
echo "would press Cmd+Option+I to open DevTools"
echo "would open DevTools command menu and run 'Show Network'"
return 0
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
bad "open-chrome is macOS-only"
note "open $SERVER_URL/ in your browser and open DevTools manually"
return 1
fi
if ! command -v osascript > /dev/null 2>&1; then
bad "osascript not found"
note "open $SERVER_URL/ in Chrome and press Cmd+Option+I manually"
return 1
fi
SERVER_URL="$SERVER_URL" osascript << 'OSA'
set targetUrl to (system attribute "SERVER_URL") & "/"
tell application "Google Chrome"
activate
if (count of windows) = 0 then
make new window
end if
tell front window to make new tab with properties {URL:targetUrl}
end tell
delay 1
tell application "System Events"
tell process "Google Chrome"
set frontmost to true
keystroke "i" using {command down, option down}
delay 1
keystroke "p" using {command down, shift down}
delay 0.2
keystroke "Show Network"
key code 36
end tell
end tell
OSA
ok "opened Chrome at $SERVER_URL/ and requested DevTools Network panel"
}
# Build a Playwright storageState file from a raw Cookie header on stdin,
# keeping only the better-auth cookies. See references/auth.md for why the
# header must come from a Network request (HttpOnly) and why httpOnly=false.
cmd_web() {
mkdir -p "$AUTH_DIR"
python3 - "$STATE_FILE" << 'PY'
import json, sys, time
local raw
raw="$(cat)"
COOKIE_INPUT="$raw" python3 - "$STATE_FILE" << 'PY'
import json, os, sys, time
raw = sys.stdin.read().strip()
if raw.lower().startswith("cookie:"):
raw = raw.split(":", 1)[1].strip()
raw = os.environ.get("COOKIE_INPUT", "").strip()
cookie_lines = []
for line in raw.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.lower().startswith("cookie:"):
cookie_lines.append(stripped.split(":", 1)[1].strip())
else:
cookie_lines.append(stripped)
WANTED = {"better-auth.session_token", "better-auth.state"}
raw = "; ".join(cookie_lines)
WANTED = {"better-auth.session_token", "better-auth.session_data", "better-auth.state"}
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
cookies = []
for pair in raw.split("; "):
for pair in raw.split(";"):
pair = pair.strip()
if "=" not in pair:
continue
name, _, value = pair.partition("=")
@@ -146,14 +354,35 @@ with open(sys.argv[1], "w") as f:
json.dump({"cookies": cookies, "origins": []}, f, indent=2)
print(f"wrote {len(cookies)} cookie(s) to {sys.argv[1]}")
PY
agent-browser --session "$SESSION" state load "$STATE_FILE"
cmd_web_verify
}
cmd_web_verify() {
agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null
local skip_server_check="${1:-}"
if [[ "$skip_server_check" != "--skip-server-check" ]]; then
check_server || return 1
fi
if [[ ! -f "$STATE_FILE" ]]; then
bad "no web auth state for agent-browser"
note "copy the Cookie header from Chrome DevTools (Network tab), then:"
note "pbpaste | $0 web"
return 1
fi
check_agent_browser || return 1
if ! agent-browser --session "$SESSION" state load "$STATE_FILE" > /dev/null; then
bad "failed to load web auth state into agent-browser session '$SESSION'"
return 1
fi
if ! agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null; then
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
return 1
fi
local url
url=$(agent-browser --session "$SESSION" get url)
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
if [[ -z "$url" ]]; then
bad "agent-browser session '$SESSION' did not report a current URL"
return 1
fi
if [[ "$url" == *"/signin"* || "$url" == *"/login"* ]]; then
bad "agent-browser session '$SESSION' NOT authenticated (landed on $url)"
note "re-copy the Cookie header and re-run: pbpaste | $0 web"
@@ -163,10 +392,18 @@ cmd_web_verify() {
}
case "${1:-status}" in
status) cmd_status ;;
status)
shift || true
cmd_status "$@"
;;
cli) cmd_cli ;;
open-chrome)
shift || true
cmd_open_chrome "$@"
;;
web) cmd_web ;;
web-verify) cmd_web_verify ;;
-h|--help) usage ;;
*)
echo "Usage: $0 {status|cli|web|web-verify}" >&2
exit 2
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Smoke tests for setup-auth.sh. Uses a temporary agent-browser stub and local
# HTTP server, so it does not need real browser auth.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$SCRIPT_DIR/setup-auth.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains() {
local file="$1"
local text="$2"
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
}
tmp_dir="$(mktemp -d)"
server_pid=""
cleanup() {
if [[ -n "$server_pid" ]]; then
kill "$server_pid" > /dev/null 2>&1 || true
wait "$server_pid" > /dev/null 2>&1 || true
fi
rm -rf "$tmp_dir"
}
trap cleanup EXIT
port="$(python3 - << 'PY'
import socket
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
sock.close()
PY
)"
python3 -m http.server "$port" --bind localhost --directory "$tmp_dir" \
> "$tmp_dir/http.log" 2>&1 &
server_pid="$!"
server_url="http://localhost:$port"
for _ in {1..50}; do
if curl -s -o /dev/null "$server_url/"; then
break
fi
sleep 0.1
done
curl -s -o /dev/null "$server_url/" || fail "test HTTP server did not start"
mkdir -p "$tmp_dir/bin" "$tmp_dir/auth"
cat > "$tmp_dir/bin/agent-browser" << 'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--session" ]]; then
shift 2
fi
case "${1:-}" in
state)
[[ "${2:-}" == "load" ]] || exit 2
[[ -f "${3:-}" ]] || exit 1
;;
open)
printf '%s\n' "${2:-}" > "${AGENT_BROWSER_URL_FILE:?}"
;;
get)
[[ "${2:-}" == "url" ]] || exit 2
cat "${AGENT_BROWSER_URL_FILE:?}"
;;
*)
echo "unexpected agent-browser command: $*" >&2
exit 2
;;
esac
SH
chmod +x "$tmp_dir/bin/agent-browser"
export PATH="$tmp_dir/bin:$PATH"
export AUTH_DIR="$tmp_dir/auth"
export SESSION="setup-auth-test"
export SERVER_URL="$server_url"
export AGENT_BROWSER_URL_FILE="$tmp_dir/current-url"
cookie_header="Cookie: foo=bar; better-auth.session_token=test.token; better-auth.session_data=encoded%3D; theme=dark"
printf '%s\n' "$cookie_header" | "$SCRIPT" web > "$tmp_dir/web.out"
python3 - "$AUTH_DIR/web-state.json" << 'PY'
import json, sys
with open(sys.argv[1]) as f:
state = json.load(f)
names = {cookie["name"] for cookie in state["cookies"]}
expected = {"better-auth.session_token", "better-auth.session_data"}
if names != expected:
raise SystemExit(f"unexpected cookies: {sorted(names)}")
PY
"$SCRIPT" status --surface web > "$tmp_dir/status.out"
assert_contains "$tmp_dir/status.out" "surface=web"
assert_contains "$tmp_dir/status.out" "web auth green"
if printf 'foo=bar\n' | "$SCRIPT" web > "$tmp_dir/invalid.out" 2> "$tmp_dir/invalid.err"; then
fail "invalid cookie unexpectedly passed"
fi
assert_contains "$tmp_dir/invalid.err" "no better-auth cookies found"
echo "setup-auth tests passed"
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env bash
# Print the resolved local test environment for agent-testing.
#
# This is intentionally read-only. It mirrors scripts/runWithEnv.mts precedence:
# .env -> .env.$NODE_ENV -> .env.local -> .env.$NODE_ENV.local, then shell env.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
NODE_ENV="${NODE_ENV:-development}"
VALUE_APP_URL=""
VALUE_PORT=""
VALUE_SERVER_URL=""
VALUE_AUTH_TRUSTED_ORIGINS=""
VALUE_SPA_PORT=""
VALUE_MOBILE_SPA_PORT=""
VALUE_DESKTOP_PORT=""
SOURCE_APP_URL=""
SOURCE_PORT=""
SOURCE_SERVER_URL=""
SOURCE_AUTH_TRUSTED_ORIGINS=""
SOURCE_SPA_PORT=""
SOURCE_MOBILE_SPA_PORT=""
SOURCE_DESKTOP_PORT=""
LOADED_ENV_FILES=""
keys() {
printf '%s\n' \
APP_URL \
PORT \
SERVER_URL \
AUTH_TRUSTED_ORIGINS \
SPA_PORT \
MOBILE_SPA_PORT \
DESKTOP_PORT
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
workspace_root() {
local root="$REPO_ROOT"
local name
name="$(basename "$root")"
if [[ "$name" == "lobehub" ]]; then
local parent parent_name
parent="$(cd "$root/.." && pwd)"
parent_name="$(basename "$parent")"
if [[ "$parent_name" == lobehub-cloud* ]]; then
root="$parent"
fi
fi
printf '%s\n' "$root"
}
workspace_offset() {
local name="$1"
case "$name" in
lobehub-cloud)
printf '0\n'
;;
lobehub-cloud-*)
local suffix="${name#lobehub-cloud-}"
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$((10#$suffix))"
else
printf '\n'
fi
;;
*)
printf '\n'
;;
esac
}
default_port() {
local base="$1"
local fallback="$2"
local root name offset
root="$(workspace_root)"
name="$(basename "$root")"
offset="$(workspace_offset "$name")"
if [[ -n "$offset" ]]; then
printf '%s\n' "$((base + offset))"
else
printf '%s\n' "$fallback"
fi
}
url_port() {
local url="$1"
local hostport
hostport="${url#*://}"
hostport="${hostport%%/*}"
if [[ "$hostport" == *:* ]]; then
local port="${hostport##*:}"
if [[ "$port" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$port"
return 0
fi
fi
return 1
}
url_origin() {
local url="$1"
local scheme rest hostport
if [[ "$url" == *"://"* ]]; then
scheme="${url%%://*}"
rest="${url#*://}"
hostport="${rest%%/*}"
printf '%s://%s\n' "$scheme" "$hostport"
else
printf '%s\n' "$url"
fi
}
set_value() {
local key="$1"
local value="$2"
local source="$3"
case "$key" in
APP_URL) VALUE_APP_URL="$value"; SOURCE_APP_URL="$source" ;;
PORT) VALUE_PORT="$value"; SOURCE_PORT="$source" ;;
SERVER_URL) VALUE_SERVER_URL="$value"; SOURCE_SERVER_URL="$source" ;;
AUTH_TRUSTED_ORIGINS) VALUE_AUTH_TRUSTED_ORIGINS="$value"; SOURCE_AUTH_TRUSTED_ORIGINS="$source" ;;
SPA_PORT) VALUE_SPA_PORT="$value"; SOURCE_SPA_PORT="$source" ;;
MOBILE_SPA_PORT) VALUE_MOBILE_SPA_PORT="$value"; SOURCE_MOBILE_SPA_PORT="$source" ;;
DESKTOP_PORT) VALUE_DESKTOP_PORT="$value"; SOURCE_DESKTOP_PORT="$source" ;;
esac
}
value_for() {
case "$1" in
APP_URL) printf '%s\n' "$VALUE_APP_URL" ;;
PORT) printf '%s\n' "$VALUE_PORT" ;;
SERVER_URL) printf '%s\n' "$VALUE_SERVER_URL" ;;
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$VALUE_AUTH_TRUSTED_ORIGINS" ;;
SPA_PORT) printf '%s\n' "$VALUE_SPA_PORT" ;;
MOBILE_SPA_PORT) printf '%s\n' "$VALUE_MOBILE_SPA_PORT" ;;
DESKTOP_PORT) printf '%s\n' "$VALUE_DESKTOP_PORT" ;;
esac
}
source_for() {
case "$1" in
APP_URL) printf '%s\n' "$SOURCE_APP_URL" ;;
PORT) printf '%s\n' "$SOURCE_PORT" ;;
SERVER_URL) printf '%s\n' "$SOURCE_SERVER_URL" ;;
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$SOURCE_AUTH_TRUSTED_ORIGINS" ;;
SPA_PORT) printf '%s\n' "$SOURCE_SPA_PORT" ;;
MOBILE_SPA_PORT) printf '%s\n' "$SOURCE_MOBILE_SPA_PORT" ;;
DESKTOP_PORT) printf '%s\n' "$SOURCE_DESKTOP_PORT" ;;
esac
}
is_tracked_key() {
case "$1" in
APP_URL|PORT|SERVER_URL|AUTH_TRUSTED_ORIGINS|SPA_PORT|MOBILE_SPA_PORT|DESKTOP_PORT) return 0 ;;
*) return 1 ;;
esac
}
parse_env_file() {
local file="$1"
local root="$2"
local label="${file#$root/}"
local line key value
[[ -f "$file" ]] || return 0
if [[ -z "$LOADED_ENV_FILES" ]]; then
LOADED_ENV_FILES="$label"
else
LOADED_ENV_FILES="$LOADED_ENV_FILES, $label"
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "$line")"
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ "$line" == export[[:space:]]* ]]; then
line="$(trim "${line#export}")"
fi
[[ "$line" == *=* ]] || continue
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
is_tracked_key "$key" || continue
if [[ "$value" == \"*\" && "$value" == *\" && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'* && "$value" == *\' && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
fi
set_value "$key" "$value" "$label"
done < "$file"
}
apply_env_files() {
local root="$1"
parse_env_file "$root/.env" "$root"
parse_env_file "$root/.env.$NODE_ENV" "$root"
parse_env_file "$root/.env.local" "$root"
parse_env_file "$root/.env.$NODE_ENV.local" "$root"
}
apply_shell_overrides() {
local key value
while IFS= read -r key; do
if [[ -n "${!key+x}" ]]; then
value="${!key}"
set_value "$key" "$value" "shell"
fi
done < <(keys)
}
resolve_defaults() {
local app_port spa_port mobile_spa_port desktop_port
app_port="$(default_port 3020 3010)"
spa_port="$(default_port 9800 9876)"
mobile_spa_port="$(default_port 3810 3012)"
desktop_port="$(default_port 3030 3015)"
if [[ -z "$VALUE_APP_URL" ]]; then
set_value APP_URL "http://localhost:$app_port" "inferred"
fi
if [[ -z "$VALUE_PORT" ]]; then
if app_port="$(url_port "$VALUE_APP_URL")"; then
set_value PORT "$app_port" "inferred from APP_URL"
else
set_value PORT "$(default_port 3020 3010)" "inferred"
fi
fi
if [[ -z "$VALUE_SERVER_URL" ]]; then
set_value SERVER_URL "$VALUE_APP_URL" "from APP_URL"
fi
if [[ -z "$VALUE_SPA_PORT" ]]; then
set_value SPA_PORT "$spa_port" "inferred"
fi
if [[ -z "$VALUE_MOBILE_SPA_PORT" ]]; then
set_value MOBILE_SPA_PORT "$mobile_spa_port" "inferred"
fi
if [[ -z "$VALUE_DESKTOP_PORT" ]]; then
set_value DESKTOP_PORT "$desktop_port" "inferred"
fi
if [[ -z "$VALUE_AUTH_TRUSTED_ORIGINS" ]]; then
set_value AUTH_TRUSTED_ORIGINS "$(url_origin "$VALUE_APP_URL"),http://localhost:$VALUE_SPA_PORT" "inferred"
fi
}
contains_origin() {
local list="$1"
local expected="$2"
local item
IFS=',' read -r -a items <<< "$list"
for item in "${items[@]}"; do
item="$(trim "$item")"
[[ "$item" == "$expected" ]] && return 0
done
return 1
}
print_exports() {
local key value
while IFS= read -r key; do
value="$(value_for "$key")"
printf 'export %s=%q\n' "$key" "$value"
done < <(keys)
}
print_value() {
local key="$1"
if ! is_tracked_key "$key"; then
echo "unknown key: $key" >&2
exit 2
fi
value_for "$key"
}
print_human() {
local root="$1"
local key value source
echo "agent-testing test env:"
printf ' workspace: %s\n' "$root"
printf ' NODE_ENV: %s\n' "$NODE_ENV"
printf ' env files: %s\n' "${LOADED_ENV_FILES:-none}"
echo
echo "resolved values:"
while IFS= read -r key; do
value="$(value_for "$key")"
source="$(source_for "$key")"
printf ' %-22s %s (%s)\n' "$key=$value" "" "$source"
done < <(keys)
echo
echo "checks:"
local app_origin spa_origin app_port
app_origin="$(url_origin "$VALUE_APP_URL")"
spa_origin="http://localhost:$VALUE_SPA_PORT"
if app_port="$(url_port "$VALUE_APP_URL")" && [[ "$app_port" == "$VALUE_PORT" ]]; then
printf ' OK PORT matches APP_URL (%s)\n' "$VALUE_PORT"
else
printf ' WARN PORT (%s) does not match APP_URL (%s)\n' "$VALUE_PORT" "$VALUE_APP_URL"
fi
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$app_origin"; then
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$app_origin"
else
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$app_origin"
fi
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$spa_origin"; then
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$spa_origin"
else
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$spa_origin"
fi
}
usage() {
cat << EOF
Usage:
$0 # print resolved test environment
$0 --exports # print source-able export lines
$0 --value KEY # print one resolved value
Tracked keys:
APP_URL PORT SERVER_URL AUTH_TRUSTED_ORIGINS SPA_PORT MOBILE_SPA_PORT DESKTOP_PORT
EOF
}
ROOT="$(workspace_root)"
apply_env_files "$ROOT"
apply_shell_overrides
resolve_defaults
case "${1:-}" in
"")
print_human "$ROOT"
;;
--exports)
print_exports
;;
--value)
print_value "${2:-}"
;;
-h|--help)
usage
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Smoke tests for test-env.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
local actual="$1"
local expected="$2"
[[ "$actual" == "$expected" ]] || fail "expected '$expected', got '$actual'"
}
assert_contains() {
local file="$1"
local text="$2"
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
}
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
mkdir -p "$tmp_dir/lobehub-cloud-1/.agents/skills" "$tmp_dir/lobehub/.agents/skills"
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing"
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub/.agents/skills/agent-testing"
cloud_script="$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing/scripts/test-env.sh"
oss_script="$tmp_dir/lobehub/.agents/skills/agent-testing/scripts/test-env.sh"
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:3021"
assert_eq "$("$cloud_script" --value SPA_PORT)" "9801"
assert_eq "$("$cloud_script" --value MOBILE_SPA_PORT)" "3811"
assert_eq "$("$cloud_script" --value DESKTOP_PORT)" "3031"
assert_eq "$("$oss_script" --value SERVER_URL)" "http://localhost:3010"
cat > "$tmp_dir/lobehub-cloud-1/.env" << 'EOF'
APP_URL=http://localhost:4123
PORT=4123
AUTH_TRUSTED_ORIGINS=http://localhost:4123,http://localhost:9823
SPA_PORT=9823
MOBILE_SPA_PORT=3823
DESKTOP_PORT=3043
EOF
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:4123"
assert_eq "$("$cloud_script" --value SPA_PORT)" "9823"
"$cloud_script" --exports > "$tmp_dir/exports.out"
assert_contains "$tmp_dir/exports.out" "export APP_URL=http://localhost:4123"
assert_contains "$tmp_dir/exports.out" "export SERVER_URL=http://localhost:4123"
assert_contains "$tmp_dir/exports.out" "export AUTH_TRUSTED_ORIGINS=http://localhost:4123\\,http://localhost:9823"
echo "test-env tests passed"
+7 -6
View File
@@ -10,23 +10,24 @@ backend-only changes prefer [../cli/index.md](../cli/index.md).
## Prerequisites
- Complete [Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) (resolve ports) and [Step -1](../SKILL.md#step--1--plan-approval-for-non-trivial-tests) (plan approval) first.
- Local dev server running — [../references/dev-server.md](../references/dev-server.md)
- Web auth injected into agent-browser — [../references/auth.md](../references/auth.md):
```bash
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web # after copying the Cookie header
```
- Web auth verified in agent-browser — see [auth decision flow](../references/auth.md#web--decision-flow).
## Option A — agent-browser with injected auth (recommended)
```bash
SESSION=lobehub-dev
agent-browser --session $SESSION open "http://localhost:3010/"
agent-browser --session $SESSION open "$SERVER_URL/"
agent-browser --session $SESSION snapshot -i
# interact via refs — full command reference: ../references/agent-browser.md
```
Use this session as the evidence source. Do not use ordinary Chrome screenshots
or Chrome Network records as proof for Web tests; ordinary Chrome is only a
source for copying cookies into agent-browser.
### Watch the API while driving the UI
```bash
+2
View File
@@ -307,6 +307,8 @@
"providerModels.list.enabledActions.sort": "Custom Model Sorting",
"providerModels.list.enabledEmpty": "No enabled models available. Please enable your preferred models from the list below~",
"providerModels.list.fetcher.clear": "Clear fetched models",
"providerModels.list.fetcher.error": "Failed to fetch models: {{message}}",
"providerModels.list.fetcher.errorFallback": "Unknown error",
"providerModels.list.fetcher.fetch": "Fetch models",
"providerModels.list.fetcher.fetching": "Fetching model list...",
"providerModels.list.fetcher.latestTime": "Last updated: {{time}}",
+2
View File
@@ -307,6 +307,8 @@
"providerModels.list.enabledActions.sort": "自定义模型排序",
"providerModels.list.enabledEmpty": "还没有启用模型。先从下方列表挑一个启用,之后随时可以调整",
"providerModels.list.fetcher.clear": "清除获取的模型",
"providerModels.list.fetcher.error": "获取模型列表失败:{{message}}",
"providerModels.list.fetcher.errorFallback": "未知错误",
"providerModels.list.fetcher.fetch": "获取模型列表",
"providerModels.list.fetcher.fetching": "正在获取模型列表…",
"providerModels.list.fetcher.latestTime": "上次更新时间:{{time}}",
@@ -384,6 +384,8 @@ export default {
'providerModels.list.enabledEmpty':
'No enabled models available. Please enable your preferred models from the list below~',
'providerModels.list.fetcher.clear': 'Clear fetched models',
'providerModels.list.fetcher.error': 'Failed to fetch models: {{message}}',
'providerModels.list.fetcher.errorFallback': 'Unknown error',
'providerModels.list.fetcher.fetch': 'Fetch models',
'providerModels.list.fetcher.fetching': 'Fetching model list...',
'providerModels.list.fetcher.latestTime': 'Last updated: {{time}}',
@@ -4,6 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as modelParse from '../../utils/modelParse';
import { LobeAiHubMixAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -21,6 +27,7 @@ describe('LobeAiHubMixAI', () => {
let instance: InstanceType<typeof LobeAiHubMixAI>;
beforeEach(() => {
loadModelsMock.mockResolvedValue([]);
instance = new LobeAiHubMixAI({ apiKey: 'test_api_key' });
});
@@ -190,27 +197,24 @@ describe('LobeAiHubMixAI', () => {
expect(passedModels.find((m) => m.id === 'gpt-4o')).toBeDefined();
});
it('should return empty array on non-ok HTTP response', async () => {
it('should throw on non-ok HTTP response', async () => {
mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
const list = await instance.models();
expect(list).toEqual([]);
await expect(instance.models()).rejects.toThrow('HTTP 401: Unauthorized');
});
it('should return empty array on network error', async () => {
it('should throw on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network Error'));
const list = await instance.models();
expect(list).toEqual([]);
await expect(instance.models()).rejects.toThrow('Network Error');
});
it('should return empty array on timeout (AbortError)', async () => {
it('should throw on timeout (AbortError)', async () => {
mockFetch.mockRejectedValueOnce(
Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),
);
const list = await instance.models();
expect(list).toEqual([]);
await expect(instance.models()).rejects.toThrow('The operation was aborted');
});
});
});
@@ -162,6 +162,7 @@ export const params: CreateRouterRuntimeOptions = {
// 'APP-Code' is an AiHubMix-required client identifier.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch('https://aihubmix.com/api/v1/models', {
headers: {
@@ -181,12 +182,6 @@ export const params: CreateRouterRuntimeOptions = {
.filter((m: any) => !UNSUPPORTED_AIHUBMIX_TYPES.has(m.types ?? ''))
.map((m: any) => mapAiHubMixModel(m));
return await processMultiProviderModelList(modelList, 'aihubmix');
} catch (error) {
console.warn(
'Failed to fetch AiHubMix models. Please ensure your AiHubMix API key is valid:',
error,
);
return [];
} finally {
clearTimeout(timeoutId);
}
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeAkashChatAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
const provider = ModelProvider.AkashChat;
const defaultBaseURL = 'https://chatapi.akash.network/api/v1';
@@ -313,8 +319,7 @@ describe('LobeAkashChatAI - custom features', () => {
expect(models).toEqual([]);
});
it('should handle API errors gracefully and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when model API fails', async () => {
const mockClient = {
apiKey: 'test',
baseURL: 'https://chatapi.akash.network/api/v1',
@@ -323,19 +328,10 @@ describe('LobeAkashChatAI - custom features', () => {
},
};
const models = await params.models({ client: mockClient as any });
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch AkashChat models. Please ensure your AkashChat API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
await expect(params.models({ client: mockClient as any })).rejects.toThrow('API Error');
});
it('should handle network timeout errors', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockClient = {
apiKey: 'test',
baseURL: 'https://chatapi.akash.network/api/v1',
@@ -344,16 +340,10 @@ describe('LobeAkashChatAI - custom features', () => {
},
};
const models = await params.models({ client: mockClient as any });
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
await expect(params.models({ client: mockClient as any })).rejects.toThrow('Network timeout');
});
it('should handle invalid API key errors', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockClient = {
apiKey: 'invalid',
baseURL: 'https://chatapi.akash.network/api/v1',
@@ -362,18 +352,10 @@ describe('LobeAkashChatAI - custom features', () => {
},
};
const models = await params.models({ client: mockClient as any });
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch AkashChat models. Please ensure your AkashChat API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
await expect(params.models({ client: mockClient as any })).rejects.toThrow('Unauthorized');
});
it('should handle malformed response data', async () => {
it('should throw on malformed response data', async () => {
const mockClient = {
apiKey: 'test',
baseURL: 'https://chatapi.akash.network/api/v1',
@@ -384,10 +366,9 @@ describe('LobeAkashChatAI - custom features', () => {
},
};
const models = await params.models({ client: mockClient as any });
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
await expect(params.models({ client: mockClient as any })).rejects.toThrow(
/Cannot destructure property 'created'/,
);
});
});
@@ -36,22 +36,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_AKASH_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const rawList: any[] = modelsPage.data || [];
const modelsPage = (await client.models.list()) as any;
const rawList: any[] = modelsPage.data || [];
// Remove `created` field from each model item
// Remove `created` field from each model item
const modelList: AkashChatModelCard[] = rawList.map(({ created: _, ...rest }) => rest);
const modelList: AkashChatModelCard[] = rawList.map(({ created: _, ...rest }) => rest);
return await processMultiProviderModelList(modelList, 'akashchat');
} catch (error) {
console.warn(
'Failed to fetch AkashChat models. Please ensure your AkashChat API key is valid:',
error,
);
return [];
}
return await processMultiProviderModelList(modelList, 'akashchat');
},
provider: ModelProvider.AkashChat,
} satisfies OpenAICompatibleFactoryOptions;
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeCerebrasAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
testProvider({
Runtime: LobeCerebrasAI,
bizErrorType: 'ProviderBizError',
@@ -330,8 +336,7 @@ describe('LobeCerebrasAI - custom features', () => {
expect(models).toHaveLength(0);
});
it('should handle network error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when network error occurs', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.cerebras.ai/v1',
@@ -340,22 +345,12 @@ describe('LobeCerebrasAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Network error');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Cerebras models. Please ensure your Cerebras API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API authentication error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API authentication fails', async () => {
const mockClient = {
apiKey: 'invalid_key',
baseURL: 'https://api.cerebras.ai/v1',
@@ -364,20 +359,12 @@ describe('LobeCerebrasAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('401 Unauthorized');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Cerebras models. Please ensure your Cerebras API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API rate limit error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API rate limit fails', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.cerebras.ai/v1',
@@ -386,20 +373,12 @@ describe('LobeCerebrasAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('429 Too Many Requests');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Cerebras models. Please ensure your Cerebras API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle timeout error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when request times out', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.cerebras.ai/v1',
@@ -408,20 +387,12 @@ describe('LobeCerebrasAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Request timeout');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Cerebras models. Please ensure your Cerebras API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle malformed JSON response', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.cerebras.ai/v1',
@@ -430,15 +401,9 @@ describe('LobeCerebrasAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Invalid JSON');
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Cerebras models. Please ensure your Cerebras API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
});
it('should pass correct client to processMultiProviderModelList', async () => {
@@ -21,22 +21,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_CEREBRAS_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
const modelsPage = (await client.models.list()) as any;
const modelList = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
return await processMultiProviderModelList(modelList, 'cerebras');
} catch (error) {
console.warn(
'Failed to fetch Cerebras models. Please ensure your Cerebras API key is valid:',
error,
);
return [];
}
return await processMultiProviderModelList(modelList, 'cerebras');
},
provider: ModelProvider.Cerebras,
} satisfies OpenAICompatibleFactoryOptions;
@@ -181,7 +181,7 @@ describe('LobeCloudflareAI', () => {
expect(result).toBeInstanceOf(Response);
});
it('should call Cloudflare API with supported opions', async () => {
it('should call Cloudflare API with supported options', async () => {
// Arrange
const mockResponse = new Response(
new ReadableStream<Uint8Array>({
@@ -558,5 +558,32 @@ describe('LobeCloudflareAI', () => {
expect(result).toHaveLength(2);
});
it('should throw regular Error when API returns null result', async () => {
const instance = new LobeCloudflareAI({
apiKey: 'test_api_key',
baseURLOrAccountID: accountID,
});
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
errors: [{ code: 10000, message: 'Authentication error' }],
result: null,
success: false,
}),
{ status: 401 },
),
);
await expect(instance.models()).rejects.toMatchObject({
cause: {
errors: [{ code: 10000, message: 'Authentication error' }],
result: null,
success: false,
},
message: 'Cloudflare models API returned an invalid response',
});
});
});
});
@@ -162,7 +162,11 @@ export class LobeCloudflareAI implements LobeRuntimeAI {
});
const json = await response.json();
const modelList: CloudflareModelCard[] = json.result;
const modelList: CloudflareModelCard[] | undefined = json.result;
if (!Array.isArray(modelList)) {
throw new Error('Cloudflare models API returned an invalid response', { cause: json });
}
return modelList
.map((model) => {
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeCometAPIAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
// Basic provider tests
testProvider({
Runtime: LobeCometAPIAI,
@@ -295,16 +301,7 @@ describe('LobeCometAPIAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const models = await params.models!({ client: mockClient as any });
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch CometAPI models. Please ensure your CometAPI API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
await expect(params.models!({ client: mockClient as any })).rejects.toThrow('API Error');
});
it('should handle network error', async () => {
@@ -316,12 +313,7 @@ describe('LobeCometAPIAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const models = await params.models!({ client: mockClient as any });
expect(models).toEqual([]);
consoleWarnSpy.mockRestore();
await expect(params.models!({ client: mockClient as any })).rejects.toThrow('Network Error');
});
it('should handle unauthorized error', async () => {
@@ -333,12 +325,7 @@ describe('LobeCometAPIAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const models = await params.models!({ client: mockClient as any });
expect(models).toEqual([]);
consoleWarnSpy.mockRestore();
await expect(params.models!({ client: mockClient as any })).rejects.toThrow('Unauthorized');
});
it('should process multi-provider model list', async () => {
@@ -27,25 +27,17 @@ export const params = {
chatCompletion: () => process.env.DEBUG_COMETAPI_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const rawList: any[] = modelsPage.data || [];
const modelsPage = (await client.models.list()) as any;
const rawList: any[] = modelsPage.data || [];
// Process the model list and remove unnecessary fields
const modelList: CometAPIModelCard[] = rawList.map((model) => ({
id: model.id,
object: model.object,
owned_by: model.owned_by,
}));
// Process the model list and remove unnecessary fields
const modelList: CometAPIModelCard[] = rawList.map((model) => ({
id: model.id,
object: model.object,
owned_by: model.owned_by,
}));
return await processMultiProviderModelList(modelList, 'cometapi');
} catch (error) {
console.warn(
'Failed to fetch CometAPI models. Please ensure your CometAPI API key is valid:',
error,
);
return [];
}
return await processMultiProviderModelList(modelList, 'cometapi');
},
provider: ModelProvider.CometAPI,
} satisfies OpenAICompatibleFactoryOptions;
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeGiteeAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
testProvider({
Runtime: LobeGiteeAI,
chatDebugEnv: 'DEBUG_GITEE_AI_CHAT_COMPLETION',
@@ -200,8 +206,7 @@ describe('LobeGiteeAI - custom features', () => {
expect(models).toHaveLength(0);
});
it('should handle network error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when network error occurs', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://ai.gitee.com/v1',
@@ -210,22 +215,12 @@ describe('LobeGiteeAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Network error');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch GiteeAI models. Please ensure your GiteeAI API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API authentication error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API authentication fails', async () => {
const mockClient = {
apiKey: 'invalid_key',
baseURL: 'https://ai.gitee.com/v1',
@@ -234,20 +229,12 @@ describe('LobeGiteeAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('401 Unauthorized');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch GiteeAI models. Please ensure your GiteeAI API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API rate limit error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API rate limit fails', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://ai.gitee.com/v1',
@@ -256,20 +243,12 @@ describe('LobeGiteeAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('429 Too Many Requests');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch GiteeAI models. Please ensure your GiteeAI API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle timeout error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when request times out', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://ai.gitee.com/v1',
@@ -278,20 +257,12 @@ describe('LobeGiteeAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Request timeout');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch GiteeAI models. Please ensure your GiteeAI API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle malformed JSON response', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://ai.gitee.com/v1',
@@ -300,15 +271,9 @@ describe('LobeGiteeAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Invalid JSON');
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch GiteeAI models. Please ensure your GiteeAI API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
});
it('should pass correct client to processMultiProviderModelList', async () => {
@@ -14,22 +14,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_GITEE_AI_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList: GiteeAIModelCard[] = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
const modelsPage = (await client.models.list()) as any;
const modelList: GiteeAIModelCard[] = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
return await processMultiProviderModelList(modelList, 'giteeai');
} catch (error) {
console.warn(
'Failed to fetch GiteeAI models. Please ensure your GiteeAI API key is valid:',
error,
);
return [];
}
return await processMultiProviderModelList(modelList, 'giteeai');
},
provider: ModelProvider.GiteeAI,
} satisfies OpenAICompatibleFactoryOptions;
@@ -216,19 +216,61 @@ describe('LobeGithubCopilotAI', () => {
expect(models).toEqual([]);
});
it('should throw regular Error when models request fails', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ error: { message: 'Copilot access denied' } }),
ok: false,
status: 403,
});
const instance = new LobeGithubCopilotAI({
bearerToken: 'cached-bearer-token',
bearerTokenExpiresAt: Date.now() + 60 * 60 * 1000,
});
try {
await instance.models();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('GitHub Copilot models API request failed');
expect((error as Error).cause).toEqual({ status: 403 });
expect((error as Error & { errorType?: string }).errorType).toBeUndefined();
}
});
it('should throw runtime payload when token exchange fails before models request', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({}),
ok: false,
status: 403,
});
const instance = new LobeGithubCopilotAI({ apiKey: 'ghp_models_denied' });
try {
await instance.models();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toEqual({
error: { message: 'No GitHub Copilot subscription or access denied' },
errorType: 'PermissionDenied',
});
}
});
});
describe('error handling in constructor', () => {
it('should throw InvalidGithubCopilotToken when no credentials provided', () => {
expect(() => new LobeGithubCopilotAI({})).toThrow();
});
it('should throw with descriptive message', () => {
it('should throw runtime payload when no credentials provided', () => {
try {
new LobeGithubCopilotAI({});
expect.fail('Should have thrown');
} catch (error: any) {
expect(error.errorType).toBe('InvalidGithubCopilotToken');
expect(error).toEqual({
error: { message: 'GitHub Personal Access Token or OAuth token is required' },
errorType: 'InvalidGithubCopilotToken',
});
}
});
});
@@ -398,34 +398,45 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
}
async models(): Promise<ChatModelCard[]> {
return this.executeWithRetry(async () => {
const bearerToken = this.cachedBearerToken || (await tokenManager.getToken(this.githubToken));
return this.executeWithRetry(
async () => {
const bearerToken =
this.cachedBearerToken || (await tokenManager.getToken(this.githubToken));
const response = await fetch(`${COPILOT_BASE_URL}/models`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearerToken}`,
'Copilot-Integration-Id': 'vscode-chat',
'Editor-Plugin-Version': 'LobeChat/1.0',
'Editor-Version': 'LobeChat/1.0',
},
method: 'GET',
});
const response = await fetch(`${COPILOT_BASE_URL}/models`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearerToken}`,
'Copilot-Integration-Id': 'vscode-chat',
'Editor-Plugin-Version': 'LobeChat/1.0',
'Editor-Version': 'LobeChat/1.0',
},
method: 'GET',
});
if (!response.ok) {
throw { status: response.status };
}
if (!response.ok) {
throw Object.assign(
new Error('GitHub Copilot models API request failed', {
cause: { status: response.status },
}),
{
status: response.status,
},
);
}
const data = await response.json();
const data = await response.json();
// Transform Copilot models to ChatModelCard format
return (data.models || data.data || []).map((model: any) => ({
displayName: model.name || model.id,
enabled: true,
id: model.id || model.name,
type: 'chat',
}));
});
// Transform Copilot models to ChatModelCard format
return (data.models || data.data || []).map((model: any) => ({
displayName: model.name || model.id,
enabled: true,
id: model.id || model.name,
type: 'chat',
}));
},
{ mapError: false },
);
}
private handlePayload(payload: ChatStreamPayload) {
@@ -443,7 +454,10 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
return { type: tool.type, ...tool.function } as any;
};
private async executeWithRetry<T>(fn: () => Promise<T>): Promise<T> {
private async executeWithRetry<T>(
fn: () => Promise<T>,
options: { mapError?: boolean } = {},
): Promise<T> {
let totalAttempts = 0;
let hasRefreshedAuth = false;
let rateLimitAttempts = 0;
@@ -471,6 +485,7 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
// If retry-after exceeds the quota exhaustion threshold, surface immediately
if (retryAfter > QUOTA_EXHAUSTION_THRESHOLD_MS) {
if (options.mapError === false) throw error;
throw this.mapError(error);
}
@@ -481,10 +496,17 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
}
// Map and throw
if (options.mapError === false) throw error;
throw this.mapError(error);
}
}
if (options.mapError === false) {
throw new Error('Max retry attempts exceeded', {
cause: { endpoint: this.baseURL },
});
}
throw AgentRuntimeError.chat({
endpoint: this.baseURL,
error: { message: 'Max retry attempts exceeded' },
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentRuntimeErrorType } from '../../types/error';
import { LobeHuggingFaceAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
describe('LobeHuggingFaceAI', () => {
let instance: any;
@@ -355,6 +361,7 @@ describe('LobeHuggingFaceAI', () => {
describe('Models Fetching', () => {
beforeEach(() => {
vi.restoreAllMocks();
loadModelsMock.mockResolvedValue([]);
});
it('should fetch and process models from HuggingFace API', async () => {
@@ -676,8 +683,7 @@ describe('LobeHuggingFaceAI', () => {
it('should handle API errors gracefully', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('API Error'));
const result = await params.models!();
expect(result).toEqual([]);
await expect(params.models!()).rejects.toThrow('API Error');
});
it('should handle invalid JSON response', async () => {
@@ -688,8 +694,7 @@ describe('LobeHuggingFaceAI', () => {
},
} as unknown as Response);
const result = await params.models!();
expect(result).toEqual([]);
await expect(params.models!()).rejects.toThrow('Invalid JSON');
});
it('should preserve contextWindowTokens from provider info', async () => {
@@ -8,7 +8,7 @@ import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactor
import { convertIterableToStream } from '../../core/streams';
import { AgentRuntimeErrorType } from '../../types/error';
import { processMultiProviderModelList } from '../../utils/modelParse';
import type { HuggingFaceRouterModelCard, HuggingFaceRouterResponse } from './type';
import type { HuggingFaceRouterResponse } from './type';
export const params = {
chatCompletion: {
@@ -54,19 +54,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_HUGGINGFACE_CHAT_COMPLETION === '1',
},
models: async () => {
let modelList: HuggingFaceRouterModelCard[] = [];
try {
const response = await fetch('https://router.huggingface.co/v1/models');
if (response.ok) {
const data: HuggingFaceRouterResponse = await response.json();
modelList = data.data;
}
} catch (error) {
console.error('Failed to fetch HuggingFace router models:', error);
return [];
const response = await fetch('https://router.huggingface.co/v1/models');
if (!response.ok) {
throw new Error(`HuggingFace models API request failed with status ${response.status}`);
}
const data: HuggingFaceRouterResponse = await response.json();
const modelList = data.data;
const formattedModels = modelList
.map((model) => {
const { architecture, providers } = model;
@@ -6,6 +6,12 @@ import { testProvider } from '../../providerTestUtils';
import type { ModelScopeModelCard } from './index';
import { LobeModelScopeAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
const provider = ModelProvider.ModelScope;
const defaultBaseURL = 'https://api-inference.modelscope.cn/v1';
@@ -55,7 +61,6 @@ describe('LobeModelScopeAI - custom features', () => {
describe('models function', () => {
let mockClient: any;
let consoleWarnSpy: any;
beforeEach(() => {
mockClient = {
@@ -63,7 +68,6 @@ describe('LobeModelScopeAI - custom features', () => {
list: vi.fn(),
},
};
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
it('should fetch and process models successfully', async () => {
@@ -91,7 +95,6 @@ describe('LobeModelScopeAI - custom features', () => {
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should handle empty model list', async () => {
@@ -105,7 +108,6 @@ describe('LobeModelScopeAI - custom features', () => {
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(0);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should handle missing data field', async () => {
@@ -117,47 +119,28 @@ describe('LobeModelScopeAI - custom features', () => {
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(0);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should handle API errors gracefully', async () => {
const mockError = new Error('API Error');
mockClient.models.list.mockRejectedValue(mockError);
const models = await params.models({ client: mockClient });
await expect(params.models({ client: mockClient })).rejects.toThrow('API Error');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ModelScope models. Please ensure your ModelScope API key is valid and your Alibaba Cloud account is properly bound:',
mockError,
);
});
it('should handle network errors', async () => {
const networkError = new Error('Network timeout');
mockClient.models.list.mockRejectedValue(networkError);
const models = await params.models({ client: mockClient });
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ModelScope models. Please ensure your ModelScope API key is valid and your Alibaba Cloud account is properly bound:',
networkError,
);
await expect(params.models({ client: mockClient })).rejects.toThrow('Network timeout');
});
it('should handle invalid API key errors', async () => {
const authError = new Error('Invalid API key');
mockClient.models.list.mockRejectedValue(authError);
const models = await params.models({ client: mockClient });
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ModelScope models. Please ensure your ModelScope API key is valid and your Alibaba Cloud account is properly bound:',
authError,
);
await expect(params.models({ client: mockClient })).rejects.toThrow('Invalid API key');
});
it('should process models with processMultiProviderModelList', async () => {
@@ -17,18 +17,10 @@ export const params = {
chatCompletion: () => process.env.DEBUG_MODELSCOPE_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList: ModelScopeModelCard[] = modelsPage.data || [];
const modelsPage = (await client.models.list()) as any;
const modelList: ModelScopeModelCard[] = modelsPage.data || [];
return await processMultiProviderModelList(modelList, 'modelscope');
} catch (error) {
console.warn(
'Failed to fetch ModelScope models. Please ensure your ModelScope API key is valid and your Alibaba Cloud account is properly bound:',
error,
);
return [];
}
return await processMultiProviderModelList(modelList, 'modelscope');
},
provider: ModelProvider.ModelScope,
} satisfies OpenAICompatibleFactoryOptions;
@@ -873,15 +873,13 @@ describe('models', () => {
expect(models).toEqual([]);
});
it('should handle fetch error gracefully', async () => {
it('should throw when model fetch fails', async () => {
const mockClient = {
models: {
list: vi.fn().mockRejectedValue(new Error('Network error')),
},
} as unknown as OpenAI;
const models = await fetchModels({ client: mockClient });
expect(models).toEqual([]);
await expect(fetchModels({ client: mockClient })).rejects.toThrow('Network error');
});
});
@@ -213,21 +213,16 @@ const buildMoonshotOpenAIPayload = (
* Fetch Moonshot models from the API using OpenAI client
*/
const fetchMoonshotModels = async ({ client }: { client: OpenAI }): Promise<ChatModelCard[]> => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList: MoonshotModelCard[] = modelsPage.data || [];
const modelsPage = (await client.models.list()) as any;
const modelList: MoonshotModelCard[] = modelsPage.data || [];
const processedList = modelList.map((model) => ({
contextWindowTokens: model.context_length,
id: model.id,
vision: model.supports_image_in,
}));
const processedList = modelList.map((model) => ({
contextWindowTokens: model.context_length,
id: model.id,
vision: model.supports_image_in,
}));
return processModelList(processedList, MODEL_LIST_CONFIGS.moonshot, 'moonshot');
} catch (error) {
console.warn('Failed to fetch Moonshot models:', error);
return [];
}
return processModelList(processedList, MODEL_LIST_CONFIGS.moonshot, 'moonshot');
};
/**
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeOllamaCloudAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
// Basic provider tests
testProvider({
Runtime: LobeOllamaCloudAI,
@@ -223,18 +229,9 @@ describe('LobeOllamaCloudAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await params.models({ client: mockClient as any });
await expect(params.models({ client: mockClient as any })).rejects.toThrow('API Error');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(result).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Ollama Cloud models. Please ensure your Ollama Cloud API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle network error', async () => {
@@ -246,14 +243,7 @@ describe('LobeOllamaCloudAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await params.models({ client: mockClient as any });
expect(result).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
await expect(params.models({ client: mockClient as any })).rejects.toThrow('Network Error');
});
it('should handle invalid API key error', async () => {
@@ -265,17 +255,7 @@ describe('LobeOllamaCloudAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await params.models({ client: mockClient as any });
expect(result).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch Ollama Cloud models. Please ensure your Ollama Cloud API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
await expect(params.models({ client: mockClient as any })).rejects.toThrow('Invalid API Key');
});
it('should handle null response', async () => {
@@ -373,14 +353,7 @@ describe('LobeOllamaCloudAI - custom features', () => {
},
};
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await params.models({ client: mockClient as any });
expect(result).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
await expect(params.models({ client: mockClient as any })).rejects.toThrow('Request timeout');
});
});
@@ -20,22 +20,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_OLLAMA_CLOUD_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
const modelsPage = (await client.models.list()) as any;
const modelList = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
return await processMultiProviderModelList(modelList, 'ollamacloud');
} catch (error) {
console.warn(
'Failed to fetch Ollama Cloud models. Please ensure your Ollama Cloud API key is valid:',
error,
);
return [];
}
return await processMultiProviderModelList(modelList, 'ollamacloud');
},
provider: ModelProvider.OllamaCloud,
} satisfies OpenAICompatibleFactoryOptions;
@@ -5,6 +5,12 @@ import type { LobeOpenAICompatibleRuntime } from '../../core/BaseAI';
import { testProvider } from '../../providerTestUtils';
import { LobeOpenRouterAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
const provider = 'openrouter';
const defaultBaseURL = 'https://openrouter.ai/api/v1';
@@ -1347,29 +1353,24 @@ describe('LobeOpenRouterAI - custom features', () => {
expect(models).toEqual([]);
});
it('should return empty array when fetch fails', async () => {
it('should throw when fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
}),
);
const models = await params.models();
expect(models).toEqual([]);
await expect(params.models()).rejects.toThrow(
'OpenRouter models API request failed with status 401',
);
});
it('should return empty array when fetch throws error', async () => {
it('should throw when fetch throws error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const models = await params.models();
expect(models).toEqual([]);
expect(console.error).toHaveBeenCalledWith(
'Failed to fetch OpenRouter frontend models:',
expect.any(Error),
);
await expect(params.models()).rejects.toThrow('Network error');
});
it('should handle models with missing optional fields', async () => {
@@ -93,19 +93,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_OPENROUTER_CHAT_COMPLETION === '1',
},
models: async () => {
let modelList: OpenRouterModelCard[] = [];
try {
const response = await fetch('https://openrouter.ai/api/v1/models');
if (response.ok) {
const data = await response.json();
modelList = data['data'];
}
} catch (error) {
console.error('Failed to fetch OpenRouter frontend models:', error);
return [];
const response = await fetch('https://openrouter.ai/api/v1/models');
if (!response.ok) {
throw new Error(`OpenRouter models API request failed with status ${response.status}`);
}
const data = (await response.json()) as { data: OpenRouterModelCard[] };
const modelList = data.data;
// Process the model info fetched from the frontend and convert to standard format
const formattedModels = modelList.map((model) => {
const { top_provider, architecture, pricing, supported_parameters } = model;
@@ -0,0 +1,33 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeStraicoAI } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
const mockFetch = vi.fn();
describe('LobeStraicoAI', () => {
beforeEach(() => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('models', () => {
it('should throw a regular Error when the API request fails', async () => {
mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
const instance = new LobeStraicoAI({ apiKey: 'test-api-key' });
await expect(instance.models()).rejects.toThrow('HTTP 401');
});
});
});
@@ -27,7 +27,6 @@ export const LobeStraicoAI = createOpenAICompatibleRuntime({
baseURL: 'https://api.straico.com/v0',
chatCompletion: {
handlePayload: (payload) => {
const { model, ...rest } = payload;
return {
@@ -41,56 +40,48 @@ export const LobeStraicoAI = createOpenAICompatibleRuntime({
chatCompletion: () => process.env.DEBUG_STRAICO_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const url = 'https://api.straico.com/v1/models';
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${client.apiKey}`,
},
method: 'GET',
});
const url = 'https://api.straico.com/v1/models';
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${client.apiKey}`,
},
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json: StraicoModelsResponse = await response.json();
const chatModels: StraicoChatModel[] = json?.data?.chat || []; // There are also audio and image models to be adapted
// Transform Straico models to standardized format
const formattedModels = chatModels.map((model) => {
const inputPrice = formatPrice(model.pricing);
const outputPrice = inputPrice; // Straico uses same price for input/output
return {
contextWindowTokens: model.word_limit
? Math.floor(model.word_limit * 1.33) // Convert words to tokens
: undefined,
description: model.metadata?.pros?.join('; ') || '',
displayName: cleanModelName(model.name),
enabled: model.enabled ?? false,
functionCall: false,
id: model.model,
maxOutput: model.max_output,
pricing: inputPrice
? {
input: inputPrice,
output: outputPrice,
}
: undefined,
reasoning: model.metadata?.applications?.includes('Reasoning') ?? false,
vision: model.metadata?.features?.includes('Image input') ?? false,
};
});
return await processMultiProviderModelList(formattedModels, 'straico');
} catch (error) {
console.warn(
'Failed to fetch Straico models. Please ensure your Straico API key is valid:',
error,
);
return [];
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json: StraicoModelsResponse = await response.json();
const chatModels: StraicoChatModel[] = json?.data?.chat || []; // There are also audio and image models to be adapted
// Transform Straico models to standardized format
const formattedModels = chatModels.map((model) => {
const inputPrice = formatPrice(model.pricing);
const outputPrice = inputPrice; // Straico uses same price for input/output
return {
contextWindowTokens: model.word_limit
? Math.floor(model.word_limit * 1.33) // Convert words to tokens
: undefined,
description: model.metadata?.pros?.join('; ') || '',
displayName: cleanModelName(model.name),
enabled: model.enabled ?? false,
functionCall: false,
id: model.model,
maxOutput: model.max_output,
pricing: inputPrice
? {
input: inputPrice,
output: outputPrice,
}
: undefined,
reasoning: model.metadata?.applications?.includes('Reasoning') ?? false,
vision: model.metadata?.features?.includes('Image input') ?? false,
};
});
return await processMultiProviderModelList(formattedModels, 'straico');
},
provider: ModelProvider.Straico,
});
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeV0AI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
testProvider({
Runtime: LobeV0AI,
bizErrorType: 'ProviderBizError',
@@ -206,8 +212,7 @@ describe('LobeV0AI - custom features', () => {
expect(models).toHaveLength(0);
});
it('should handle network error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when network error occurs', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.v0.dev/v1',
@@ -216,22 +221,12 @@ describe('LobeV0AI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Network error');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch V0 models. Please ensure your V0 API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API authentication error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API authentication fails', async () => {
const mockClient = {
apiKey: 'invalid_key',
baseURL: 'https://api.v0.dev/v1',
@@ -240,20 +235,12 @@ describe('LobeV0AI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('401 Unauthorized');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch V0 models. Please ensure your V0 API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API rate limit error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API rate limit fails', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.v0.dev/v1',
@@ -262,20 +249,12 @@ describe('LobeV0AI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('429 Too Many Requests');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch V0 models. Please ensure your V0 API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle timeout error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when request times out', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.v0.dev/v1',
@@ -284,20 +263,12 @@ describe('LobeV0AI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Request timeout');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch V0 models. Please ensure your V0 API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle malformed JSON response', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.v0.dev/v1',
@@ -306,15 +277,9 @@ describe('LobeV0AI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Invalid JSON');
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch V0 models. Please ensure your V0 API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
});
it('should pass correct client to processModelList', async () => {
@@ -14,19 +14,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_V0_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList: V0ModelCard[] = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
const modelsPage = (await client.models.list()) as any;
const modelList: V0ModelCard[] = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
return processModelList(modelList, MODEL_LIST_CONFIGS.v0, 'v0');
} catch (error) {
console.warn('Failed to fetch V0 models. Please ensure your V0 API key is valid:', error);
return [];
}
return processModelList(modelList, MODEL_LIST_CONFIGS.v0, 'v0');
},
provider: ModelProvider.V0,
} satisfies OpenAICompatibleFactoryOptions;
@@ -5,6 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { testProvider } from '../../providerTestUtils';
import { LobeZeroOneAI, params } from './index';
const loadModelsMock = vi.hoisted(() => vi.fn().mockResolvedValue([]));
vi.mock('@lobechat/business-model-bank/model-config', () => ({
loadModels: loadModelsMock,
}));
testProvider({
Runtime: LobeZeroOneAI,
chatDebugEnv: 'DEBUG_ZEROONE_CHAT_COMPLETION',
@@ -200,8 +206,7 @@ describe('LobeZeroOneAI - custom features', () => {
expect(models).toHaveLength(0);
});
it('should handle network error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when network error occurs', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.lingyiwanwu.com/v1',
@@ -210,22 +215,12 @@ describe('LobeZeroOneAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Network error');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ZeroOne models. Please ensure your ZeroOne API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API authentication error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API authentication fails', async () => {
const mockClient = {
apiKey: 'invalid_key',
baseURL: 'https://api.lingyiwanwu.com/v1',
@@ -234,20 +229,12 @@ describe('LobeZeroOneAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('401 Unauthorized');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ZeroOne models. Please ensure your ZeroOne API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle API rate limit error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when API rate limit fails', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.lingyiwanwu.com/v1',
@@ -256,20 +243,12 @@ describe('LobeZeroOneAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('429 Too Many Requests');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ZeroOne models. Please ensure your ZeroOne API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle timeout error and return empty array', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
it('should throw when request times out', async () => {
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.lingyiwanwu.com/v1',
@@ -278,20 +257,12 @@ describe('LobeZeroOneAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Request timeout');
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ZeroOne models. Please ensure your ZeroOne API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
});
it('should handle malformed JSON response', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockClient = {
apiKey: 'test_api_key',
baseURL: 'https://api.lingyiwanwu.com/v1',
@@ -300,15 +271,9 @@ describe('LobeZeroOneAI - custom features', () => {
},
} as any;
const models = await params.models!({ client: mockClient });
await expect(params.models!({ client: mockClient })).rejects.toThrow('Invalid JSON');
expect(models).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to fetch ZeroOne models. Please ensure your ZeroOne API key is valid:',
expect.any(Error),
);
consoleWarnSpy.mockRestore();
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
});
it('should pass correct client to processModelList', async () => {
@@ -14,22 +14,14 @@ export const params = {
chatCompletion: () => process.env.DEBUG_ZEROONE_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList: ZeroOneModelCard[] = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
const modelsPage = (await client.models.list()) as any;
const modelList: ZeroOneModelCard[] = Array.isArray(modelsPage?.data)
? modelsPage.data
: Array.isArray(modelsPage)
? modelsPage
: [];
return processModelList(modelList, MODEL_LIST_CONFIGS.zeroone);
} catch (error) {
console.warn(
'Failed to fetch ZeroOne models. Please ensure your ZeroOne API key is valid:',
error,
);
return [];
}
return processModelList(modelList, MODEL_LIST_CONFIGS.zeroone);
},
provider: ModelProvider.ZeroOne,
} satisfies OpenAICompatibleFactoryOptions;
@@ -1,6 +1,6 @@
// @vitest-environment node
import { type LobeRuntimeAI } from '@lobechat/model-runtime';
import { ModelRuntime } from '@lobechat/model-runtime';
import type { LobeRuntimeAI } from '@lobechat/model-runtime';
import { AgentRuntimeErrorType, ModelRuntime } from '@lobechat/model-runtime';
import { ChatErrorType } from '@lobechat/types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -28,6 +28,8 @@ vi.mock('@/server/modules/ModelRuntime', () => ({
let request: Request;
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
request = new Request(new URL('https://test.com'), {
method: 'GET',
});
@@ -41,11 +43,12 @@ beforeEach(() => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('GET handler', () => {
describe('error handling', () => {
it('should not expose stack trace when an Error is thrown', async () => {
it('should return the thrown error message without exposing stack trace', async () => {
const mockParams = Promise.resolve({ provider: 'google' });
const errorWithStack = new Error('Something went wrong');
@@ -62,16 +65,16 @@ describe('GET handler', () => {
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(responseBody.body.error.name).toBe('Error');
expect(responseBody.body.error.message).toBe('Something went wrong');
expect(responseBody.body.error.stack).toBeUndefined();
expect(response.status).toBe(500);
expect(responseBody.errorType).toBe(ChatErrorType.InternalServerError);
expect(responseBody.body.message).toBe('Something went wrong');
const responseText = JSON.stringify(responseBody);
expect(responseText).not.toContain('/path/to/file.ts');
expect(responseText).not.toContain('at Object');
});
it('should preserve error name for custom error types', async () => {
it('should return custom error messages', async () => {
const mockParams = Promise.resolve({ provider: 'google' });
class CustomError extends Error {
@@ -94,17 +97,17 @@ describe('GET handler', () => {
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(responseBody.body.error.name).toBe('CustomError');
expect(responseBody.body.error.message).toBe('Custom error occurred');
expect(responseBody.body.error.stack).toBeUndefined();
expect(response.status).toBe(500);
expect(responseBody.errorType).toBe(ChatErrorType.InternalServerError);
expect(responseBody.body.message).toBe('Custom error occurred');
});
it('should pass through structured error objects as-is', async () => {
it('should preserve structured model fetch error context', async () => {
const mockParams = Promise.resolve({ provider: 'google' });
const structuredError = {
errorType: ChatErrorType.InternalServerError,
error: { code: 'PROVIDER_ERROR', details: 'API limit exceeded' },
errorType: AgentRuntimeErrorType.ProviderBizError,
error: { code: 'PROVIDER_ERROR', message: 'API limit exceeded' },
};
const mockRuntime: LobeRuntimeAI = {
@@ -117,11 +120,15 @@ describe('GET handler', () => {
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(response.status).toBe(471);
expect(responseBody.errorType).toBe(AgentRuntimeErrorType.ProviderBizError);
expect(responseBody.body.error.code).toBe('PROVIDER_ERROR');
expect(responseBody.body.error.details).toBe('API limit exceeded');
expect(responseBody.body.error.message).toBe('API limit exceeded');
expect(responseBody.body.message).toBe('API limit exceeded');
expect(responseBody.body.provider).toBe('google');
});
it('should return correct status code for errors', async () => {
it('should return generic status code for model fetch errors', async () => {
const mockParams = Promise.resolve({ provider: 'google' });
const mockRuntime: LobeRuntimeAI = {
@@ -132,8 +139,65 @@ describe('GET handler', () => {
vi.mocked(initModelRuntimeFromDB).mockResolvedValue(new ModelRuntime(mockRuntime));
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(response.status).toBe(500);
expect(responseBody.errorType).toBe(ChatErrorType.InternalServerError);
expect(responseBody.body.message).toBe('Failed');
});
it('should prefer wrapped cause message for model fetch errors', async () => {
const mockParams = Promise.resolve({ provider: 'openrouter' });
const cause = new Error('OpenRouter models API request failed with status 401');
const wrappedError = new Error('Failed to fetch OpenRouter models', { cause });
const mockRuntime: LobeRuntimeAI = {
baseURL: 'abc',
chat: vi.fn(),
models: vi.fn().mockRejectedValue(wrappedError),
};
vi.mocked(initModelRuntimeFromDB).mockResolvedValue(new ModelRuntime(mockRuntime));
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(response.status).toBe(500);
expect(responseBody.errorType).toBe(ChatErrorType.InternalServerError);
expect(responseBody.body.message).toBe(
'OpenRouter models API request failed with status 401',
);
});
it('should return generic status code for setup errors', async () => {
const mockParams = Promise.resolve({ provider: 'google' });
vi.mocked(initModelRuntimeFromDB).mockRejectedValue(new Error('Setup failed'));
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(response.status).toBe(500);
expect(responseBody.errorType).toBe(ChatErrorType.InternalServerError);
expect(responseBody.body.message).toBe('Setup failed');
});
it('should preserve structured setup error type and message', async () => {
const mockParams = Promise.resolve({ provider: 'githubcopilot' });
vi.mocked(initModelRuntimeFromDB).mockRejectedValue({
error: { message: 'Invalid GitHub Copilot API key' },
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
});
const response = await GET(request, { params: mockParams });
const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody.errorType).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
expect(responseBody.body.message).toBe('Invalid GitHub Copilot API key');
expect(responseBody.body.error.message).toBe('Invalid GitHub Copilot API key');
expect(responseBody.body.provider).toBe('githubcopilot');
});
it('should include provider in error response', async () => {
@@ -1,4 +1,4 @@
import { type ChatCompletionErrorPayload } from '@lobechat/model-runtime';
import type { ChatCompletionErrorPayload } from '@lobechat/model-runtime';
import { ChatErrorType } from '@lobechat/types';
import { NextResponse } from 'next/server';
@@ -8,6 +8,48 @@ import { createErrorResponse } from '@/utils/errorResponse';
import { resolveValidWorkspaceIdFromRequest } from '../../_utils/workspace';
const getMessageFromError = (error: unknown): string | undefined => {
if (error === null || error === undefined) return;
if (typeof error === 'string') return error;
if (error instanceof Error) {
if (error.cause instanceof Error && error.cause.message) return error.cause.message;
return error.message;
}
if (typeof error !== 'object') return;
const message = (error as { message?: unknown }).message;
return typeof message === 'string' ? message : undefined;
};
const createModelListErrorResponse = (provider: string, e: unknown) => {
let error = e;
let errorType: ChatCompletionErrorPayload['errorType'] = ChatErrorType.InternalServerError;
let rest: Partial<ChatCompletionErrorPayload> = {};
if (e && typeof e === 'object') {
const {
error: errorContent,
errorType: payloadErrorType,
...payloadRest
} = e as Partial<ChatCompletionErrorPayload>;
error = errorContent || e;
errorType = payloadErrorType || errorType;
rest = payloadRest;
}
console.error(`Route: [${provider}] ${errorType}:`, error);
return createErrorResponse(errorType, {
error,
...rest,
message: getMessageFromError(error) || getMessageFromError(e) || rest.message,
provider,
});
};
export const GET = checkAuth(async (req, { params, userId, serverDB }) => {
const provider = (await params)!.provider!;
@@ -21,20 +63,6 @@ export const GET = checkAuth(async (req, { params, userId, serverDB }) => {
return NextResponse.json(list);
} catch (e) {
const {
errorType = ChatErrorType.InternalServerError,
error: errorContent,
...res
} = e as ChatCompletionErrorPayload;
const error = errorContent || e;
// track the error at server side
console.error(`Route: [${provider}] ${errorType}:`, error);
// Sanitize error to avoid exposing stack traces to users
const sanitizedError =
error instanceof Error ? { message: error.message, name: error.name } : error;
return createErrorResponse(errorType, { error: sanitizedError, ...res, provider });
return createModelListErrorResponse(provider, e);
}
});
@@ -1,4 +1,5 @@
import { Button, Center, Flexbox, Icon, Tooltip } from '@lobehub/ui';
import { App } from 'antd';
import { createStaticStyles } from 'antd-style';
import { BrainIcon, LucideRefreshCcwDot, PlusIcon } from 'lucide-react';
import { memo, use, useState } from 'react';
@@ -48,6 +49,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const EmptyState = memo<{ provider: string }>(({ provider }) => {
const { t } = useTranslation('modelProvider');
const { message } = App.useApp();
const { allowed: canManageProvider, reason } = usePermission('manage_provider_key');
const [fetchRemoteModelList] = useAiInfraStore((s) => [s.fetchRemoteModelList]);
@@ -89,10 +91,22 @@ const EmptyState = memo<{ provider: string }>(({ provider }) => {
setFetchRemoteModelsLoading(true);
try {
await fetchRemoteModelList(provider);
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
const errorMessage =
error instanceof Error
? error.message
: t('providerModels.list.fetcher.errorFallback');
message.error(
t('providerModels.list.fetcher.error', {
message: errorMessage,
}),
);
} finally {
setFetchRemoteModelsLoading(false);
}
setFetchRemoteModelsLoading(false);
}}
>
{fetchRemoteModelsLoading
@@ -127,10 +127,22 @@ const ModelTitle = memo<ModelFetcherProps>(
setFetchRemoteModelsLoading(true);
try {
await fetchRemoteModelList(provider);
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
const errorMessage =
error instanceof Error
? error.message
: t('providerModels.list.fetcher.errorFallback');
message.error(
t('providerModels.list.fetcher.error', {
message: errorMessage,
}),
);
} finally {
setFetchRemoteModelsLoading(false);
}
setFetchRemoteModelsLoading(false);
}}
>
{fetchRemoteModelsLoading
+79
View File
@@ -1,3 +1,5 @@
import type * as FetchSSE from '@lobechat/fetch-sse';
import { getMessageError } from '@lobechat/fetch-sse';
import { type Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -10,6 +12,15 @@ import { ModelsService } from '../models';
vi.stubGlobal('fetch', vi.fn());
vi.mock('@lobechat/fetch-sse', async () => {
const actual = (await vi.importActual('@lobechat/fetch-sse')) as typeof FetchSSE;
return {
...actual,
getMessageError: vi.fn(actual.getMessageError),
};
});
vi.mock('@/const/version', () => ({
isDesktop: false,
}));
@@ -49,6 +60,7 @@ vi.mock('@/store/user/selectors', () => ({
const modelsService = new ModelsService();
const mockedCreateHeaderWithAuth = vi.mocked(createHeaderWithAuth);
const mockedGetMessageError = vi.mocked(getMessageError);
const mockedResolveRuntimeProvider = vi.mocked(resolveRuntimeProvider);
const mockedInitializeWithClientStore = vi.mocked(initializeWithClientStore);
@@ -56,6 +68,7 @@ describe('ModelsService', () => {
beforeEach(() => {
(fetch as Mock).mockClear();
mockedCreateHeaderWithAuth.mockClear();
mockedGetMessageError.mockClear();
mockedResolveRuntimeProvider.mockReset();
mockedResolveRuntimeProvider.mockImplementation((provider: string) => provider);
mockedInitializeWithClientStore.mockClear();
@@ -109,5 +122,71 @@ describe('ModelsService', () => {
spyIsClient.mockRestore();
});
it('should throw model fetch error details when server response is not ok', async () => {
(fetch as Mock).mockResolvedValueOnce(
new Response(
JSON.stringify({
body: {
error: {
message: 'Cloudflare models API returned an invalid response',
name: 'Error',
},
message: 'Cloudflare models API returned an invalid response',
provider: 'cloudflare',
},
errorType: 'ProviderBizError',
}),
{ status: 471 },
),
);
await expect(modelsService.getModels('cloudflare')).rejects.toThrow(
'Cloudflare models API returned an invalid response',
);
});
it('should fall back to translated error message when server error body has no message', async () => {
mockedGetMessageError.mockResolvedValueOnce({
body: {
provider: 'cloudflare',
},
message: 'fallback model fetch failure',
type: 'ProviderBizError',
});
(fetch as Mock).mockResolvedValueOnce(
new Response(
JSON.stringify({
body: {
provider: 'cloudflare',
},
errorType: 'ProviderBizError',
}),
{ status: 471 },
),
);
await expect(modelsService.getModels('cloudflare')).rejects.toThrow(
'fallback model fetch failure',
);
});
it('should propagate server fetch network errors', async () => {
(fetch as Mock).mockRejectedValueOnce(new Error('network down'));
await expect(modelsService.getModels('openai')).rejects.toThrow('network down');
});
it('should propagate client runtime model fetch errors', async () => {
const spyIsClient = vi
.spyOn(aiProviderSelectors, 'isProviderFetchOnClient')
.mockReturnValue(() => true);
const mockModels = vi.fn().mockRejectedValue(new Error('client runtime failed'));
mockedInitializeWithClientStore.mockResolvedValue({ models: mockModels } as any);
await expect(modelsService.getModels('openai')).rejects.toThrow('client runtime failed');
spyIsClient.mockRestore();
});
});
});
+23 -19
View File
@@ -34,26 +34,30 @@ export class ModelsService {
});
const runtimeProvider = resolveRuntimeProvider(provider);
try {
/**
* Use browser agent runtime
*/
const enableFetchOnClient = isEnableFetchOnClient(provider);
if (enableFetchOnClient) {
const agentRuntime = await initializeWithClientStore({
provider,
runtimeProvider,
});
return agentRuntime.models();
}
const res = await fetch(API_ENDPOINTS.models(provider), { headers });
if (!res.ok) return;
return res.json();
} catch {
return;
/**
* Use browser agent runtime
*/
const enableFetchOnClient = isEnableFetchOnClient(provider);
if (enableFetchOnClient) {
const agentRuntime = await initializeWithClientStore({
provider,
runtimeProvider,
});
return agentRuntime.models();
}
const res = await fetch(API_ENDPOINTS.models(provider), { headers });
if (!res.ok) {
const error = await getMessageError(res);
const message =
typeof error.body?.message === 'string' && error.body.message
? error.body.message
: error.message;
throw new Error(message, { cause: error });
}
return res.json();
};
/**
@@ -224,6 +224,7 @@ describe('AiModelAction', () => {
.mockResolvedValue(undefined);
// Mock dynamic import
vi.resetModules();
vi.doMock('@/services/models', () => ({
modelsService: {
getModels: vi.fn().mockResolvedValue(mockRemoteModels),
@@ -274,6 +275,7 @@ describe('AiModelAction', () => {
.mockResolvedValue(undefined);
// Mock dynamic import with null response
vi.resetModules();
vi.doMock('@/services/models', () => ({
modelsService: {
getModels: vi.fn().mockResolvedValue(null),
@@ -286,6 +288,28 @@ describe('AiModelAction', () => {
expect(batchUpdateSpy).not.toHaveBeenCalled();
});
it('should propagate remote service errors', async () => {
const { result } = renderHook(() => useStore());
const batchUpdateSpy = vi
.spyOn(result.current, 'batchUpdateAiModels')
.mockResolvedValue(undefined);
vi.resetModules();
vi.doMock('@/services/models', () => ({
modelsService: {
getModels: vi.fn().mockRejectedValue(new Error('model fetch failed')),
},
}));
await expect(async () => {
await act(async () => {
await result.current.fetchRemoteModelList('test-provider');
});
}).rejects.toThrow('model fetch failed');
expect(batchUpdateSpy).not.toHaveBeenCalled();
});
});
describe('internal_toggleAiModelLoading', () => {