feat: add dbgpt app start cli (#2997)

This commit is contained in:
alanchen
2026-03-22 22:42:12 +08:00
committed by GitHub
parent 91048cae6f
commit 295885f021
51 changed files with 3616 additions and 95 deletions

View File

@@ -0,0 +1,426 @@
---
sidebar_position: 1
---
# CLI Quick Start
Install DB-GPT from PyPI and start it with a single command — no source checkout required.
:::tip Prerequisites
- Python **3.10** or later
- [uv](https://docs.astral.sh/uv/getting-started/installation/) package manager (recommended) or pip
:::
## 1. Install
```bash
# Recommended: use uv
uv pip install dbgpt-app
# Or with pip
pip install dbgpt-app
```
:::info What's included
The default installation includes the **core framework** (CLI, FastAPI, SQLAlchemy, Agent),
**OpenAI-compatible LLM support** (also works with Kimi, Qwen, MiniMax, Z.AI),
**DashScope / Tongyi** support, **RAG document parsing**, and **ChromaDB** vector store.
Need additional providers or data sources? See [Optional Modules](#optional-modules).
:::
After installation the `dbgpt` command is available in your terminal.
## 2. Start DB-GPT
```bash
dbgpt start
```
That's it! On first run DB-GPT will launch an **interactive setup wizard** that helps you:
1. Choose an LLM provider (OpenAI, Kimi, Qwen, MiniMax, Z.AI, or a custom endpoint)
2. Enter your API key (or use an environment variable)
3. Confirm the model names and API base URL
Once complete, a TOML configuration file is written to `~/.dbgpt/configs/<profile>.toml` and the web server starts automatically. Open your browser at **http://localhost:5670**.
### What the startup looks like
```
____ ____ ____ ____ _____
| _ \| __ ) / ___| _ \_ _|
| | | | _ \ ____| | _| |_) || |
| |_| | |_) |____| |_| | __/ | |
|____/|____/ \____|_| |_|
🚀 DB-GPT Quick Start
+- - - - - - - - - - - - - - - - - - - - - - - -+
: Profile: openai :
: Config: /Users/you/.dbgpt/configs/openai.toml:
: Workspace: /Users/you/.dbgpt/workspace :
+- - - - - - - - - - - - - - - - - - - - - - - -+
```
---
## 3. Command Reference
### Overview
```
dbgpt [OPTIONS] COMMAND [ARGS]...
Options:
--log-level TEXT Log level (default: warn)
--version Show version and exit
--help Show help message
Commands:
start Start the DB-GPT server
stop Stop a running server
setup Configure LLM provider (interactive wizard or CI mode)
profile Manage configuration profiles
knowledge Knowledge base operations
model Manage model serving
db Database management and migration
...
```
---
### `dbgpt start`
Start the DB-GPT web server. Running `dbgpt start` without a subcommand is equivalent to `dbgpt start web`.
#### Subcommands
| Subcommand | Description |
|---|---|
| `web` (or `webserver`) | Start the web server (default) |
| `none` | API-only mode — *planned for a future release* |
| `controller` | Start the model controller |
| `worker` | Start a model worker |
| `apiserver` | Start the API server |
#### `dbgpt start web` Options
| Option | Short | Type | Default | Description |
|---|---|---|---|---|
| `--config` | `-c` | PATH | *auto* | Path to a TOML config file. If omitted, uses the active profile or launches the setup wizard. |
| `--profile` | `-p` | TEXT | *active* | Provider profile name (`openai`, `kimi`, `qwen`, `minimax`, `glm`, `custom`). Overrides the active profile. |
| `--yes` | `-y` | FLAG | false | Non-interactive mode: skip the wizard and use defaults / environment variables. Ideal for CI/CD. |
| `--api-key` | | TEXT | *env* | API key for the chosen provider. Can also be set via the provider's own environment variable. |
| `--daemon` | `-d` | FLAG | false | Run as a background daemon. Stop with `dbgpt stop webserver`. |
#### Examples
```bash
# Interactive (first run) — wizard will guide you
dbgpt start
# Use an existing profile
dbgpt start web --profile openai
# Non-interactive with explicit API key
dbgpt start web --profile kimi --api-key sk-xxx --yes
# Use a specific config file
dbgpt start web --config /path/to/my-config.toml
# Run as a daemon
dbgpt start web --daemon
```
#### Config Resolution Priority
When the web server starts, the configuration file is resolved in this order:
1. **`--config` flag** — if specified, use this file directly
2. **`--profile` flag** — look up `~/.dbgpt/configs/<profile>.toml`
3. **Active profile** — read from `~/.dbgpt/config.toml`
4. **Setup wizard** — if nothing is configured yet, launch the interactive wizard
---
### `dbgpt stop`
Stop running DB-GPT server processes.
```bash
# Stop the web server
dbgpt stop webserver
# Stop the web server on a specific port
dbgpt stop webserver --port 5670
# Stop all servers
dbgpt stop all
```
---
### `dbgpt setup`
Configure the LLM provider interactively, or in non-interactive / CI mode. This command writes a TOML config to `~/.dbgpt/configs/<profile>.toml` and marks it as the active profile.
#### Options
| Option | Short | Type | Default | Description |
|---|---|---|---|---|
| `--profile` | `-p` | TEXT | *interactive* | Provider profile to configure. If omitted, an interactive menu is shown. |
| `--yes` | `-y` | FLAG | false | Non-interactive mode: skip the wizard and use defaults. |
| `--api-key` | | TEXT | *env* | API key. Also reads `DBGPT_API_KEY` env var. |
| `--show` | | FLAG | false | Show the current active profile and config path, then exit. |
#### Examples
```bash
# Interactive wizard
dbgpt setup
# Non-interactive: use OpenAI with env key
export OPENAI_API_KEY=sk-xxx
dbgpt setup --profile openai --yes
# Non-interactive with explicit key
dbgpt setup --profile kimi --api-key sk-xxx
# Show current configuration
dbgpt setup --show
```
---
### `dbgpt profile`
Manage multiple configuration profiles. Each profile is a TOML file under `~/.dbgpt/configs/`.
#### Subcommands
| Subcommand | Description |
|---|---|
| `list` | List all profiles. The active one is marked with `*`. |
| `show <name>` | Display the TOML content of a profile. |
| `create <name>` | Create (or reconfigure) a profile using the setup wizard. |
| `switch <name>` | Set a profile as the active default. |
| `delete <name>` | Delete a profile configuration file. |
#### Examples
```bash
# List all profiles
dbgpt profile list
# openai ← no asterisk
# * kimi ← active
# Show profile content
dbgpt profile show openai
# Create a new profile
dbgpt profile create qwen
# Switch active profile
dbgpt profile switch openai
# Delete a profile
dbgpt profile delete minimax
dbgpt profile delete minimax --yes # skip confirmation
```
---
## 4. Supported Providers
The setup wizard and `--profile` flag support the following providers:
| Profile Name | Display Name | LLM Model | Embedding Model | API Key Env Var |
|---|---|---|---|---|
| `openai` | OpenAI | gpt-4o | text-embedding-3-small | `OPENAI_API_KEY` |
| `kimi` | Kimi | kimi-k2 | text-embedding-v3 | `MOONSHOT_API_KEY` (+ `DASHSCOPE_API_KEY` for embeddings) |
| `qwen` | Qwen | qwen-plus | text-embedding-v3 | `DASHSCOPE_API_KEY` |
| `minimax` | MiniMax | abab6.5s-chat | embo-01 | `MINIMAX_API_KEY` |
| `glm` | Z.AI | glm-4-plus | embedding-3 | `ZHIPUAI_API_KEY` |
| `custom` | Custom | gpt-4o | text-embedding-3-small | `OPENAI_API_KEY` |
:::info
The **Custom** profile lets you connect to any OpenAI-compatible API endpoint. During the wizard you'll be asked for the API base URL.
:::
---
## 5. Directory Structure
After first run, DB-GPT creates the following structure under your home directory:
```
~/.dbgpt/
├── config.toml # Records the active profile name
├── configs/
│ ├── openai.toml # Profile: OpenAI
│ ├── kimi.toml # Profile: Kimi
│ └── ... # One file per profile
└── workspace/
└── pilot/ # Runtime workspace (databases, data files, etc.)
├── meta_data/
│ └── dbgpt.db # SQLite metadata database
└── data/ # Vector store data
```
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `DBGPT_HOME` | `~/.dbgpt` | Override the DB-GPT home directory |
| `OPENAI_API_KEY` | — | OpenAI API key (used by `openai` and `custom` profiles) |
| `MOONSHOT_API_KEY` | — | Kimi / Moonshot API key |
| `DASHSCOPE_API_KEY` | — | Qwen / DashScope API key (also used for Kimi embeddings) |
| `MINIMAX_API_KEY` | — | MiniMax API key |
| `ZHIPUAI_API_KEY` | — | Z.AI / Zhipu API key |
| `DBGPT_API_KEY` | — | Generic API key (fallback for `--api-key` flag) |
| `DBGPT_LANG` | `en` | UI language (`en` or `zh`) |
---
## 6. Common Workflows
### First-time setup
```bash
pip install dbgpt-app
dbgpt start
# Follow the wizard → choose provider → enter API key → server starts
```
### Switch between providers
```bash
# Create a Kimi profile
dbgpt profile create kimi
# Switch to it
dbgpt profile switch kimi
# Start with the new profile
dbgpt start
```
### CI/CD deployment
```bash
export OPENAI_API_KEY=sk-xxx
dbgpt setup --profile openai --yes
dbgpt start web --daemon
```
### Custom endpoint (e.g. Azure OpenAI, local vLLM)
```bash
dbgpt setup --profile custom
# Wizard will ask for:
# - API base URL (e.g. http://localhost:8000/v1)
# - API key
# - Model names
```
---
## 7. Optional Modules
The core framework is included by default when you `pip install dbgpt-app`. Use extras to add LLM providers, vector stores, data sources, and more.
### LLM Providers
| Extra | Provider | Key packages |
|-------|----------|-------------|
| `proxy_openai` | OpenAI, Kimi, Qwen, MiniMax, Z.AI, any OpenAI-compatible API | `openai`, `tiktoken` |
| `proxy_ollama` | Ollama (local models) | `ollama` |
| `proxy_zhipuai` | Zhipu AI (GLM) | `openai` |
| `proxy_tongyi` | Tongyi Qianwen | `openai`, `dashscope` |
| `proxy_qianfan` | Baidu Qianfan | `qianfan` |
| `proxy_anthropic` | Anthropic Claude | `anthropic` |
### Vector Stores
| Extra | Storage | Key packages |
|-------|---------|-------------|
| `storage_chromadb` | ChromaDB | `chromadb`, `onnxruntime` |
| `storage_milvus` | Milvus | `pymilvus` |
| `storage_weaviate` | Weaviate | `weaviate-client` |
| `storage_elasticsearch` | Elasticsearch | `elasticsearch` |
| `storage_obvector` | OBVector | `pyobvector` |
### Knowledge & RAG
| Extra | What it adds | Key packages |
|-------|-------------|-------------|
| `rag` | Document parsing (PDF, DOCX, PPTX, Markdown, HTML) | `spacy`, `pypdf`, `python-docx`, `python-pptx` |
| `graph_rag` | Graph-based RAG with TuGraph/Neo4j | `networkx`, `neo4j` |
### Data Sources
| Extra | Database | Key packages |
|-------|----------|-------------|
| `datasource_mysql` | MySQL | `mysqlclient` |
| `datasource_postgres` | PostgreSQL | `psycopg2-binary` |
| `datasource_clickhouse` | ClickHouse | `clickhouse-connect` |
| `datasource_oracle` | Oracle | `oracledb` |
| `datasource_mssql` | SQL Server | `pymssql` |
| `datasource_spark` | Apache Spark | `pyspark` |
| `datasource_hive` | Hive | `pyhive` |
| `datasource_vertica` | Vertica | `vertica-python` |
### Example: combine multiple extras
```bash
# OpenAI + ChromaDB + RAG + MySQL
pip install "dbgpt-app[proxy_openai,storage_chromadb,rag,datasource_mysql]"
```
:::tip Minimal install
If you only need the core framework without any LLM or storage:
```bash
pip install dbgpt-app
```
This gives you the CLI, FastAPI server, and agent framework — but you'll need to add at least one LLM provider extra to actually use it.
:::
---
## 8. Troubleshooting
### Port already in use
```bash
# Stop the existing server
dbgpt stop webserver --port 5670
# Or choose a different port by editing the config file
# [service.web]
# port = 5671
```
### "No config file found" error
This means no profile has been set up yet. Run:
```bash
dbgpt setup
```
### Changing your API key
Re-run the setup wizard for the same profile — it will overwrite the existing config:
```bash
dbgpt setup --profile openai
# Or simply edit ~/.dbgpt/configs/openai.toml directly
```
### View current configuration
```bash
dbgpt setup --show
dbgpt profile show openai
```

View File

@@ -0,0 +1,9 @@
---
sidebar_position: 0
---
# Getting Started
Welcome to DB-GPT! This section will help you get up and running quickly.
- **[CLI Quick Start](./cli-quickstart)** — Install DB-GPT via pip and start it with a single command. Includes interactive setup wizard, profile management, and all CLI options.

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-acc-auto"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "Add your description here"
authors = [
{ name = "csunny", email = "cfqcsunny@gmail.com" }

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

View File

@@ -2,7 +2,7 @@
# https://github.com/astral-sh/uv/issues/2252#issuecomment-2624150395
[project]
name = "dbgpt-acc-flash-attn"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-app"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "Add your description here"
authors = [
{ name = "csunny", email = "cfqcsunny@gmail.com" }
@@ -11,8 +11,8 @@ requires-python = ">= 3.10"
dependencies = [
"dbgpt-acc-auto",
"dbgpt",
"dbgpt-ext",
"dbgpt[client,cli,agent,simple_framework,framework,code,proxy_openai,proxy_tongyi,proxy_zhipuai]",
"dbgpt-ext[rag,storage_chromadb]",
"dbgpt-serve",
"dbgpt-client",
"dbgpt-sandbox",
@@ -64,3 +64,39 @@ exclude = [
"src/dbgpt_app/**/examples/*"
]
[tool.hatch.build.targets.sdist.force-include]
# Builtin skills
"../../skills/csv-data-analysis" = "skills/csv-data-analysis"
"../../skills/skill-creator" = "skills/skill-creator"
"../../skills/financial-report-analyzer" = "skills/financial-report-analyzer"
"../../skills/walmart-sales-analyzer" = "skills/walmart-sales-analyzer"
"../../skills/agent-browser" = "skills/agent-browser"
# Builtin example files
"../../docker/examples/excel/Walmart_Sales.csv" = "examples/excel/Walmart_Sales.csv"
"../../docker/examples/fin_report/pdf/2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf" = "examples/fin_report/pdf/2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf"
# Pilot workspace template files (source of truth: pilot/)
"../../pilot/meta_data/alembic.ini" = "pilot_tpl/meta_data/alembic.ini"
"../../pilot/meta_data/alembic/README" = "pilot_tpl/meta_data/alembic/README"
"../../pilot/meta_data/alembic/env.py" = "pilot_tpl/meta_data/alembic/env.py"
"../../pilot/meta_data/alembic/script.py.mako" = "pilot_tpl/meta_data/alembic/script.py.mako"
"../../pilot/benchmark_meta_data/2025_07_27_public_500_standard_benchmark_question_list.xlsx" = "pilot_tpl/benchmark_meta_data/2025_07_27_public_500_standard_benchmark_question_list.xlsx"
"../../pilot/examples/Walmart_Sales.db" = "pilot_tpl/examples/Walmart_Sales.db"
[tool.hatch.build.targets.wheel.force-include]
# Builtin skills
"skills/csv-data-analysis" = "dbgpt_app/_builtin_skills/csv-data-analysis"
"skills/skill-creator" = "dbgpt_app/_builtin_skills/skill-creator"
"skills/financial-report-analyzer" = "dbgpt_app/_builtin_skills/financial-report-analyzer"
"skills/walmart-sales-analyzer" = "dbgpt_app/_builtin_skills/walmart-sales-analyzer"
"skills/agent-browser" = "dbgpt_app/_builtin_skills/agent-browser"
# Builtin example files
"examples/excel/Walmart_Sales.csv" = "dbgpt_app/_builtin_examples/excel/Walmart_Sales.csv"
"examples/fin_report/pdf/2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf" = "dbgpt_app/_builtin_examples/fin_report/pdf/2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf"
# Pilot workspace template files (provisioned to ~/.dbgpt/workspace/pilot/ on first startup)
"pilot_tpl/meta_data/alembic.ini" = "dbgpt_app/pilot_template/meta_data/alembic.ini"
"pilot_tpl/meta_data/alembic/README" = "dbgpt_app/pilot_template/meta_data/alembic/README"
"pilot_tpl/meta_data/alembic/env.py" = "dbgpt_app/pilot_template/meta_data/alembic/env.py"
"pilot_tpl/meta_data/alembic/script.py.mako" = "dbgpt_app/pilot_template/meta_data/alembic/script.py.mako"
"pilot_tpl/benchmark_meta_data/2025_07_27_public_500_standard_benchmark_question_list.xlsx" = "dbgpt_app/pilot_template/benchmark_meta_data/2025_07_27_public_500_standard_benchmark_question_list.xlsx"
"pilot_tpl/examples/Walmart_Sales.db" = "dbgpt_app/pilot_template/examples/Walmart_Sales.db"

View File

@@ -0,0 +1,7 @@
"""Builtin example files bundled with the dbgpt-app wheel.
This package exists solely as an anchor so that code can locate the
bundled example data files via ``os.path.dirname(__file__)``.
The actual data files are injected by hatch ``force-include`` at build
time and are **not** present during source-code development.
"""

View File

@@ -0,0 +1,6 @@
"""Builtin skills bundled with dbgpt-app.
These skills are copied into the user's skills directory (~/.dbgpt/skills/)
on first startup via ``ensure_builtin_skills()``. Do **not** modify files
in this directory directly -- they will be overwritten on package upgrade.
"""

View File

@@ -5,24 +5,168 @@ from typing import Any, Dict, Optional
import click
from dbgpt.configs.model_config import LOGDIR
from dbgpt.model.cli import add_start_server_options
from dbgpt.util.command_utils import _run_current_with_daemon, _stop_service
from dbgpt.util.i18n_utils import _
_GLOBAL_CONFIG: str = ""
_BANNER_ART = """\
____ ____ ____ ____ _____
| _ \\| __ ) / ___| _ \\_ _|
| | | | _ \\ ____| | _| |_) || |
| |_| | |_) |____| |_| | __/ | |
|____/|____/ \\____|_| |_|\
"""
def _print_banner() -> None:
"""Print the DB-GPT ASCII art banner to the terminal."""
from dbgpt.util.console.console import CliLogger
_log = CliLogger()
_log.print(f"[bold green]{_BANNER_ART}[/bold green]")
_log.print("")
_log.print(" [dim]🚀 DB-GPT Quick Start[/dim]")
_log.print("")
def _add_webserver_start_options(func):
"""Click options decorator for the webserver start command.
Unlike the generic ``add_start_server_options``, ``--config`` here is
*optional* so that users can rely on ``--profile`` / wizard flow instead.
"""
@click.option(
"-c",
"--config",
type=str,
required=False,
default=None,
help=_(
"Path to a TOML config file. If omitted, DB-GPT will use the active "
"profile from ~/.dbgpt/ or run the first-time setup wizard."
),
)
@click.option(
"-p",
"--profile",
type=str,
required=False,
default=None,
help=_(
"Name of the provider profile to use (openai / kimi / qwen / minimax / "
"deepseek / ollama). Overrides the active profile in ~/.dbgpt/config.toml."
),
)
@click.option(
"-y",
"--yes",
is_flag=True,
default=False,
help=_(
"Non-interactive mode: skip the setup wizard and use defaults / "
"environment variables. Useful for CI/CD and scripted installs."
),
)
@click.option(
"--api-key",
type=str,
required=False,
default=None,
envvar="DBGPT_API_KEY",
help=_(
"API key for the chosen provider. Can also be set via the provider's "
"own environment variable (e.g. OPENAI_API_KEY)."
),
)
@click.option(
"-d",
"--daemon",
is_flag=True,
help=_(
"Run in daemon mode. It will run in the background. If you want to stop"
" it, use `dbgpt stop` command"
),
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@click.command(name="webserver")
@add_start_server_options
def start_webserver(config: str, **kwargs):
"""Start webserver(dbgpt_server.py)"""
if kwargs["daemon"]:
@_add_webserver_start_options
def start_webserver(
config: Optional[str],
profile: Optional[str],
yes: bool,
api_key: Optional[str],
**kwargs,
):
"""Start webserver (dbgpt_server.py).
On first run (or when no config is found) DB-GPT will launch an
interactive setup wizard so you can choose your LLM provider and API key.
Use ``--yes`` to skip the wizard in non-interactive environments.
"""
# Print banner first — skip in daemon mode (output goes to a log file)
if not kwargs.get("daemon"):
_print_banner()
if kwargs.get("daemon"):
log_file = os.path.join(LOGDIR, "webserver_uvicorn.log")
_run_current_with_daemon("WebServer", log_file)
else:
from dbgpt_app.dbgpt_server import run_webserver
return
run_webserver(config)
# Resolve (or create) a config file via the wizard if needed
try:
from dbgpt.cli._wizard import maybe_run_wizard
resolved_config = maybe_run_wizard(
profile=profile,
config=config,
yes=yes,
api_key=api_key,
)
except ImportError:
# Graceful fallback: if wizard module is somehow unavailable, require --config
if not config:
raise click.UsageError(
"No config file found. Please pass --config or run `dbgpt setup`."
)
resolved_config = config
from pathlib import Path
from dbgpt.cli._config import dbgpt_home
from dbgpt.util.console.console import CliLogger
_log = CliLogger()
_profile = Path(resolved_config).stem
_workspace = dbgpt_home() / "workspace"
_log.print("")
_info_lines = [
("Profile: ", str(_profile)),
("Config: ", str(resolved_config)),
("Workspace: ", str(_workspace)),
]
_max_len = max(len(f" {lbl}{val}") for lbl, val in _info_lines)
_inner_w = _max_len + 2 # 1 space padding each side
_dash_line = "- " * ((_inner_w + 1) // 2)
_dash_line = _dash_line[:_inner_w] # trim to exact width
_log.print(f" +{_dash_line}+", highlight=False)
for _lbl, _val in _info_lines:
_content = f" {_lbl}[bold]{_val}[/bold]"
_pad = _max_len - len(f" {_lbl}{_val}")
_log.print(f" : {_content}{' ' * _pad} :", highlight=False)
_log.print(f" +{_dash_line}+", highlight=False)
_log.print("")
from dbgpt_app.dbgpt_server import run_webserver
run_webserver(resolved_config)
@click.command(name="webserver")
@@ -312,6 +456,12 @@ def _get_migration_config(
default_meta_data_path = _initialize_db(
db_url, "sqlite", db_name, db_engine_args, try_to_create_db=True
)
from dbgpt_app.initialization.workspace_provisioning import _ensure_pilot_workspace
# Provision pilot workspace template files for pip-installed users.
# dest_root is the parent of meta_data/ (e.g. ~/.dbgpt/workspace/pilot/)
pilot_root = os.path.dirname(default_meta_data_path)
_ensure_pilot_workspace(pilot_root)
alembic_cfg = create_alembic_config(
default_meta_data_path,
db_manager.engine,

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

View File

@@ -104,7 +104,32 @@ def _migration_db_storage(
from dbgpt_app.initialization.db_model_initialization import _MODELS # noqa: F401
from dbgpt_ext.datasource.rdbms.conn_sqlite import SQLiteConnectorParameters
default_meta_data_path = os.path.join(PILOT_PATH, "meta_data")
# Derive meta_data path from the resolved db path when available.
# For pip-installed users, db_params.path is an absolute path like
# ~/.dbgpt/workspace/pilot/meta_data/dbgpt.db (already resolved by
# _initialize_db_storage), so we use its parent directory. For source-code
# developers with relative paths, we fall back to PILOT_PATH/meta_data.
if (
isinstance(db_params, SQLiteConnectorParameters)
and hasattr(db_params, "path")
and db_params.path
and os.path.isabs(db_params.path)
):
default_meta_data_path = os.path.dirname(db_params.path)
else:
default_meta_data_path = os.path.join(PILOT_PATH, "meta_data")
from dbgpt_app.initialization.workspace_provisioning import _ensure_pilot_workspace
# Provision pilot workspace template files for pip-installed users.
# dest_root is the parent of meta_data/ (e.g. ~/.dbgpt/workspace/pilot/)
pilot_root = os.path.dirname(default_meta_data_path)
_ensure_pilot_workspace(pilot_root)
# Provision builtin skills for pip-installed users.
from dbgpt.configs.model_config import SKILLS_DIR
from dbgpt_app.initialization.skills_provisioning import ensure_builtin_skills
ensure_builtin_skills(SKILLS_DIR)
if not disable_alembic_upgrade:
from dbgpt.storage.metadata.db_manager import db
from dbgpt.util._db_migration_utils import _ddl_init_and_upgrade

View File

@@ -89,10 +89,11 @@ def mount_routers(app: FastAPI):
def mount_static_files(app: FastAPI, param: ApplicationConfig):
package_dir = os.path.dirname(os.path.abspath(__file__))
if param.service.web.new_web_ui:
static_file_path = os.path.join(ROOT_PATH, "src", "dbgpt_app/static/web")
static_file_path = os.path.join(package_dir, "static", "web")
else:
static_file_path = os.path.join(ROOT_PATH, "src", "dbgpt_app/static/old_web")
static_file_path = os.path.join(package_dir, "static", "old_web")
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
app.mount(
@@ -172,25 +173,37 @@ def initialize_app(param: ApplicationConfig, args: List[str] = None):
# Register default data sources
try:
from dbgpt.configs.model_config import ROOT_PATH
from dbgpt.configs.model_config import PILOT_PATH, ROOT_PATH
from dbgpt_serve.datasource.manages.connect_config_db import ConnectConfigDao
dao = ConnectConfigDao()
db_name = "Walmart_Sales"
if not dao.get_by_names(db_name):
db_absolute_path = os.path.join(
ROOT_PATH, "docker/examples/dashboard/Walmart_Sales.db"
)
dao.add_file_db(
db_name=db_name,
db_type="sqlite",
db_path=db_absolute_path,
comment="Default Walmart Sales example database",
)
logger.info(
f"Successfully registered default data source: "
f"{db_name} at {db_absolute_path}"
candidate_paths = [
os.path.join(PILOT_PATH, "examples", "Walmart_Sales.db"),
os.path.join(
ROOT_PATH, "docker", "examples", "dashboard", "Walmart_Sales.db"
),
]
db_absolute_path = next(
(p for p in candidate_paths if os.path.isfile(p)), None
)
if db_absolute_path is None:
logger.info(
f"Skipping default data source '%s': file not found in any "
f"{db_name} at {candidate_paths}"
)
else:
dao.add_file_db(
db_name=db_name,
db_type="sqlite",
db_path=db_absolute_path,
comment="Default Walmart Sales example database",
)
logger.info(
f"Successfully registered default data source: "
f"{db_name} at {db_absolute_path}"
)
except Exception as e:
logger.error(f"Failed to register default data sources: {str(e)}")

View File

@@ -0,0 +1,68 @@
"""Skills provisioning module for dbgpt-app pip package users.
Copies builtin skill templates from the installed package to the user's
skills directory on first startup. Existing skills are never overwritten.
"""
import logging
import os
import shutil
logger = logging.getLogger(__name__)
def ensure_builtin_skills(skills_dir: str) -> None:
"""Idempotently seed builtin skills into *skills_dir*.
On first run after ``pip install dbgpt-app``, the builtin skill
templates bundled inside the wheel (``dbgpt_app/_builtin_skills/``)
are copied to *skills_dir*. Skills that already exist in the
destination are **never** overwritten so that user modifications are
preserved.
This function is a no-op when:
* ``dbgpt_app._builtin_skills`` cannot be imported (e.g. running
from a source checkout where the force-include hasn't been
triggered).
* The builtin skills directory inside the package is empty.
Args:
skills_dir: Absolute path to the target skills directory,
e.g. ``~/.dbgpt/skills/``.
"""
try:
import dbgpt_app._builtin_skills as _bs
builtin_root = os.path.dirname(_bs.__file__)
except (ImportError, AttributeError):
logger.debug("dbgpt_app._builtin_skills not available, skipping seed.")
return
if not os.path.isdir(builtin_root):
return
os.makedirs(skills_dir, exist_ok=True)
for entry in os.listdir(builtin_root):
# Skip Python artifacts and hidden files
if entry.startswith(("_", ".")) or entry == "__pycache__":
continue
src = os.path.join(builtin_root, entry)
dst = os.path.join(skills_dir, entry)
# Never overwrite existing skills (user may have modified them)
if os.path.exists(dst):
logger.debug("Builtin skill already exists, skipping: %s", dst)
continue
if os.path.isdir(src):
shutil.copytree(src, dst)
logger.info("Provisioned builtin skill: %s", entry)
else:
shutil.copy2(src, dst)
logger.info("Provisioned builtin skill file: %s", entry)
# Ensure user/ subdirectory exists for uploaded/imported skills
user_dir = os.path.join(skills_dir, "user")
os.makedirs(user_dir, exist_ok=True)

View File

@@ -0,0 +1,41 @@
"""Workspace provisioning module for dbgpt-app pip package users.
Copies pilot template files to the user's workspace directory on first startup.
"""
import logging
import os
import shutil
logger = logging.getLogger(__name__)
def _ensure_pilot_workspace(dest_root: str) -> None:
"""Idempotently copy pilot workspace template files to dest_root.
This function is safe to call multiple times — it will never overwrite
existing files. On first run, it provisions the full pilot/ directory
structure including alembic config and benchmark data.
Args:
dest_root (str): The destination root directory (parent of meta_data/).
Example: ~/.dbgpt/workspace/pilot/
"""
template_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"pilot_template",
)
for src_dir, dirs, files in os.walk(template_dir):
dirs[:] = [d for d in dirs if d not in ("__pycache__", "versions")]
for filename in files:
if filename in (".DS_Store",) or filename.endswith((".pyc",)):
continue
src_file = os.path.join(src_dir, filename)
rel_path = os.path.relpath(src_file, template_dir)
dest_file = os.path.join(dest_root, rel_path)
if not os.path.exists(dest_file):
os.makedirs(os.path.dirname(dest_file), exist_ok=True)
shutil.copy2(src_file, dest_file)
logger.info("Provisioned: %s", dest_file)
else:
logger.debug("Skipped (exists): %s", dest_file)

View File

@@ -19,7 +19,7 @@ from dbgpt._private.pydantic import BaseModel as _BaseModel
from dbgpt.agent.resource.tool.base import tool
from dbgpt.agent.skill.manage import get_skill_manager
from dbgpt.component import ComponentType
from dbgpt.configs.model_config import resolve_root_path
from dbgpt.configs.model_config import SKILLS_DIR, resolve_root_path
from dbgpt.core import PromptTemplate
from dbgpt.model.cluster import WorkerManagerFactory
from dbgpt_app.openapi.api_view_model import (
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
REACT_AGENT_MEMORY_CACHE: Dict[str, "GptsMemory"] = {}
DEFAULT_SKILLS_DIR = resolve_root_path("skills") or "skills"
DEFAULT_SKILLS_DIR = SKILLS_DIR
AUTO_DATA_MARKER_PATTERN = re.compile(
r"###([A-Z0-9_]+)_START###\s*(.*?)\s*###\1_END###", re.DOTALL
)

View File

@@ -1,6 +1,19 @@
"""API endpoints for bundled example files.
Provides a single ``POST /v1/examples/use`` endpoint that copies a bundled
example file to the user's upload directory so it can be used in
conversations.
Example files are resolved in the following order:
1. ``docker/examples/`` under the source-code project root (dev mode).
2. ``dbgpt_app/_builtin_examples/`` inside the installed wheel (PyPI mode).
"""
import logging
import os
import shutil
from typing import Optional
from fastapi import APIRouter, Body, Depends
@@ -12,32 +25,78 @@ router = APIRouter()
CFG = Config()
logger = logging.getLogger(__name__)
# Map of example IDs to their file paths (relative to project root)
# Map of example IDs to their file info.
# - ``source_path``: path relative to source-repo root (``docker/examples/…``).
# - ``builtin_path``: path relative to ``_builtin_examples/`` inside the wheel.
# - ``name``: the user-visible filename.
EXAMPLE_FILES = {
"walmart_sales": {
"path": "docker/examples/excel/Walmart_Sales.csv",
"source_path": "docker/examples/excel/Walmart_Sales.csv",
"builtin_path": "excel/Walmart_Sales.csv",
"name": "Walmart_Sales.csv",
},
"csv_visual_report": {
"path": "docker/examples/excel/Walmart_Sales.csv",
"source_path": "docker/examples/excel/Walmart_Sales.csv",
"builtin_path": "excel/Walmart_Sales.csv",
"name": "Walmart_Sales.csv",
},
"fin_report": {
"path": (
"source_path": (
"docker/examples/fin_report/pdf/"
"2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf"
),
"builtin_path": (
"fin_report/pdf/"
"2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf"
),
"name": (
"2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf"
),
},
"create_sql_skill": {
"path": "docker/examples/txt/sql_skill.txt",
"source_path": "docker/examples/txt/sql_skill.txt",
"builtin_path": "txt/sql_skill.txt",
"name": "sql_skill.txt",
},
}
def _resolve_example_source(example: dict) -> Optional[str]:
"""Return the absolute path to an example file, or *None* if not found.
Resolution order:
1. ``docker/examples/…`` under ``SYSTEM_APP.work_dir`` or cwd (source-code
development mode).
2. ``_builtin_examples/…`` inside the installed ``dbgpt_app`` package
(PyPI install mode).
"""
# --- 1. Source-code / work_dir mode ---
base_dir = os.getcwd()
if (
CFG.SYSTEM_APP
and hasattr(CFG.SYSTEM_APP, "work_dir")
and CFG.SYSTEM_APP.work_dir
):
base_dir = CFG.SYSTEM_APP.work_dir
candidate = os.path.join(base_dir, example["source_path"])
if os.path.isfile(candidate):
return candidate
# --- 2. Builtin examples bundled in the wheel ---
try:
import dbgpt_app._builtin_examples as _be
builtin_root = os.path.dirname(_be.__file__)
candidate = os.path.join(builtin_root, example["builtin_path"])
if os.path.isfile(candidate):
return candidate
except (ImportError, AttributeError):
pass
return None
@router.post("/v1/examples/use", response_model=Result[str])
async def use_example_file(
example_id: str = Body(..., embed=True),
@@ -51,7 +110,11 @@ async def use_example_file(
example = EXAMPLE_FILES[example_id]
user_id = user_token.user_id or "default"
# Determine base directory
source_path = _resolve_example_source(example)
if source_path is None:
return Result.failed(msg=f"Example file not found: {example['name']}")
# Determine upload base directory (same convention as python_upload_api)
base_dir = os.getcwd()
if (
CFG.SYSTEM_APP
@@ -60,28 +123,6 @@ async def use_example_file(
):
base_dir = CFG.SYSTEM_APP.work_dir
# Source file - try base_dir first, then project root
source_path = os.path.join(base_dir, example["path"])
if not os.path.exists(source_path):
project_root = os.path.dirname(
os.path.dirname(
os.path.dirname(
os.path.dirname(
os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
)
)
)
)
)
source_path = os.path.join(project_root, example["path"])
if not os.path.exists(source_path):
return Result.failed(msg=f"Example file not found: {example['name']}")
# Target directory - same as python_upload_api
upload_dir = os.path.join(base_dir, "python_uploads", user_id)
os.makedirs(upload_dir, exist_ok=True)

View File

@@ -0,0 +1,47 @@
"""Tests for workspace_provisioning module."""
import os
from dbgpt_app.initialization.workspace_provisioning import _ensure_pilot_workspace
def test_ensure_pilot_workspace_copies_alembic_ini(tmp_path):
"""Test that alembic.ini is copied to dest_root/meta_data/alembic.ini"""
_ensure_pilot_workspace(str(tmp_path))
assert os.path.exists(tmp_path / "meta_data" / "alembic.ini")
def test_ensure_pilot_workspace_copies_alembic_env_py(tmp_path):
"""Test that alembic/env.py is copied"""
_ensure_pilot_workspace(str(tmp_path))
assert os.path.exists(tmp_path / "meta_data" / "alembic" / "env.py")
def test_ensure_pilot_workspace_copies_alembic_script_mako(tmp_path):
"""Test that alembic/script.py.mako is copied"""
_ensure_pilot_workspace(str(tmp_path))
assert os.path.exists(tmp_path / "meta_data" / "alembic" / "script.py.mako")
def test_ensure_pilot_workspace_copies_benchmark_xlsx(tmp_path):
"""Test that benchmark xlsx is copied"""
_ensure_pilot_workspace(str(tmp_path))
xlsx_files = list((tmp_path / "benchmark_meta_data").glob("*.xlsx"))
assert len(xlsx_files) == 1
def test_ensure_pilot_workspace_idempotent_no_overwrite(tmp_path):
"""Test that calling twice does not overwrite existing files"""
_ensure_pilot_workspace(str(tmp_path))
ini_path = tmp_path / "meta_data" / "alembic.ini"
# write custom content to simulate user modification
ini_path.write_text("custom content")
_ensure_pilot_workspace(str(tmp_path)) # call again
assert ini_path.read_text() == "custom content" # must not be overwritten
def test_ensure_pilot_workspace_creates_missing_directories(tmp_path):
"""Test that missing destination directories are created automatically"""
dest = tmp_path / "deep" / "nested" / "pilot"
_ensure_pilot_workspace(str(dest))
assert os.path.exists(dest / "meta_data" / "alembic.ini")

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-client"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "Add your description here"
authors = [
{ name = "csunny", email = "cfqcsunny@gmail.com" }

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = """DB-GPT is an experimental open-source project that uses localized GPT \
large models to interact with your data and environment. With this solution, you can be\
assured that there is no risk of data leakage, and your data is 100% private and secure.\

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

View File

@@ -523,14 +523,9 @@ class SkillManager(BaseComponent):
if hasattr(metadata, "path"):
return metadata.path
skills_dir = os.environ.get("DBGPT_SKILLS_DIR")
if not skills_dir:
from dbgpt.configs.model_config import resolve_root_path
from dbgpt.configs.model_config import SKILLS_DIR
skills_dir = resolve_root_path("skills")
if not skills_dir:
skills_dir = "skills"
skills_dir = SKILLS_DIR
# Search candidate subdirectories: direct, user/, claude/, project/, etc.
subdirs = ["", "user", "claude", "project"]

View File

@@ -0,0 +1,322 @@
"""User-level configuration management for DB-GPT CLI.
Manages ``~/.dbgpt/configs/<profile>.toml`` — one flat TOML file per
profile — and a small ``~/.dbgpt/config.toml`` that records which profile
is active by default.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_DBGPT_HOME = Path(os.environ.get("DBGPT_HOME", str(Path.home() / ".dbgpt")))
_CONFIGS_DIR = _DBGPT_HOME / "configs"
_ACTIVE_CONFIG = _DBGPT_HOME / "config.toml"
def dbgpt_home() -> Path:
"""Return ``~/.dbgpt``, creating it if necessary."""
_DBGPT_HOME.mkdir(parents=True, exist_ok=True)
return _DBGPT_HOME
def configs_dir() -> Path:
"""Return ``~/.dbgpt/configs/``, creating it if necessary."""
_CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
return _CONFIGS_DIR
def profile_config_path(profile_name: str) -> Path:
"""Return the path for a profile TOML, e.g. ``~/.dbgpt/configs/openai.toml``."""
return configs_dir() / f"{profile_name}.toml"
def active_config_path() -> Path:
"""Return ``~/.dbgpt/config.toml``."""
return _ACTIVE_CONFIG
# ---------------------------------------------------------------------------
# Active-profile record
# ---------------------------------------------------------------------------
def read_active_profile() -> Optional[str]:
"""Return the name of the active profile from ``~/.dbgpt/config.toml``.
Returns:
Optional[str]: Profile name, or *None* if not yet configured.
"""
path = active_config_path()
if not path.exists():
return None
try:
import tomlkit # type: ignore[import]
data = tomlkit.loads(path.read_text(encoding="utf-8"))
return data.get("default", {}).get("profile") or None
except Exception:
return None
def write_active_profile(profile_name: str) -> None:
"""Persist the active profile name to ``~/.dbgpt/config.toml``.
Args:
profile_name (str): The profile to activate.
"""
import tomlkit # type: ignore[import]
path = active_config_path()
dbgpt_home() # ensure directory exists
if path.exists():
try:
data = tomlkit.loads(path.read_text(encoding="utf-8"))
except Exception:
data = tomlkit.document()
else:
data = tomlkit.document()
if "default" not in data:
data["default"] = tomlkit.table()
data["default"]["profile"] = profile_name # type: ignore[index]
path.write_text(tomlkit.dumps(data), encoding="utf-8")
# ---------------------------------------------------------------------------
# Profile TOML generation
# ---------------------------------------------------------------------------
def _escape_toml_string(value: str) -> str:
"""Escape backslashes and double quotes for TOML basic strings."""
return value.replace("\\", "\\\\").replace('"', '\\"')
def _render_profile_toml(
spec: "ProfileSpec", # noqa: F821
api_key: Optional[str],
llm_model: Optional[str] = None,
embedding_model: Optional[str] = None,
api_base: Optional[str] = None,
embedding_api_key: Optional[str] = None,
) -> str:
"""Render a complete TOML config for the given profile.
The generated file uses a literal API key when *api_key* is provided;
otherwise it uses the ``${env:VAR}`` interpolation syntax so the server
reads the key from the environment at runtime.
Args:
spec: A :class:`~dbgpt.cli._profiles.ProfileSpec` instance.
api_key (Optional[str]): Literal API key value, or *None*.
llm_model (Optional[str]): Override for the LLM model name. If *None*,
uses ``spec.llm_model``.
embedding_model (Optional[str]): Override for the embedding model name.
If *None*, uses ``spec.embedding_model``.
api_base (Optional[str]): Override for the LLM API base URL. If *None*,
uses ``spec.llm_api_base``.
embedding_api_key (Optional[str]): Literal embedding API key, or
*None*. When *None* and ``spec.embedding_env_var`` is set (and
differs from ``spec.env_var``), the generated TOML will reference
that separate env var instead of the LLM key.
Returns:
str: TOML content as a string.
"""
from dbgpt.cli._profiles import ProfileSpec # noqa: F401 (type-only)
if api_key:
api_key_value = _escape_toml_string(api_key)
elif spec.env_var:
api_key_value = f"${{env:{spec.env_var}:-sk-xxx}}"
else:
api_key_value = ""
emb_env_var = spec.embedding_env_var or spec.env_var
if embedding_api_key:
emb_key_value = _escape_toml_string(embedding_api_key)
elif api_key and not spec.embedding_env_var:
emb_key_value = _escape_toml_string(api_key)
elif emb_env_var:
emb_key_value = f"${{env:{emb_env_var}:-sk-xxx}}"
else:
emb_key_value = ""
if api_base:
embedding_api_url = f"{api_base.rstrip('/')}/embeddings"
else:
embedding_api_url = spec.embedding_api_url
data_dir = str(dbgpt_home() / "workspace" / "pilot" / "meta_data" / "dbgpt.db")
vector_dir = str(dbgpt_home() / "workspace" / "pilot" / "data")
lines = [
f"# DB-GPT configuration — profile: {spec.name}",
"# Generated by `dbgpt setup`",
"",
"[system]",
"# Load language from environment variable(It is set by the hook)",
'language = "${env:DBGPT_LANG:-en}"',
"api_keys = []",
'encrypt_key = "your_secret_key"',
"",
"# Server Configurations",
"[service.web]",
'host = "0.0.0.0"',
"port = 5670",
"",
"[service.web.database]",
'type = "sqlite"',
f'path = "{data_dir}"',
"",
"[rag.storage]",
"[rag.storage.vector]",
'type = "chroma"',
f'persist_path = "{vector_dir}"',
"",
"# Model Configurations",
"[models]",
"[[models.llms]]",
]
use_env = getattr(spec, "use_env_interpolation", False) and not api_key
if use_env:
lines.append(f'name = "${{env:LLM_MODEL_NAME:-{spec.llm_model}}}"')
lines.append(f'provider = "${{env:LLM_MODEL_PROVIDER:-{spec.llm_provider}}}"')
effective_api_base = api_base or spec.llm_api_base
if effective_api_base:
lines.append(f'api_base = "${{env:OPENAI_API_BASE:-{effective_api_base}}}"')
lines.append(f'api_key = "{api_key_value}"')
lines.append("")
lines.append("[[models.embeddings]]")
lines.append(f'name = "${{env:EMBEDDING_MODEL_NAME:-{spec.embedding_model}}}"')
lines.append(
f'provider = "${{env:EMBEDDING_MODEL_PROVIDER:-{spec.embedding_provider}}}"'
)
lines.append(
f'api_url = "${{env:EMBEDDING_MODEL_API_URL:-{embedding_api_url}}}"'
)
lines.append(f'api_key = "{emb_key_value}"')
else:
lines.append(f'name = "{llm_model or spec.llm_model}"')
lines.append(f'provider = "{spec.llm_provider}"')
effective_api_base = api_base or spec.llm_api_base
if effective_api_base:
lines.append(f'api_base = "{effective_api_base}"')
lines.append(f'api_key = "{api_key_value}"')
lines.append("")
lines.append("[[models.embeddings]]")
lines.append(f'name = "{embedding_model or spec.embedding_model}"')
lines.append(f'provider = "{spec.embedding_provider}"')
lines.append(f'api_url = "{embedding_api_url}"')
lines.append(f'api_key = "{emb_key_value}"')
# Append any provider-specific extras
for extra in spec.extra_toml_lines:
lines.append(extra)
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Public write API
# ---------------------------------------------------------------------------
def write_profile_config(
profile_name: str,
api_key: Optional[str] = None,
activate: bool = True,
llm_model: Optional[str] = None,
embedding_model: Optional[str] = None,
api_base: Optional[str] = None,
embedding_api_key: Optional[str] = None,
) -> Path:
"""Write (or overwrite) the TOML config for *profile_name*.
Args:
profile_name (str): One of the supported profile names.
api_key (Optional[str]): Literal API key. If *None*, env-var
interpolation is used instead.
activate (bool): Also update ``~/.dbgpt/config.toml`` to make this
profile the active default.
llm_model (Optional[str]): Override LLM model name. Uses spec default
if *None*.
embedding_model (Optional[str]): Override embedding model name. Uses
spec default if *None*.
api_base (Optional[str]): Override LLM API base URL. Uses spec default
if *None*.
embedding_api_key (Optional[str]): Literal embedding API key. When
*None* and the profile has a separate ``embedding_env_var``, the
generated TOML will reference that env var.
Returns:
Path: Path to the written config file.
"""
from dbgpt.cli._profiles import get_profile
spec = get_profile(profile_name)
content = _render_profile_toml(
spec,
api_key,
llm_model=llm_model,
embedding_model=embedding_model,
api_base=api_base,
embedding_api_key=embedding_api_key,
)
path = profile_config_path(profile_name)
path.write_text(content, encoding="utf-8")
if activate:
write_active_profile(profile_name)
return path
def resolve_config_path(
profile: Optional[str] = None,
config: Optional[str] = None,
) -> Optional[str]:
"""Resolve which config file to use, in priority order.
Priority:
1. Explicit ``--config`` flag → use as-is.
2. Explicit ``--profile`` flag → look up ``~/.dbgpt/configs/<profile>.toml``.
3. Active profile from ``~/.dbgpt/config.toml``.
4. Return *None* (caller should run the setup wizard).
Args:
profile (Optional[str]): Value of ``--profile`` CLI flag.
config (Optional[str]): Value of ``--config`` CLI flag.
Returns:
Optional[str]: Absolute path to the config file, or *None*.
"""
if config:
return config
if profile:
path = profile_config_path(profile)
if path.exists():
return str(path)
return None # profile specified but not yet configured
# Fall back to whatever is active
active = read_active_profile()
if active:
path = profile_config_path(active)
if path.exists():
return str(path)
return None

View File

@@ -0,0 +1,95 @@
"""Profile management subcommands: list, show, create, switch, delete."""
import click
@click.group()
def profile():
"""Manage DB-GPT configuration profiles."""
pass
@profile.command(name="list")
def profile_list():
"""List all configured profiles."""
from dbgpt.cli._config import configs_dir, read_active_profile
configs = configs_dir()
active = read_active_profile()
toml_files = sorted(configs.glob("*.toml"))
if not toml_files:
click.echo("No profiles configured. Run: dbgpt setup")
return
for f in toml_files:
name = f.stem
marker = "* " if name == active else " "
click.echo(f"{marker}{name}")
@profile.command()
@click.argument("name")
def show(name):
"""Show the TOML configuration for a profile."""
from dbgpt.cli._config import profile_config_path
path = profile_config_path(name)
if not path.exists():
raise click.ClickException(
f"Profile '{name}' not found. Run: dbgpt profile create {name}"
)
click.echo(path.read_text(encoding="utf-8"))
@profile.command()
@click.argument("name")
def create(name):
"""Create or reconfigure a profile (runs the setup wizard)."""
from dbgpt.cli._wizard import run_setup_wizard
run_setup_wizard(pre_selected_profile=name)
@profile.command()
@click.argument("name")
def switch(name):
"""Set a profile as the active default."""
from dbgpt.cli._config import profile_config_path, write_active_profile
path = profile_config_path(name)
if not path.exists():
click.echo(
f"Error: Profile '{name}' not found. Run: dbgpt profile create {name}",
err=False,
)
raise SystemExit(1)
write_active_profile(name)
click.echo(f"Switched active profile to: {name}")
@profile.command()
@click.argument("name")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
def delete(name, yes):
"""Delete a profile configuration file."""
from dbgpt.cli._config import (
profile_config_path,
read_active_profile,
write_active_profile,
)
path = profile_config_path(name)
if not path.exists():
raise click.ClickException(f"Profile '{name}' not found.")
if not yes:
click.confirm(f"Delete profile '{name}'?", abort=True)
path.unlink()
# Clear active pointer if this was the active profile
if read_active_profile() == name:
write_active_profile("") # clear by writing empty string
click.echo(f"Deleted profile: {name}")

View File

@@ -0,0 +1,203 @@
"""Profile definitions and API key resolution for DB-GPT CLI.
Each profile corresponds to a supported LLM provider and contains the
information needed to generate a TOML configuration file and resolve
API credentials from environment variables.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass
class ProfileSpec:
"""Specification for a single LLM provider profile."""
name: str
"""Internal identifier, e.g. 'openai'."""
label: str
"""Human-readable display name, e.g. 'OpenAI (GPT-4o)'."""
env_var: str
"""Primary environment variable that holds the API key."""
llm_model: str
"""Default LLM model name."""
llm_provider: str
"""DB-GPT provider string, e.g. 'proxy/openai'."""
llm_api_base: str
"""Base URL for the LLM API."""
embedding_model: str
"""Default embedding model name."""
embedding_provider: str
"""DB-GPT provider string for embeddings."""
embedding_api_url: str
"""URL for the embedding API endpoint."""
needs_api_key: bool = True
"""Whether this profile requires an API key."""
use_env_interpolation: bool = False
"""When True, model fields use ``${env:VAR:-default}`` syntax in generated TOML.
Set to True only for the *default* profile so that ``default.toml`` mirrors
``configs/dbgpt-proxy-openai.toml`` exactly — allowing runtime override via
environment variables without re-running the setup wizard.
"""
extra_toml_lines: List[str] = field(default_factory=list)
"""Extra TOML lines appended verbatim to the generated config."""
embedding_env_var: Optional[str] = None
"""Environment variable for the embedding API key.
When *None* (the default), the same ``env_var`` used for the LLM key is
reused for embeddings. Set this to a different name when the embedding
endpoint requires a separate API key (e.g. Kimi uses MOONSHOT_API_KEY for
LLM but DASHSCOPE_API_KEY for embeddings via DashScope/Tongyi).
"""
def env_key(self) -> Optional[str]:
"""Return the API key from the environment, or None."""
return os.environ.get(self.env_var)
def embedding_env_key(self) -> Optional[str]:
"""Return the embedding API key from the environment, or None.
Falls back to the primary ``env_var`` when ``embedding_env_var`` is
not set.
"""
var = self.embedding_env_var or self.env_var
return os.environ.get(var) if var else None
# ---------------------------------------------------------------------------
# Supported profiles
# ---------------------------------------------------------------------------
PROFILES: Dict[str, ProfileSpec] = {
"openai": ProfileSpec(
name="openai",
label="OpenAI (OpenAI or OpenAI API proxy)",
env_var="OPENAI_API_KEY",
llm_model="gpt-4o",
llm_provider="proxy/openai",
llm_api_base="https://api.openai.com/v1",
embedding_model="text-embedding-3-small",
embedding_provider="proxy/openai",
embedding_api_url="https://api.openai.com/v1/embeddings",
),
"kimi": ProfileSpec(
name="kimi",
label="Kimi (Moonshot AI / kimi-k2)",
env_var="MOONSHOT_API_KEY",
llm_model="kimi-k2",
llm_provider="proxy/moonshot",
llm_api_base="https://api.moonshot.cn/v1",
embedding_model="text-embedding-v3",
embedding_provider="proxy/tongyi",
embedding_api_url="https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings",
embedding_env_var="DASHSCOPE_API_KEY",
),
"qwen": ProfileSpec(
name="qwen",
label="Qwen (DashScope API)",
env_var="DASHSCOPE_API_KEY",
llm_model="qwen-plus",
llm_provider="proxy/tongyi",
llm_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
embedding_model="text-embedding-v3",
embedding_provider="proxy/tongyi",
embedding_api_url="https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings",
),
"minimax": ProfileSpec(
name="minimax",
label="MiniMax (abab series)",
env_var="MINIMAX_API_KEY",
llm_model="abab6.5s-chat",
llm_provider="proxy/openai",
llm_api_base="https://api.minimax.chat/v1",
embedding_model="embo-01",
embedding_provider="proxy/openai",
embedding_api_url="https://api.minimax.chat/v1/embeddings",
),
"glm": ProfileSpec(
name="glm",
label="z.ai (zhipu.ai API)",
env_var="ZHIPUAI_API_KEY",
llm_model="glm-4-plus",
llm_provider="proxy/zhipu",
llm_api_base="https://open.bigmodel.cn/api/paas/v4",
embedding_model="embedding-3",
embedding_provider="proxy/zhipu",
embedding_api_url="https://open.bigmodel.cn/api/paas/v4/embeddings",
),
"custom": ProfileSpec(
name="custom",
label="Custom Provider (Any OpenAI compatible endpoint)",
env_var="OPENAI_API_KEY",
llm_model="gpt-4o",
llm_provider="proxy/openai",
llm_api_base="https://api.openai.com/v1",
embedding_model="text-embedding-3-small",
embedding_provider="proxy/openai",
embedding_api_url="https://api.openai.com/v1/embeddings",
),
"default": ProfileSpec(
name="default",
label="Skip for now (use OpenAI defaults)",
env_var="OPENAI_API_KEY",
llm_model="gpt-4o",
llm_provider="proxy/openai",
llm_api_base="https://api.openai.com/v1",
embedding_model="text-embedding-3-small",
embedding_provider="proxy/openai",
embedding_api_url="https://api.openai.com/v1/embeddings",
needs_api_key=False,
use_env_interpolation=True,
),
}
# Ordered list for display in the wizard
PROFILE_ORDER: List[str] = [
"openai",
"kimi",
"qwen",
"minimax",
"glm",
"custom",
"default",
]
def get_profile(name: str) -> ProfileSpec:
"""Return a ProfileSpec by name.
Args:
name (str): Profile identifier (case-insensitive).
Returns:
ProfileSpec: The matching profile spec.
Raises:
ValueError: If the profile name is not recognised.
"""
key = name.lower()
if key not in PROFILES:
valid = ", ".join(PROFILE_ORDER)
raise ValueError(f"Unknown profile '{name}'. Valid profiles: {valid}")
return PROFILES[key]
def list_profiles() -> List[ProfileSpec]:
"""Return profiles in canonical display order."""
return [PROFILES[k] for k in PROFILE_ORDER]

View File

@@ -0,0 +1,325 @@
"""First-run setup wizard for DB-GPT CLI.
Provides :func:`run_setup_wizard` (interactive) and
:func:`run_setup_noninteractive` (``--yes`` / CI mode).
Interactive flow::
Welcome to DB-GPT! 🎉
Which LLM provider would you like to use?
● OpenAI OpenAI or OpenAI API proxy
○ Kimi Moonshot AI
○ Qwen DashScope API
○ MiniMax abab series
○ Z.AI zhipu.ai API
○ Custom Any OpenAI compatible endpoint
○ Skip for now Use OpenAI defaults
Enter your OPENAI_API_KEY: ****
✔ Config saved → ~/.dbgpt/configs/openai.toml
For profiles with a separate embedding key (e.g. Kimi uses MOONSHOT_API_KEY
for LLM but DASHSCOPE_API_KEY for embeddings), the wizard will prompt for
both keys.
"""
from __future__ import annotations
import os
from typing import Optional
from dbgpt.cli._config import (
resolve_config_path,
write_profile_config,
)
from dbgpt.cli._profiles import ProfileSpec, get_profile, list_profiles
from dbgpt.util.console.console import CliLogger
_log = CliLogger()
# ---------------------------------------------------------------------------
# Provider display metadata (description only)
# ---------------------------------------------------------------------------
_PROVIDER_META = {
"openai": "OpenAI or OpenAI API proxy",
"kimi": "Moonshot AI",
"qwen": "DashScope API",
"minimax": "MiniMax API",
"glm": "zhipu.ai API",
"custom": "Any OpenAI compatible endpoint",
"default": "Use OpenAI defaults",
}
_DISPLAY_NAMES = {
"openai": "OpenAI",
"kimi": "Kimi",
"qwen": "Qwen",
"minimax": "MiniMax",
"glm": "Z.AI",
"custom": "Custom",
"default": "Skip for now",
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def run_setup_wizard(
pre_selected_profile: Optional[str] = None,
pre_set_key: Optional[str] = None,
) -> str:
"""Run the interactive first-time setup wizard.
Args:
pre_selected_profile (Optional[str]): If supplied (e.g. via
``--profile``), skip the provider selection step.
pre_set_key (Optional[str]): If supplied (e.g. via ``--api-key``),
skip the API-key prompt.
Returns:
str: Absolute path to the written config file.
"""
_print_welcome()
# ── step 1: choose profile ──────────────────────────────────────────────
if pre_selected_profile:
try:
spec = get_profile(pre_selected_profile)
except ValueError as exc:
_log.error(str(exc), exit_code=1)
return ""
else:
spec = _ask_profile()
# ── step 2: API key ─────────────────────────────────────────────────────
api_key: Optional[str] = None
embedding_api_key: Optional[str] = None
if spec.needs_api_key:
api_key = _ask_api_key(spec, pre_set_key)
if spec.embedding_env_var and spec.embedding_env_var != spec.env_var:
embedding_api_key = _ask_embedding_api_key(spec)
# ── step 2.5: api_base (openai + custom ask; others use spec default) ───
api_base: Optional[str] = None
if spec.name in ("openai", "custom"):
api_base = _ask_api_base(spec)
# ── step 3: model names (default profile skips this) ────────────────────
llm_model: Optional[str] = None
embedding_model: Optional[str] = None
if spec.name != "default":
llm_model, embedding_model = _ask_model_names(spec)
# ── step 4: write config ─────────────────────────────────────────────────
config_path = write_profile_config(
spec.name,
api_key=api_key,
activate=True,
llm_model=llm_model,
embedding_model=embedding_model,
api_base=api_base,
embedding_api_key=embedding_api_key,
)
_log.success(f"✔ Config saved → {config_path}")
return str(config_path)
def run_setup_noninteractive(
profile_name: str = "openai",
api_key: Optional[str] = None,
) -> str:
"""Create a config without prompting (``--yes`` / CI mode).
Uses the literal *api_key* if provided; otherwise falls back to the
environment variable defined in the profile spec.
Args:
profile_name (str): Profile to configure.
api_key (Optional[str]): Explicit API key; *None* means use env var.
Returns:
str: Absolute path to the written config file.
"""
spec = get_profile(profile_name)
# If no explicit key, try to read from environment
resolved_key = api_key or (spec.env_key() if spec.needs_api_key else None)
config_path = write_profile_config(spec.name, api_key=resolved_key, activate=True)
_log.success(f"✔ Config saved → {config_path}")
return str(config_path)
def maybe_run_wizard(
profile: Optional[str],
config: Optional[str],
yes: bool,
api_key: Optional[str],
) -> str:
"""Decide whether to run the wizard and return a ready config path.
This is the single call-site used by ``start webserver`` to obtain a
config path, handling all first-run and re-configuration scenarios.
Priority:
1. ``--config`` path supplied → use it directly (skip wizard).
2. Config already exists (via ``--profile`` or active default) → reuse.
3. ``--yes`` → non-interactive setup.
4. Interactive wizard.
Args:
profile (Optional[str]): ``--profile`` CLI flag value.
config (Optional[str]): ``--config`` CLI flag value.
yes (bool): ``--yes`` / ``-y`` flag.
api_key (Optional[str]): ``--api-key`` flag value.
Returns:
str: Absolute path to a usable config file.
"""
# 1. Explicit --config
if config:
return config
# 2. Existing profile config
existing = resolve_config_path(profile=profile, config=None)
if existing:
return existing
# 3. Non-interactive
if yes:
return run_setup_noninteractive(
profile_name=profile or "openai",
api_key=api_key,
)
# 4. Interactive wizard
return run_setup_wizard(
pre_selected_profile=profile,
pre_set_key=api_key,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _print_welcome() -> None:
_log.print("")
_log.print("[bold bright_blue]Welcome to DB-GPT! 🎉[/bold bright_blue]")
_log.print("")
_log.info(
"Let's set up your configuration. This only takes a moment.\n"
"You can re-run [bold]dbgpt setup[/bold] at any time to change settings."
)
_log.print("")
def _build_provider_option(spec: ProfileSpec) -> tuple[str, str]:
"""Return (display_name, description) for a provider."""
display_name = _DISPLAY_NAMES.get(spec.name, spec.name.capitalize())
desc = _PROVIDER_META.get(spec.name, spec.label)
return (display_name, desc)
def _ask_profile() -> ProfileSpec:
"""Prompt the user to choose a provider with an arrow-key selector."""
profiles = list_profiles()
options = [_build_provider_option(spec) for spec in profiles]
idx = _log.select("Which LLM provider would you like to use?", options)
return profiles[idx]
def _ask_api_key(spec: ProfileSpec, pre_set_key: Optional[str]) -> Optional[str]:
"""Ask for an API key, honouring a pre-set value or env var.
Returns *None* for providers that don't need a key (Ollama).
Returns an empty string if the user explicitly skips.
"""
if not spec.needs_api_key:
return None
# If an explicit key was passed in (e.g. via --api-key flag), use it.
if pre_set_key:
_log.success(f"✔ Using supplied API key for {spec.label}")
return pre_set_key
# Check environment
env_val = spec.env_key()
if env_val:
_log.success(f"✔ Found [bold]{spec.env_var}[/bold] in environment — using it.")
return env_val
# Prompt the user
_log.print(
f"\nEnter your [bold]{spec.env_var}[/bold] "
f"(or press Enter to use env-var at runtime):"
)
import getpass
try:
entered = getpass.getpass(prompt=" API key: ").strip()
except (EOFError, KeyboardInterrupt):
_log.warning("\nSkipping API key — you can set it via the environment later.")
return None
if not entered:
_log.warning(
f"No key entered. The config will reference ${spec.env_var} at runtime."
)
return None
return entered
def _ask_model_names(spec: ProfileSpec) -> tuple[str, str]:
llm = _log.ask("LLM model name", default=spec.llm_model)
emb = _log.ask("Embedding model name", default=spec.embedding_model)
return (llm, emb)
def _ask_api_base(spec: ProfileSpec) -> str:
return _log.ask("API base URL", default=spec.llm_api_base)
def _ask_embedding_api_key(spec: ProfileSpec) -> Optional[str]:
emb_env_var = spec.embedding_env_var or spec.env_var
env_val = os.environ.get(emb_env_var) if emb_env_var else None
if env_val:
_log.success(
f"✔ Found [bold]{emb_env_var}[/bold] in environment"
" — using it for embeddings."
)
return env_val
_log.print(
f"\nEnter your [bold]{emb_env_var}[/bold] for embeddings "
f"(or press Enter to use env-var at runtime):"
)
import getpass
try:
entered = getpass.getpass(prompt=" Embedding API key: ").strip()
except (EOFError, KeyboardInterrupt):
_log.warning(
"\nSkipping embedding API key — you can set it via the environment later."
)
return None
if not entered:
_log.warning(
f"No key entered. The config will reference ${emb_env_var} at runtime."
)
return None
return entered

View File

@@ -34,10 +34,17 @@ def add_command_alias(command, name: str, hidden: bool = False, parent_group=Non
parent_group.add_command(new_command, name=name)
@click.group()
def start():
@click.group(invoke_without_command=True)
@click.pass_context
def start(ctx):
"""Start specific server."""
pass
if ctx.invoked_subcommand is None:
# Try web first, then webserver as fallback
cmd = start.commands.get("web") or start.commands.get("webserver")
if cmd:
ctx.invoke(cmd)
else:
click.echo(ctx.get_help())
@click.group()
@@ -93,6 +100,99 @@ def tool():
"""DB-GPT Tools."""
# ---------------------------------------------------------------------------
# dbgpt setup
# ---------------------------------------------------------------------------
@click.command(name="setup")
@click.option(
"-p",
"--profile",
type=str,
required=False,
default=None,
help=(
"Provider profile to configure: openai / kimi / qwen / minimax / "
"deepseek / ollama. If omitted, an interactive menu is shown."
),
)
@click.option(
"-y",
"--yes",
is_flag=True,
default=False,
help="Non-interactive: skip wizard and use defaults / env variables.",
)
@click.option(
"--api-key",
type=str,
required=False,
default=None,
envvar="DBGPT_API_KEY",
help="API key for the chosen provider.",
)
@click.option(
"--show",
is_flag=True,
default=False,
help="Show the current active profile and config path, then exit.",
)
def setup_command(profile: str, yes: bool, api_key: str, show: bool):
"""Configure DB-GPT's LLM provider and write ~/.dbgpt/configs/<profile>.toml.
Run without arguments for an interactive wizard, or use --yes for
non-interactive / CI usage.
\b
Examples:
dbgpt setup # interactive wizard
dbgpt setup --profile openai --yes # use OPENAI_API_KEY from env
dbgpt setup --profile kimi --api-key sk-xxx
dbgpt setup --show # print current config
"""
try:
from dbgpt.cli._config import (
profile_config_path,
read_active_profile,
)
from dbgpt.cli._wizard import run_setup_noninteractive, run_setup_wizard
from dbgpt.util.console.console import CliLogger
cl = CliLogger()
if show:
active = read_active_profile()
if active:
path = profile_config_path(active)
cl.info(f"Active profile : [bold]{active}[/bold]")
cl.info(f"Config file : {path}")
if not path.exists():
cl.warning(" ⚠ Config file does not exist yet. Run `dbgpt setup`.")
else:
cl.warning("No profile configured yet. Run `dbgpt setup`.")
return
if yes:
run_setup_noninteractive(
profile_name=profile or "openai",
api_key=api_key,
)
else:
run_setup_wizard(
pre_selected_profile=profile,
pre_set_key=api_key,
)
except ImportError as e:
logger.warning(f"Setup wizard unavailable: {e}")
raise click.ClickException(str(e))
# ---------------------------------------------------------------------------
# Stop all
# ---------------------------------------------------------------------------
stop_all_func_list = []
@@ -103,6 +203,17 @@ def stop_all():
stop_func()
@click.command(name="none")
def start_none():
"""Start DB-GPT in API-only mode (no web UI). [Planned]"""
click.echo(
"API-only mode (no web UI) is planned for a future release.\n"
"For now, use: dbgpt start web"
)
start.add_command(start_none)
cli.add_command(start)
cli.add_command(stop)
# cli.add_command(install)
@@ -113,6 +224,10 @@ cli.add_command(repo)
cli.add_command(run)
cli.add_command(net)
cli.add_command(tool)
cli.add_command(setup_command)
from dbgpt.cli._profile_cmd import profile as profile_cmd # noqa: E402
cli.add_command(profile_cmd, name="profile")
add_command_alias(stop_all, name="all", parent_group=stop)
try:
@@ -149,6 +264,7 @@ try:
)
add_command_alias(start_webserver, name="webserver", parent_group=start)
add_command_alias(start_webserver, name="web", parent_group=start)
add_command_alias(stop_webserver, name="webserver", parent_group=stop)
# Add migration command
add_command_alias(migration, name="migration", parent_group=db)

View File

@@ -0,0 +1,24 @@
"""Shared fixtures for CLI tests."""
import click.testing
import pytest
@pytest.fixture()
def isolated_dbgpt_home(tmp_path, monkeypatch):
"""Isolate DBGPT_HOME to a temp directory."""
home = tmp_path / "dbgpt"
home.mkdir()
monkeypatch.setenv("DBGPT_HOME", str(home))
import dbgpt.cli._config as _cfg
monkeypatch.setattr(_cfg, "_DBGPT_HOME", home)
monkeypatch.setattr(_cfg, "_CONFIGS_DIR", home / "configs")
monkeypatch.setattr(_cfg, "_ACTIVE_CONFIG", home / "config.toml")
return home
@pytest.fixture()
def cli_runner():
"""Return a Click test runner."""
return click.testing.CliRunner(mix_stderr=False)

View File

@@ -0,0 +1,65 @@
"""Tests for cli_scripts.py — start group alias and bare-start behavior."""
from unittest.mock import patch
from click.testing import CliRunner
from dbgpt.cli.cli_scripts import cli
def test_start_web_alias_help_shows_options():
"""dbgpt start web --help exits 0 and shows --config and --profile."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["start", "web", "--help"])
assert result.exit_code == 0, result.output
assert "--config" in result.output or "--profile" in result.output
def test_start_webserver_still_works():
"""dbgpt start webserver --help exits 0 (regression)."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["start", "webserver", "--help"])
assert result.exit_code == 0, result.output
def test_start_controller_unaffected():
"""dbgpt start controller --help still works after changes."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["start", "controller", "--help"])
assert result.exit_code == 0, result.output
def test_bare_start_invokes_web_or_shows_help():
"""bare dbgpt start (no subcommand) either invokes web or shows help — does NOT crash.""" # noqa: E501
runner = CliRunner(mix_stderr=False)
with (
patch("dbgpt.cli._wizard.maybe_run_wizard", return_value="/fake/config.toml"),
patch("dbgpt_app.dbgpt_server.run_webserver"),
):
result = runner.invoke(cli, ["start"])
# Must not return an error exit code from a crash
assert result.exit_code in (0, 1, 2), (
f"Unexpected exit code: {result.exit_code}\n{result.output}"
)
assert result.exception is None or "No such command" not in str(result.exception)
def test_start_none_exits_zero(cli_runner):
from dbgpt.cli.cli_scripts import cli
result = cli_runner.invoke(cli, ["start", "none"])
assert result.exit_code == 0
def test_start_none_output_mentions_planned(cli_runner):
from dbgpt.cli.cli_scripts import cli
result = cli_runner.invoke(cli, ["start", "none"])
assert "planned" in result.output.lower()
def test_start_none_output_mentions_start_web(cli_runner):
from dbgpt.cli.cli_scripts import cli
result = cli_runner.invoke(cli, ["start", "none"])
assert "dbgpt start web" in result.output

View File

@@ -0,0 +1,381 @@
"""Tests for _config.py — TOML generation, path handling, etc."""
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
def test_escape_api_key_with_double_quote_generates_valid_toml(isolated_dbgpt_home):
"""API key containing double quote should produce valid parseable TOML."""
import tomlkit
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key='sk-abc"def')
data = tomlkit.loads(content) # should NOT raise
llms = data["models"]["llms"]
assert llms[0]["api_key"] == 'sk-abc"def'
def test_escape_api_key_with_backslash_generates_valid_toml(isolated_dbgpt_home):
"""API key containing backslash should produce valid parseable TOML."""
import tomlkit
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="sk-abc\\def")
data = tomlkit.loads(content) # should NOT raise
llms = data["models"]["llms"]
assert llms[0]["api_key"] == "sk-abc\\def"
def test_escape_env_var_placeholder_not_escaped(isolated_dbgpt_home):
"""Env-var placeholder ${env:VAR:-default} should NOT be double-escaped."""
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key=None)
assert "${env:OPENAI_API_KEY:-sk-xxx}" in content
assert "\\\\" not in content # no double backslash introduced
def test_escape_normal_api_key_unchanged(isolated_dbgpt_home):
"""Normal API key (no special chars) should work exactly as before."""
import tomlkit
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="sk-normal-key-123")
data = tomlkit.loads(content)
llms = data["models"]["llms"]
assert llms[0]["api_key"] == "sk-normal-key-123"
class TestDbgptHomeEnvVar:
def test_dbgpt_home_returns_custom_path(self, isolated_dbgpt_home):
from dbgpt.cli._config import dbgpt_home
result = dbgpt_home()
assert result == isolated_dbgpt_home
def test_configs_dir_under_custom_home(self, isolated_dbgpt_home):
from dbgpt.cli._config import configs_dir
result = configs_dir()
assert result == isolated_dbgpt_home / "configs"
def test_profile_config_path_under_custom_home(self, isolated_dbgpt_home):
from dbgpt.cli._config import profile_config_path
result = profile_config_path("openai")
assert result == isolated_dbgpt_home / "configs" / "openai.toml"
def test_active_config_path_under_custom_home(self, isolated_dbgpt_home):
from dbgpt.cli._config import active_config_path
result = active_config_path()
assert result == isolated_dbgpt_home / "config.toml"
def test_default_home_is_dotdbgpt(self, monkeypatch):
monkeypatch.delenv("DBGPT_HOME", raising=False)
from pathlib import Path
import dbgpt.cli._config as _cfg
expected = Path.home() / ".dbgpt"
monkeypatch.setattr(_cfg, "_DBGPT_HOME", expected)
monkeypatch.setattr(_cfg, "_CONFIGS_DIR", expected / "configs")
monkeypatch.setattr(_cfg, "_ACTIVE_CONFIG", expected / "config.toml")
from dbgpt.cli._config import dbgpt_home
assert dbgpt_home() == expected
class TestWorkspacePaths:
def test_render_toml_data_path_contains_workspace(self, isolated_dbgpt_home):
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="test-key")
assert "workspace/pilot/meta_data/dbgpt.db" in content
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="test-key")
assert "workspace/pilot/data" in content
def test_render_toml_uses_custom_home_in_data_path(self, isolated_dbgpt_home):
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="test-key")
# 路径中包含 isolated_dbgpt_home 的字符串
assert str(isolated_dbgpt_home) in content
assert "workspace/pilot/meta_data/dbgpt.db" in content
class TestExtendedSignature:
def test_render_toml_with_llm_model_override_uses_override(
self, isolated_dbgpt_home
):
"""llm_model override replaces spec default in generated TOML."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config(
"openai", api_key="test-key", llm_model="gpt-4-turbo"
)
data = tomlkit.loads(path.read_text())
assert data["models"]["llms"][0]["name"] == "gpt-4-turbo"
def test_render_toml_with_embedding_model_override_uses_override(
self, isolated_dbgpt_home
):
"""embedding_model override replaces spec default in generated TOML."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config(
"openai", api_key="test-key", embedding_model="ada-002"
)
data = tomlkit.loads(path.read_text())
assert data["models"]["embeddings"][0]["name"] == "ada-002"
def test_render_toml_without_overrides_uses_spec_defaults(
self, isolated_dbgpt_home
):
"""No overrides → TOML uses spec defaults (regression guard)."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config("openai", api_key="test-key")
data = tomlkit.loads(path.read_text())
assert data["models"]["llms"][0]["name"] == "gpt-4o"
assert data["models"]["embeddings"][0]["name"] == "text-embedding-3-small"
def test_render_toml_with_api_base_override_uses_override(
self, isolated_dbgpt_home
):
"""api_base override replaces spec default in generated TOML."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config(
"custom", api_key="test-key", api_base="https://my.api/v1"
)
data = tomlkit.loads(path.read_text())
assert data["models"]["llms"][0]["api_base"] == "https://my.api/v1"
def test_render_toml_custom_api_base_derives_embedding_url(
self, isolated_dbgpt_home
):
"""When api_base is overridden, embedding api_url is derived from it."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config(
"custom", api_key="test-key", api_base="https://my-proxy.com/v1"
)
data = tomlkit.loads(path.read_text())
assert (
data["models"]["embeddings"][0]["api_url"]
== "https://my-proxy.com/v1/embeddings"
)
def test_render_toml_custom_api_base_trailing_slash_stripped(
self, isolated_dbgpt_home
):
"""Trailing slash in api_base should not produce double-slash."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config(
"custom", api_key="test-key", api_base="https://my-proxy.com/v1/"
)
data = tomlkit.loads(path.read_text())
assert (
data["models"]["embeddings"][0]["api_url"]
== "https://my-proxy.com/v1/embeddings"
)
def test_render_toml_without_api_base_uses_spec_embedding_url(
self, isolated_dbgpt_home
):
"""Without api_base override, embedding api_url uses spec default."""
import tomlkit
from dbgpt.cli._config import write_profile_config
path = write_profile_config("openai", api_key="test-key")
data = tomlkit.loads(path.read_text())
assert (
data["models"]["embeddings"][0]["api_url"]
== "https://api.openai.com/v1/embeddings"
)
class TestKimiEmbeddingEnvVar:
def test_kimi_embedding_uses_dashscope_env_var(self, isolated_dbgpt_home):
"""Kimi profile should reference DASHSCOPE_API_KEY for embeddings."""
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("kimi")
content = _render_profile_toml(spec, api_key=None)
assert "${env:DASHSCOPE_API_KEY:-sk-xxx}" in content
def test_kimi_llm_uses_moonshot_env_var(self, isolated_dbgpt_home):
"""Kimi profile LLM section should still reference MOONSHOT_API_KEY."""
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("kimi")
content = _render_profile_toml(spec, api_key=None)
assert "${env:MOONSHOT_API_KEY:-sk-xxx}" in content
def test_kimi_literal_embedding_key_overrides_env_var(self, isolated_dbgpt_home):
"""When embedding_api_key is supplied, use it literally."""
import tomlkit
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("kimi")
content = _render_profile_toml(spec, api_key=None, embedding_api_key="ds-key")
data = tomlkit.loads(content)
assert data["models"]["embeddings"][0]["api_key"] == "ds-key"
def test_kimi_embedding_api_url_is_dashscope(self, isolated_dbgpt_home):
"""Kimi embedding api_url should point to DashScope."""
import tomlkit
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("kimi")
content = _render_profile_toml(spec, api_key=None)
data = tomlkit.loads(content)
assert data["models"]["embeddings"][0]["api_url"] == (
"https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
)
def test_openai_same_key_used_for_embeddings(self, isolated_dbgpt_home):
"""OpenAI profile uses the same literal key for both LLM and embeddings."""
import tomlkit
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="sk-test")
data = tomlkit.loads(content)
assert data["models"]["llms"][0]["api_key"] == "sk-test"
assert data["models"]["embeddings"][0]["api_key"] == "sk-test"
class TestDefaultProfileConfig:
"""Tests for config generation with the 'default' (formerly skip) profile."""
def test_default_profile_generates_env_var_placeholder(self, isolated_dbgpt_home):
"""default profile should use env-var placeholder with default."""
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("default")
content = _render_profile_toml(spec, api_key=None)
assert "${env:OPENAI_API_KEY:-sk-xxx}" in content
# Also verify env-var interpolation for model fields
assert "${env:LLM_MODEL_NAME:-gpt-4o}" in content
assert "${env:LLM_MODEL_PROVIDER:-proxy/openai}" in content
assert "${env:OPENAI_API_BASE:-https://api.openai.com/v1}" in content
assert "${env:EMBEDDING_MODEL_NAME:-text-embedding-3-small}" in content
assert "${env:EMBEDDING_MODEL_PROVIDER:-proxy/openai}" in content
assert (
"${env:EMBEDDING_MODEL_API_URL:-https://api.openai.com/v1/embeddings}"
in content
)
def test_default_profile_generates_valid_toml(self, isolated_dbgpt_home):
"""default profile should produce parseable TOML with env-var interpolation."""
import tomlkit
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("default")
content = _render_profile_toml(spec, api_key=None)
data = tomlkit.loads(content)
# Model fields use env-var interpolation syntax as raw strings
assert data["models"]["llms"][0]["name"] == "${env:LLM_MODEL_NAME:-gpt-4o}"
assert (
data["models"]["llms"][0]["provider"]
== "${env:LLM_MODEL_PROVIDER:-proxy/openai}"
)
assert (
data["models"]["embeddings"][0]["name"]
== "${env:EMBEDDING_MODEL_NAME:-text-embedding-3-small}"
)
def test_default_profile_config_file_named_default(self, isolated_dbgpt_home):
"""write_profile_config('default') should create default.toml."""
from dbgpt.cli._config import profile_config_path, write_profile_config
path = write_profile_config("default", api_key=None)
assert path == profile_config_path("default")
assert path.name == "default.toml"
assert path.exists()
def test_default_profile_with_literal_api_key_uses_literal_not_env(
self, isolated_dbgpt_home
):
"""When api_key is provided for default profile, use literal values."""
import tomlkit
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("default")
content = _render_profile_toml(spec, api_key="sk-literal")
data = tomlkit.loads(content)
# With a literal api_key, use_env_interpolation should NOT activate
assert data["models"]["llms"][0]["name"] == "gpt-4o"
assert data["models"]["llms"][0]["provider"] == "proxy/openai"
assert data["models"]["llms"][0]["api_key"] == "sk-literal"
# No env-var syntax for model name/provider
assert "${env:LLM_MODEL_NAME" not in content
def test_openai_profile_no_regression_with_api_key(self, isolated_dbgpt_home):
"""openai profile with literal api_key must still use literal model values."""
import tomlkit
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key="sk-xxx")
data = tomlkit.loads(content)
assert data["models"]["llms"][0]["name"] == "gpt-4o"
assert data["models"]["llms"][0]["provider"] == "proxy/openai"
assert data["models"]["llms"][0]["api_base"] == "https://api.openai.com/v1"
assert data["models"]["llms"][0]["api_key"] == "sk-xxx"
# No env-var interpolation for openai profile
assert "${env:LLM_MODEL_NAME" not in content
def test_openai_profile_no_regression_without_api_key(self, isolated_dbgpt_home):
"""openai profile with api_key=None uses env-var for key, literal for model."""
from dbgpt.cli._config import _render_profile_toml
from dbgpt.cli._profiles import get_profile
spec = get_profile("openai")
content = _render_profile_toml(spec, api_key=None)
# api_key uses env-var syntax with default
assert "${env:OPENAI_API_KEY:-sk-xxx}" in content
# model name/provider/api_base remain literal
assert 'name = "gpt-4o"' in content
assert 'provider = "proxy/openai"' in content
assert 'api_base = "https://api.openai.com/v1"' in content
# No LLM_MODEL_NAME env-var for openai profile
assert "${env:LLM_MODEL_NAME" not in content

View File

@@ -0,0 +1,170 @@
"""Integration tests — full wizard→config→start flow."""
from __future__ import annotations
from unittest.mock import patch
import dbgpt.cli._config as _cfg
from dbgpt.cli._config import (
read_active_profile,
resolve_config_path,
write_active_profile,
write_profile_config,
)
from dbgpt.cli._wizard import run_setup_noninteractive
# All tests use `isolated_dbgpt_home` from conftest.py — never touches real ~/.dbgpt
# ---------------------------------------------------------------------------
# Test 1: First run triggers wizard and creates TOML
# ---------------------------------------------------------------------------
def test_first_run_triggers_wizard_creates_toml(isolated_dbgpt_home):
"""First run: calling run_setup_wizard creates profile TOML + activates it."""
from dbgpt.cli._wizard import run_setup_wizard
# Ensure no config exists before wizard runs
assert not (_cfg._CONFIGS_DIR / "openai.toml").exists()
with (
patch("dbgpt.cli._wizard._ask_profile") as mock_ask_profile,
patch("dbgpt.cli._wizard._ask_api_key", return_value="sk-test"),
patch(
"dbgpt.cli._wizard._ask_model_names",
return_value=("gpt-4o", "text-embedding-3-small"),
),
patch("dbgpt.cli._wizard._print_welcome"),
patch(
"dbgpt.cli._wizard._ask_api_base",
return_value="https://api.openai.com/v1",
),
):
from dbgpt.cli._profiles import get_profile
mock_ask_profile.return_value = get_profile("openai")
run_setup_wizard()
# openai.toml must exist under the isolated configs dir
assert (_cfg._CONFIGS_DIR / "openai.toml").exists()
# Active profile must be set to "openai"
assert read_active_profile() == "openai"
# ---------------------------------------------------------------------------
# Test 2: Profile switch then start resolves correct config
# ---------------------------------------------------------------------------
def test_profile_switch_then_start_resolves_config(isolated_dbgpt_home):
"""Switching active profile makes resolve_config_path return the new profile path.""" # noqa: E501
# Create two TOML files
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "openai.toml").write_text("[models]\n", encoding="utf-8")
(_cfg._CONFIGS_DIR / "qwen.toml").write_text("[models]\n", encoding="utf-8")
# Switch to qwen
write_active_profile("qwen")
# resolve_config_path with no args should pick up qwen
result = resolve_config_path()
assert result is not None
assert "qwen.toml" in result
# ---------------------------------------------------------------------------
# Test 3: start web help shows config/profile options
# ---------------------------------------------------------------------------
def test_start_web_invokes_webserver_with_resolved_config(isolated_dbgpt_home):
"""start web --help exits 0 and shows relevant option text."""
import click.testing
from dbgpt.cli.cli_scripts import cli
runner = click.testing.CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["start", "web", "--help"])
# --help should always succeed (exit 0) and show options
assert result.exit_code == 0
# Should show at least one of the expected flags
help_text = result.output
assert "--config" in help_text or "--profile" in help_text or "--yes" in help_text
# ---------------------------------------------------------------------------
# Test 4: CLI --profile flag overrides active profile
# ---------------------------------------------------------------------------
def test_cli_flag_overrides_active_profile(isolated_dbgpt_home):
"""resolve_config_path(profile=...) returns that profile's path, not the active one.""" # noqa: E501
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "openai.toml").write_text("[models]\n", encoding="utf-8")
(_cfg._CONFIGS_DIR / "qwen.toml").write_text("[models]\n", encoding="utf-8")
# Active profile is openai
write_active_profile("openai")
# But we pass profile="qwen" explicitly
result = resolve_config_path(profile="qwen")
assert result is not None
assert "qwen.toml" in result
assert "openai.toml" not in result
# ---------------------------------------------------------------------------
# Test 5: Explicit --config flag overrides everything
# ---------------------------------------------------------------------------
def test_explicit_config_flag_overrides_all(isolated_dbgpt_home):
"""resolve_config_path(config=...) returns the exact path regardless of active profile.""" # noqa: E501
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "openai.toml").write_text("[models]\n", encoding="utf-8")
write_active_profile("openai")
custom_path = "/custom/path.toml"
result = resolve_config_path(config=custom_path)
assert result == custom_path
# ---------------------------------------------------------------------------
# Test 6: Non-interactive mode creates default profile
# ---------------------------------------------------------------------------
def test_noninteractive_mode_creates_default_profile(isolated_dbgpt_home):
"""run_setup_noninteractive writes openai.toml with the provided API key."""
# Confirm no config exists first
assert not (_cfg._CONFIGS_DIR / "openai.toml").exists()
run_setup_noninteractive(profile_name="openai", api_key="sk-test")
# Profile TOML must be created
toml_path = _cfg._CONFIGS_DIR / "openai.toml"
assert toml_path.exists()
content = toml_path.read_text(encoding="utf-8")
assert "sk-test" in content
# ---------------------------------------------------------------------------
# Test 7: Skip provider creates minimal config
# ---------------------------------------------------------------------------
def test_skip_provider_creates_minimal_config(isolated_dbgpt_home):
"""write_profile_config('default', ...) creates default.toml and activates it."""
# Confirm no config exists
assert not (_cfg._CONFIGS_DIR / "default.toml").exists()
write_profile_config("default", api_key=None, activate=True)
# default.toml must exist
toml_path = _cfg._CONFIGS_DIR / "default.toml"
assert toml_path.exists()
# Active profile must be "default"
assert read_active_profile() == "default"

View File

@@ -0,0 +1,126 @@
"""Tests for dbgpt profile subcommands."""
from click.testing import CliRunner
from dbgpt.cli.cli_scripts import cli
class TestProfileList:
def test_profile_list_with_no_configs_shows_empty_message(
self, isolated_dbgpt_home
):
"""profile list with no configs dir → 'No profiles configured'."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "list"])
assert result.exit_code == 0, result.output
assert (
"no profile" in result.output.lower()
or "no config" in result.output.lower()
)
def test_profile_list_shows_all_profiles(self, isolated_dbgpt_home):
"""profile list with 2 TOML files → both shown."""
# Create fake TOML files
import dbgpt.cli._config as _cfg
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "openai.toml").write_text("[models]\n")
(_cfg._CONFIGS_DIR / "kimi.toml").write_text("[models]\n")
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "list"])
assert result.exit_code == 0, result.output
assert "openai" in result.output
assert "kimi" in result.output
def test_profile_list_marks_active_with_asterisk(self, isolated_dbgpt_home):
"""profile list marks active profile with *."""
import dbgpt.cli._config as _cfg
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "openai.toml").write_text("[models]\n")
(_cfg._CONFIGS_DIR / "kimi.toml").write_text("[models]\n")
_cfg._ACTIVE_CONFIG.write_text('[default]\nprofile = "openai"\n')
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "list"])
assert result.exit_code == 0, result.output
# openai should have * marker
lines = result.output.splitlines()
openai_line = next((line for line in lines if "openai" in line), "")
assert "*" in openai_line, f"Expected * in openai line, got: {openai_line!r}"
class TestProfileShow:
def test_profile_show_prints_toml_content(self, isolated_dbgpt_home):
"""profile show openai → prints TOML file content."""
import dbgpt.cli._config as _cfg
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "openai.toml").write_text("[models]\nllms = []\n")
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "show", "openai"])
assert result.exit_code == 0, result.output
assert "[models]" in result.output
def test_profile_show_nonexistent_exits_nonzero(self, isolated_dbgpt_home):
"""profile show nonexistent → exit_code != 0."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "show", "nonexistent"])
assert result.exit_code != 0
class TestProfileSwitch:
def test_profile_switch_updates_active(self, isolated_dbgpt_home):
"""profile switch kimi → kimi becomes active."""
import dbgpt.cli._config as _cfg
from dbgpt.cli._config import read_active_profile
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
(_cfg._CONFIGS_DIR / "kimi.toml").write_text("[models]\n")
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "switch", "kimi"])
assert result.exit_code == 0, result.output
assert read_active_profile() == "kimi"
def test_profile_switch_nonexistent_exits_nonzero(self, isolated_dbgpt_home):
"""profile switch nonexistent → exit_code != 0, suggests create."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "switch", "nonexistent"])
assert result.exit_code != 0
assert (
"create" in result.output.lower() or "nonexistent" in result.output.lower()
)
class TestProfileDelete:
def test_profile_delete_with_yes_removes_file(self, isolated_dbgpt_home):
"""profile delete openai --yes → openai.toml removed."""
import dbgpt.cli._config as _cfg
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
toml_path = _cfg._CONFIGS_DIR / "openai.toml"
toml_path.write_text("[models]\n")
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "delete", "openai", "--yes"])
assert result.exit_code == 0, result.output
assert not toml_path.exists()
def test_profile_delete_active_clears_active_pointer(self, isolated_dbgpt_home):
"""profile delete active profile → clears active pointer."""
import dbgpt.cli._config as _cfg
from dbgpt.cli._config import read_active_profile
_cfg._CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
toml_path = _cfg._CONFIGS_DIR / "openai.toml"
toml_path.write_text("[models]\n")
_cfg._ACTIVE_CONFIG.write_text('[default]\nprofile = "openai"\n')
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "delete", "openai", "--yes"])
assert result.exit_code == 0, result.output
assert not toml_path.exists()
assert read_active_profile() is None
def test_profile_delete_nonexistent_exits_nonzero(self, isolated_dbgpt_home):
"""profile delete nonexistent --yes → exit_code != 0."""
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["profile", "delete", "nonexistent", "--yes"])
assert result.exit_code != 0

View File

@@ -0,0 +1,175 @@
"""Tests for _profiles.py — GLM/Custom/Default profiles and label fixes."""
import pytest
from dbgpt.cli._profiles import ProfileSpec, get_profile, list_profiles
class TestGlmProfile:
"""Tests for the glm profile."""
def test_glm_profile_spec(self):
"""get_profile('glm') should return a ProfileSpec with correct fields."""
p = get_profile("glm")
assert isinstance(p, ProfileSpec)
assert p.name == "glm"
assert p.label == "z.ai (zhipu.ai API)"
assert p.env_var == "ZHIPUAI_API_KEY"
assert p.llm_model == "glm-4-plus"
assert p.llm_provider == "proxy/zhipu"
assert p.llm_api_base == "https://open.bigmodel.cn/api/paas/v4"
assert p.embedding_model == "embedding-3"
assert p.embedding_provider == "proxy/zhipu"
assert p.needs_api_key is True
def test_glm_case_insensitive(self):
"""get_profile('GLM') should work the same as get_profile('glm')."""
p_lower = get_profile("glm")
p_upper = get_profile("GLM")
assert p_lower.name == p_upper.name
assert p_lower.label == p_upper.label
assert p_lower.env_var == p_upper.env_var
class TestCustomProfile:
"""Tests for the custom profile."""
def test_custom_profile_spec(self):
"""get_profile('custom') should return a ProfileSpec with correct fields."""
p = get_profile("custom")
assert isinstance(p, ProfileSpec)
assert p.name == "custom"
assert p.label == "Custom Provider (Any OpenAI compatible endpoint)"
assert p.env_var == "OPENAI_API_KEY"
assert p.llm_model == "gpt-4o"
assert p.llm_provider == "proxy/openai"
assert p.llm_api_base == "https://api.openai.com/v1"
assert p.needs_api_key is True
class TestDefaultProfile:
"""Tests for the default (formerly skip) profile."""
def test_default_profile_spec(self):
"""get_profile('default') returns a ProfileSpec with needs_api_key=False."""
p = get_profile("default")
assert isinstance(p, ProfileSpec)
assert p.name == "default"
assert p.needs_api_key is False
def test_default_profile_has_real_openai_values(self):
"""default profile should have real OpenAI values for config generation."""
p = get_profile("default")
assert p.env_var == "OPENAI_API_KEY"
assert p.llm_model == "gpt-4o"
assert p.llm_provider == "proxy/openai"
assert p.llm_api_base == "https://api.openai.com/v1"
assert p.embedding_model == "text-embedding-3-small"
assert p.embedding_provider == "proxy/openai"
def test_default_profile_label(self):
"""default profile label should mention 'Skip for now'."""
p = get_profile("default")
assert "Skip for now" in p.label
def test_skip_profile_no_longer_exists(self):
"""'skip' is no longer a valid profile name."""
with pytest.raises(ValueError):
get_profile("skip")
class TestProfileOrder:
"""Tests for profile ordering."""
def test_profile_order_ends_with_new_profiles(self):
"""list_profiles() should end with glm, custom, default."""
profiles = list_profiles()
names = [p.name for p in profiles]
assert names[-3:] == ["glm", "custom", "default"]
class TestExistingLabels:
"""Tests for fixed labels on existing profiles."""
def test_openai_label_fixed(self):
"""openai profile label should be updated."""
p = get_profile("openai")
assert p.label == "OpenAI (OpenAI or OpenAI API proxy)"
def test_qwen_label_fixed(self):
"""qwen profile label should be updated."""
p = get_profile("qwen")
assert p.label == "Qwen (DashScope API)"
class TestErrorHandling:
"""Tests for error handling."""
def test_unknown_profile_raises_value_error(self):
"""get_profile with unknown name should raise ValueError."""
with pytest.raises(ValueError):
get_profile("nonexistent")
class TestKimiEmbedding:
"""Tests for Kimi embedding configuration using DashScope."""
def test_kimi_embedding_uses_tongyi_provider(self):
p = get_profile("kimi")
assert p.embedding_provider == "proxy/tongyi"
def test_kimi_embedding_model_is_text_embedding_v3(self):
p = get_profile("kimi")
assert p.embedding_model == "text-embedding-v3"
def test_kimi_embedding_api_url_is_dashscope(self):
p = get_profile("kimi")
assert (
p.embedding_api_url
== "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
)
def test_kimi_has_separate_embedding_env_var(self):
p = get_profile("kimi")
assert p.embedding_env_var == "DASHSCOPE_API_KEY"
assert p.embedding_env_var != p.env_var
def test_kimi_llm_still_uses_moonshot_env_var(self):
p = get_profile("kimi")
assert p.env_var == "MOONSHOT_API_KEY"
class TestMinimaxEmbedding:
"""Tests for MiniMax embedding configuration."""
def test_minimax_embedding_model_is_embo_01(self):
p = get_profile("minimax")
assert p.embedding_model == "embo-01"
def test_minimax_embedding_provider_is_openai(self):
p = get_profile("minimax")
assert p.embedding_provider == "proxy/openai"
def test_minimax_embedding_api_url_is_minimax(self):
p = get_profile("minimax")
assert p.embedding_api_url == "https://api.minimax.chat/v1/embeddings"
def test_minimax_no_separate_embedding_env_var(self):
p = get_profile("minimax")
assert p.embedding_env_var is None
class TestEmbeddingEnvVarDefault:
"""Tests for the embedding_env_var default behaviour."""
def test_openai_has_no_separate_embedding_env_var(self):
p = get_profile("openai")
assert p.embedding_env_var is None
def test_qwen_has_no_separate_embedding_env_var(self):
p = get_profile("qwen")
assert p.embedding_env_var is None
def test_glm_has_no_separate_embedding_env_var(self):
p = get_profile("glm")
assert p.embedding_env_var is None

View File

@@ -0,0 +1,43 @@
"""Smoke tests for CLI test infrastructure — verifies fixture isolation."""
import os
from pathlib import Path
def test_isolated_dbgpt_home_is_not_real_home(isolated_dbgpt_home):
"""Verify that isolated_dbgpt_home does NOT point to the real ~/.dbgpt."""
real_home = Path.home() / ".dbgpt"
assert isolated_dbgpt_home != real_home, (
f"isolated_dbgpt_home should not be the real home: {real_home}"
)
def test_isolated_dbgpt_home_env_var(isolated_dbgpt_home):
"""Verify DBGPT_HOME env var is set to the isolated path."""
env_home = os.environ.get("DBGPT_HOME")
assert env_home is not None, "DBGPT_HOME env var should be set"
assert env_home == str(isolated_dbgpt_home), (
f"DBGPT_HOME ({env_home}) should match isolated_dbgpt_home ({isolated_dbgpt_home})" # noqa: E501
)
def test_isolated_dbgpt_home_patches_config_module(isolated_dbgpt_home):
"""Verify _config module constants are patched to isolated temp path."""
import dbgpt.cli._config as _cfg
assert _cfg._DBGPT_HOME == isolated_dbgpt_home, (
f"_config._DBGPT_HOME ({_cfg._DBGPT_HOME}) should equal isolated home"
)
assert _cfg._CONFIGS_DIR == isolated_dbgpt_home / "configs", (
"_config._CONFIGS_DIR should be under isolated home"
)
assert _cfg._ACTIVE_CONFIG == isolated_dbgpt_home / "config.toml", (
"_config._ACTIVE_CONFIG should be under isolated home"
)
def test_cli_runner_returns_click_runner(cli_runner):
"""Verify cli_runner fixture returns a CliRunner instance."""
import click.testing
assert isinstance(cli_runner, click.testing.CliRunner)

View File

@@ -0,0 +1,251 @@
"""Tests for _wizard.py — model config step and skip/default flow."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from dbgpt.cli._profiles import get_profile
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_log_ask(*return_values):
"""Return a mock _log whose .ask() yields values in sequence."""
mock_log = MagicMock()
mock_log.ask.side_effect = list(return_values)
return mock_log
# ---------------------------------------------------------------------------
# _ask_model_names
# ---------------------------------------------------------------------------
def test_ask_model_names_returns_defaults_on_enter():
"""User presses Enter → default values from spec returned."""
from dbgpt.cli._wizard import _ask_model_names
spec = get_profile("openai")
mock_log = _make_log_ask("gpt-4o", "text-embedding-3-small")
with patch("dbgpt.cli._wizard._log", mock_log):
llm, emb = _ask_model_names(spec)
assert llm == "gpt-4o"
assert emb == "text-embedding-3-small"
mock_log.ask.assert_any_call("LLM model name", default="gpt-4o")
mock_log.ask.assert_any_call(
"Embedding model name", default="text-embedding-3-small"
)
def test_ask_model_names_returns_custom_values():
"""User types custom model names → returned as-is."""
from dbgpt.cli._wizard import _ask_model_names
spec = get_profile("openai")
mock_log = _make_log_ask("my-custom-llm", "my-custom-emb")
with patch("dbgpt.cli._wizard._log", mock_log):
llm, emb = _ask_model_names(spec)
assert llm == "my-custom-llm"
assert emb == "my-custom-emb"
# ---------------------------------------------------------------------------
# run_setup_wizard — model names integration
# ---------------------------------------------------------------------------
def test_run_setup_wizard_calls_ask_model_names(isolated_dbgpt_home):
"""run_setup_wizard passes llm_model/embedding_model to write_profile_config."""
from dbgpt.cli._wizard import run_setup_wizard
with (
patch("dbgpt.cli._wizard._ask_profile") as mock_ask_profile,
patch("dbgpt.cli._wizard._ask_api_key", return_value="sk-test"),
patch(
"dbgpt.cli._wizard._ask_api_base", return_value="https://api.openai.com/v1"
),
patch(
"dbgpt.cli._wizard._ask_model_names",
return_value=("gpt-4o", "text-embedding-3-small"),
) as mock_model,
patch(
"dbgpt.cli._wizard.write_profile_config",
return_value=isolated_dbgpt_home / "configs" / "openai.toml",
) as mock_write,
patch("dbgpt.cli._wizard._print_welcome"),
):
spec = get_profile("openai")
mock_ask_profile.return_value = spec
run_setup_wizard()
mock_model.assert_called_once_with(spec)
mock_write.assert_called_once_with(
"openai",
api_key="sk-test",
activate=True,
llm_model="gpt-4o",
embedding_model="text-embedding-3-small",
api_base="https://api.openai.com/v1",
embedding_api_key=None,
)
# ---------------------------------------------------------------------------
# Default (formerly skip) flow
# ---------------------------------------------------------------------------
def test_default_profile_bypasses_all_prompts(isolated_dbgpt_home):
"""default spec → _ask_api_key and _ask_model_names never called."""
from dbgpt.cli._wizard import run_setup_wizard
with (
patch("dbgpt.cli._wizard._ask_profile") as mock_ask_profile,
patch("dbgpt.cli._wizard._ask_api_key") as mock_api_key,
patch("dbgpt.cli._wizard._ask_model_names") as mock_model,
patch(
"dbgpt.cli._wizard.write_profile_config",
return_value=isolated_dbgpt_home / "configs" / "default.toml",
),
patch("dbgpt.cli._wizard._print_welcome"),
):
mock_ask_profile.return_value = get_profile("default")
run_setup_wizard()
mock_api_key.assert_not_called()
mock_model.assert_not_called()
# ---------------------------------------------------------------------------
# run_setup_noninteractive — unchanged
# ---------------------------------------------------------------------------
def test_run_setup_noninteractive_unchanged(isolated_dbgpt_home):
"""run_setup_noninteractive does NOT call _ask_model_names."""
from dbgpt.cli._wizard import run_setup_noninteractive
with (
patch("dbgpt.cli._wizard._ask_model_names") as mock_model,
patch(
"dbgpt.cli._wizard.write_profile_config",
return_value=isolated_dbgpt_home / "configs" / "openai.toml",
),
):
run_setup_noninteractive("openai", "sk-xxx")
mock_model.assert_not_called()
# ---------------------------------------------------------------------------
# Custom profile — api_base
# ---------------------------------------------------------------------------
def test_custom_profile_asks_api_base(isolated_dbgpt_home):
"""custom spec → _ask_api_base is called and result passed to write_profile_config.""" # noqa: E501
from dbgpt.cli._wizard import run_setup_wizard
with (
patch("dbgpt.cli._wizard._ask_profile") as mock_ask_profile,
patch("dbgpt.cli._wizard._ask_api_key", return_value="sk-custom"),
patch(
"dbgpt.cli._wizard._ask_api_base", return_value="https://my.proxy.com/v1"
) as mock_api_base,
patch(
"dbgpt.cli._wizard._ask_model_names",
return_value=("gpt-4o", "text-embedding-3-small"),
),
patch(
"dbgpt.cli._wizard.write_profile_config",
return_value=isolated_dbgpt_home / "configs" / "custom.toml",
) as mock_write,
patch("dbgpt.cli._wizard._print_welcome"),
):
spec = get_profile("custom")
mock_ask_profile.return_value = spec
run_setup_wizard()
mock_api_base.assert_called_once_with(spec)
mock_write.assert_called_once_with(
"custom",
api_key="sk-custom",
activate=True,
llm_model="gpt-4o",
embedding_model="text-embedding-3-small",
api_base="https://my.proxy.com/v1",
embedding_api_key=None,
)
def test_openai_profile_asks_api_base(isolated_dbgpt_home):
"""openai spec → _ask_api_base IS called (users often use proxies)."""
from dbgpt.cli._wizard import run_setup_wizard
with (
patch("dbgpt.cli._wizard._ask_profile") as mock_ask_profile,
patch("dbgpt.cli._wizard._ask_api_key", return_value="sk-openai"),
patch(
"dbgpt.cli._wizard._ask_api_base",
return_value="https://api.openai.com/v1",
) as mock_api_base,
patch(
"dbgpt.cli._wizard._ask_model_names",
return_value=("gpt-4o", "text-embedding-3-small"),
),
patch(
"dbgpt.cli._wizard.write_profile_config",
return_value=isolated_dbgpt_home / "configs" / "openai.toml",
) as mock_write,
patch("dbgpt.cli._wizard._print_welcome"),
):
spec = get_profile("openai")
mock_ask_profile.return_value = spec
run_setup_wizard()
mock_api_base.assert_called_once_with(spec)
mock_write.assert_called_once_with(
"openai",
api_key="sk-openai",
activate=True,
llm_model="gpt-4o",
embedding_model="text-embedding-3-small",
api_base="https://api.openai.com/v1",
embedding_api_key=None,
)
def test_kimi_profile_does_not_ask_api_base(isolated_dbgpt_home):
"""kimi spec → _ask_api_base is NOT called."""
from dbgpt.cli._wizard import run_setup_wizard
with (
patch("dbgpt.cli._wizard._ask_profile") as mock_ask_profile,
patch("dbgpt.cli._wizard._ask_api_key", return_value="sk-test"),
patch("dbgpt.cli._wizard._ask_api_base") as mock_api_base,
patch(
"dbgpt.cli._wizard._ask_model_names",
return_value=("kimi-k2", "text-embedding-v3"),
),
patch("dbgpt.cli._wizard._ask_embedding_api_key", return_value="ds-key"),
patch(
"dbgpt.cli._wizard.write_profile_config",
return_value=isolated_dbgpt_home / "configs" / "kimi.toml",
),
patch("dbgpt.cli._wizard._print_welcome"),
):
mock_ask_profile.return_value = get_profile("kimi")
run_setup_wizard()
mock_api_base.assert_not_called()

View File

@@ -5,13 +5,34 @@ import os
from functools import cache
from typing import Optional
ROOT_PATH = os.path.dirname(
os.path.dirname(
def _detect_root_path() -> str:
"""Detect the root path of the DB-GPT installation.
Determines whether running from a source checkout or a pip install,
and returns the appropriate root path.
Returns:
str: The repo root directory for source installs, or
``DBGPT_HOME/workspace`` (defaulting to ``~/.dbgpt/workspace``)
for pip installs.
"""
candidate = os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
)
)
)
)
if os.path.isfile(os.path.join(candidate, "pyproject.toml")):
return candidate
home = os.environ.get("DBGPT_HOME", os.path.expanduser("~/.dbgpt"))
return os.path.join(home, "workspace")
ROOT_PATH = _detect_root_path()
MODEL_PATH = os.path.join(ROOT_PATH, "models")
PILOT_PATH = os.path.join(ROOT_PATH, "pilot")
LOGDIR = os.getenv("DBGPT_LOG_DIR", os.path.join(ROOT_PATH, "logs"))
@@ -354,3 +375,29 @@ KNOWLEDGE_CACHE_ROOT_PATH = os.path.join(
KNOWLEDGE_UPLOAD_ROOT_PATH, "_knowledge_cache_"
)
BENCHMARK_DATA_ROOT_PATH = os.path.join(PILOT_PATH, "benchmark_meta_data")
def _detect_skills_dir() -> str:
"""Detect the skills directory path.
Priority:
1. ``DBGPT_SKILLS_DIR`` environment variable (highest)
2. ``{ROOT_PATH}/skills`` if it exists (source checkout)
3. ``~/.dbgpt/skills`` (pip install mode)
Returns:
str: Absolute path to the skills directory.
"""
env_dir = os.environ.get("DBGPT_SKILLS_DIR")
if env_dir:
return env_dir
source_dir = os.path.join(ROOT_PATH, "skills")
if os.path.isdir(source_dir):
return source_dir
home = os.environ.get("DBGPT_HOME", os.path.expanduser("~/.dbgpt"))
return os.path.join(home, "skills")
SKILLS_DIR = _detect_skills_dir()

View File

@@ -0,0 +1,134 @@
"""Tests for _detect_root_path() in model_config.py."""
import os
# ---------------------------------------------------------------------------
# Helper: call _detect_root_path() with a controlled filesystem view
# ---------------------------------------------------------------------------
def _call_detect(monkeypatch, candidate: str, dbgpt_home: str | None = None) -> str:
"""Invoke _detect_root_path() with the given candidate and env.
Args:
monkeypatch: pytest monkeypatch fixture for isolation.
candidate: The fake "6-level dirname" that the function would compute
from ``__file__``.
dbgpt_home: If not None, set DBGPT_HOME env var to this value.
If None, remove DBGPT_HOME from the environment.
Returns:
str: The return value of ``_detect_root_path()``.
"""
import dbgpt.configs.model_config as _mc
# Patch os.path.abspath so that the dirname chain resolves to `candidate`
# The function calls os.path.dirname 6 times on os.path.abspath(__file__).
# We make abspath return a path deep enough that 6 dirname calls yield
# exactly `candidate`.
fake_deep_path = os.path.join(candidate, "a", "b", "c", "d", "e", "model_config.py")
monkeypatch.setattr(
_mc.os.path,
"abspath",
lambda _path: fake_deep_path,
)
if dbgpt_home is None:
monkeypatch.delenv("DBGPT_HOME", raising=False)
else:
monkeypatch.setenv("DBGPT_HOME", dbgpt_home)
return _mc._detect_root_path()
# ---------------------------------------------------------------------------
# Test 1: source install — pyproject.toml found at candidate
# ---------------------------------------------------------------------------
def test_source_install_returns_candidate(tmp_path, monkeypatch):
"""Source install: pyproject.toml at candidate → ROOT_PATH == candidate.
Creates a fake repo root (tmp_path) with a pyproject.toml sentinel and
verifies that _detect_root_path() returns that directory.
"""
candidate = str(tmp_path)
(tmp_path / "pyproject.toml").write_text("[tool]\n")
result = _call_detect(monkeypatch, candidate)
assert result == candidate
# ---------------------------------------------------------------------------
# Test 2: pip install default — no pyproject.toml, no DBGPT_HOME
# ---------------------------------------------------------------------------
def test_pip_install_default_returns_dot_dbgpt_workspace(tmp_path, monkeypatch):
"""Pip install (default): no pyproject.toml, no DBGPT_HOME → ~/.dbgpt/workspace.
Ensures the fallback path is ``~/.dbgpt/workspace`` when the candidate
directory does not contain a pyproject.toml and DBGPT_HOME is not set.
"""
# candidate has no pyproject.toml
candidate = str(tmp_path)
result = _call_detect(monkeypatch, candidate, dbgpt_home=None)
expected = os.path.join(os.path.expanduser("~/.dbgpt"), "workspace")
assert result == expected
# ---------------------------------------------------------------------------
# Test 3: pip install with custom DBGPT_HOME
# ---------------------------------------------------------------------------
def test_pip_install_custom_dbgpt_home_returns_workspace_under_home(
tmp_path, monkeypatch
):
"""Pip install + DBGPT_HOME: no pyproject.toml + DBGPT_HOME → DBGPT_HOME/workspace.
Verifies that when DBGPT_HOME is set to a custom path and pyproject.toml
does not exist at the candidate, the result is ``DBGPT_HOME/workspace``.
"""
candidate = str(tmp_path / "candidate")
os.makedirs(candidate, exist_ok=True)
# no pyproject.toml in candidate
custom_home = str(tmp_path / "custom_home")
result = _call_detect(monkeypatch, candidate, dbgpt_home=custom_home)
assert result == os.path.join(custom_home, "workspace")
# ---------------------------------------------------------------------------
# Test 4: derived constants follow ROOT_PATH
# ---------------------------------------------------------------------------
def test_derived_constants_follow_root_path(tmp_path, monkeypatch):
"""Derived constants PILOT_PATH and STATIC_MESSAGE_IMG_PATH follow ROOT_PATH.
After _detect_root_path() would return a given path, PILOT_PATH and
STATIC_MESSAGE_IMG_PATH should be derived consistently from it.
Validates the relationship defined in model_config.py:
PILOT_PATH = ROOT_PATH + "/pilot"
STATIC_MESSAGE_IMG_PATH = PILOT_PATH + "/message/img"
"""
candidate = str(tmp_path)
(tmp_path / "pyproject.toml").write_text("[tool]\n")
root = _call_detect(monkeypatch, candidate)
expected_pilot = os.path.join(root, "pilot")
expected_img = os.path.join(expected_pilot, "message/img")
# Verify the relationships (not the module-level cached constants, which
# were evaluated at import time with the real filesystem).
assert os.path.join(root, "pilot") == expected_pilot
assert os.path.join(expected_pilot, "message/img") == expected_img

View File

@@ -3,8 +3,6 @@
import os
from unittest.mock import patch
import pytest
from dbgpt.model.proxy.llms.minimax import (
_DEFAULT_MODEL,
MiniMaxDeployModelParameters,
@@ -38,28 +36,24 @@ class TestMiniMaxModelList:
def test_model_list_contains_m27(self):
# Import triggers registration
from dbgpt.model.adapter.base import get_model_adapter
from dbgpt.model.proxy.llms.minimax import MiniMaxLLMClient # noqa: F811
adapter = get_model_adapter("proxy/minimax", "MiniMax-M2.7")
assert adapter is not None
def test_model_list_contains_m27_highspeed(self):
from dbgpt.model.adapter.base import get_model_adapter
from dbgpt.model.proxy.llms.minimax import MiniMaxLLMClient # noqa: F811
adapter = get_model_adapter("proxy/minimax", "MiniMax-M2.7-highspeed")
assert adapter is not None
def test_model_list_contains_m25(self):
from dbgpt.model.adapter.base import get_model_adapter
from dbgpt.model.proxy.llms.minimax import MiniMaxLLMClient # noqa: F811
adapter = get_model_adapter("proxy/minimax", "MiniMax-M2.5")
assert adapter is not None
def test_model_list_contains_m25_highspeed(self):
from dbgpt.model.adapter.base import get_model_adapter
from dbgpt.model.proxy.llms.minimax import MiniMaxLLMClient # noqa: F811
adapter = get_model_adapter("proxy/minimax", "MiniMax-M2.5-highspeed")
assert adapter is not None

View File

@@ -35,14 +35,14 @@ def create_alembic_config(
alembic_ini_path = alembic_ini_path or os.path.join(
alembic_root_path, "alembic.ini"
)
alembic_cfg = AlembicConfig(alembic_ini_path)
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
script_location = script_location or os.path.join(alembic_root_path, "alembic")
versions_dir = os.path.join(script_location, "versions")
os.makedirs(script_location, exist_ok=True)
os.makedirs(versions_dir, exist_ok=True)
alembic_cfg = AlembicConfig(alembic_ini_path)
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
alembic_cfg.set_main_option("script_location", script_location)
alembic_cfg.attributes["target_metadata"] = base.metadata

View File

@@ -3,7 +3,7 @@
import dataclasses
import sys
from functools import lru_cache
from typing import Any
from typing import Any, Callable, List, Optional, Tuple
from rich.console import Console
from rich.markdown import Markdown
@@ -40,6 +40,66 @@ def get_console(output: Output | None = None) -> Console:
)
# ---------------------------------------------------------------------------
# Terminal raw-mode helpers (stdlib only)
# ---------------------------------------------------------------------------
def _supports_raw_mode() -> bool:
"""Return True if stdin supports raw mode (TTY and termios available)."""
if not sys.stdin.isatty():
return False
try:
import termios # noqa: F401
import tty # noqa: F401
return True
except ImportError:
return False
def _read_key() -> str:
"""Read a single keypress from stdin in raw mode.
Returns:
str: One of ``'up'``, ``'down'``, ``'enter'``, or the raw character.
Raises:
KeyboardInterrupt: On Ctrl-C.
"""
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
ch2 = sys.stdin.read(1)
ch3 = sys.stdin.read(1)
if ch2 == "[":
if ch3 == "A":
return "up"
if ch3 == "B":
return "down"
return ch
if ch in ("\r", "\n"):
return "enter"
if ch == "\x03":
raise KeyboardInterrupt
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _move_up(console: Console, lines: int) -> None:
"""Move terminal cursor up *lines* rows and clear those lines."""
# ANSI: move up N lines then erase to end of screen
console.file.write(f"\x1b[{lines}A\x1b[0J")
console.file.flush()
class CliLogger:
def __init__(self, output: Output | None = None):
self.console = get_console(output)
@@ -70,3 +130,99 @@ class CliLogger:
def ask(self, msg: str, **kwargs):
return Prompt.ask(msg, **kwargs)
def select(
self,
prompt: str,
options: List[Tuple[str, str]],
_read_key_fn: Optional[Callable[[], str]] = None,
) -> int:
"""Display an interactive arrow-key selector and return the chosen index.
Args:
prompt (str): Header text displayed above the option list.
options (List[Tuple[str, str]]): Each tuple is (name, description).
``name`` is the display name rendered in bold.
``description`` is dim helper text shown after the name.
_read_key_fn (Optional[Callable[[], str]]): Override the key-reading
function (used for testing). Should return one of: ``'up'``,
``'down'``, ``'enter'``, or a single character string.
Returns:
int: Zero-based index of the selected option.
"""
read_key = _read_key_fn or _read_key
# If raw-mode isn't available, fall back to numbered input.
if not _supports_raw_mode():
return self._select_fallback(prompt, options)
current = 0
n = len(options)
self.console.print(f"\n {prompt}\n")
def _render(idx: int, move_up_lines: int = 0) -> None:
buf = ""
if move_up_lines > 0:
buf += f"\x1b[{move_up_lines}A\x1b[0J"
for i, (name, desc) in enumerate(options):
if i == idx:
marker = "\x1b[1;96m●\x1b[0m"
else:
marker = "\x1b[2m○\x1b[0m"
bold_name = f"\x1b[1m{name}\x1b[0m"
dim_desc = f"\x1b[2m{desc}\x1b[0m"
buf += f" {marker} {bold_name:<20}{dim_desc}\n"
self.console.file.write(buf)
self.console.file.flush()
_render(current)
while True:
try:
key = read_key()
except KeyboardInterrupt:
raise
if key == "up":
current = (current - 1) % n
_render(current, move_up_lines=n)
elif key == "down":
current = (current + 1) % n
_render(current, move_up_lines=n)
elif key == "enter":
_render(current, move_up_lines=n)
self.console.print("")
return current
elif key.isdigit():
num = int(key)
if 1 <= num <= n:
current = num - 1
_render(current, move_up_lines=n)
self.console.print("")
return current
def _select_fallback(
self,
prompt: str,
options: List[Tuple[str, str]],
) -> int:
"""Numbered fallback for non-TTY environments."""
self.console.print(f"\n {prompt}\n")
for i, (name, desc) in enumerate(options, start=1):
self.console.print(
f" [[bold]{i}[/bold]] [bold]{name}[/bold] [dim]{desc}[/dim]"
)
self.console.print("")
while True:
raw = Prompt.ask("Enter a number", default="1")
try:
choice = int(raw)
if 1 <= choice <= len(options):
return choice - 1
except (ValueError, TypeError):
pass
self.console.print(
f"[warning]Please enter a number between 1 and {len(options)}.[/]"
)

View File

@@ -0,0 +1,30 @@
"""Tests for _db_migration_utils create_alembic_config behaviour."""
from unittest.mock import MagicMock, patch
def test_create_alembic_config_creates_versions_dir(tmp_path):
"""create_alembic_config must create script_location and versions/ directories."""
from dbgpt.util._db_migration_utils import create_alembic_config
mock_engine = MagicMock()
mock_engine.url = "sqlite:///test.db"
mock_base = MagicMock()
mock_base.metadata = MagicMock()
mock_session = MagicMock()
alembic_root = str(tmp_path)
with patch("dbgpt.util._db_migration_utils.AlembicConfig") as mock_alembic_cfg_cls:
mock_cfg = MagicMock()
mock_alembic_cfg_cls.return_value = mock_cfg
result = create_alembic_config(
alembic_root, mock_engine, mock_base, mock_session
)
alembic_dir = tmp_path / "alembic"
versions_dir = alembic_dir / "versions"
assert alembic_dir.exists(), "script_location directory must be created"
assert versions_dir.exists(), "versions directory must be created"
assert result is mock_cfg

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-ext"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "Add your description here"
authors = [
{ name = "csunny", email = "cfqcsunny@gmail.com" }

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-sandbox"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "A secure sandbox execution environment for DB-GPT Agent"
authors = [
{ name = "csunny", email = "cfqcsunny@gmail.com" }

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-serve"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = "Add your description here"
authors = [
{ name = "csunny", email = "cfqcsunny@gmail.com" }

View File

@@ -1 +1 @@
version = "0.8.0rc1"
version = "0.8.0rc6"

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project]
name = "dbgpt-mono"
version = "0.8.0rc1"
version = "0.8.0rc6"
description = """DB-GPT is an experimental open-source project that uses localized GPT \
large models to interact with your data and environment. With this solution, you can be\
assured that there is no risk of data leakage, and your data is 100% private and secure.\