mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 fix: surface model list fetch failures (#15753)
This commit is contained in:
@@ -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 ``. 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 —
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user