Compare commits

...

34 Commits

Author SHA1 Message Date
f58d80ac1b update registry 2026-03-04 17:48:36 +08:00
9e3fecd4bd add notes 2026-03-04 17:44:11 +08:00
9b128ae41b unified constants 2026-03-04 17:27:26 +08:00
61931cad58 more tests 2026-03-04 16:05:24 +08:00
b070d08130 correct files 2026-03-04 16:05:13 +08:00
fe87bb55a1 sql update 2026-03-04 16:04:58 +08:00
d455933e9c fix test 2026-03-04 16:04:50 +08:00
f6547ca6fe update front_end 2026-03-04 16:04:42 +08:00
9c0744e1bc update front_apis 2026-03-04 16:04:31 +08:00
2f40f1c526 record pipeline id in conv_store 2026-03-04 15:37:30 +08:00
cf1cae51f7 add pipeline id to sql conv store 2026-03-04 15:36:08 +08:00
91685d5bf7 kimi magic 2026-03-04 15:21:07 +08:00
6ed33f3185 update tests 2026-03-04 15:20:52 +08:00
9284e8f928 format spaces in build_server_utils.py 2026-03-04 15:20:32 +08:00
bb6d98c9f4 add pipeline id to conv_store 2026-03-04 14:42:14 +08:00
9d1eeaeec5 pass in pipeline_id 2026-03-04 14:41:58 +08:00
6c26f92ff1 add notes 2026-03-04 14:30:38 +08:00
e3703d962d frontend 2026-03-04 14:19:54 +08:00
9470b7e342 update front_api to use new sever_dashcope schema 2026-03-04 14:19:14 +08:00
94b24682e2 update ui 2026-03-04 14:18:24 +08:00
2ce0075dcd update packages info 2026-03-04 14:17:59 +08:00
431aea410b more robust build_server_utils 2026-03-04 14:17:35 +08:00
46978895d1 make test_dashscope_client work 2026-03-04 11:26:30 +08:00
501f9954ce load only pipeline_registry.json pipelines only 2026-03-04 11:25:16 +08:00
021b4d6ffb create and save config via tyro 2026-03-04 10:30:38 +08:00
16916e183b bug fixes 2026-03-04 10:30:22 +08:00
6097e9c24a test new route approach of server_dashcope.py 2026-03-04 10:23:00 +08:00
11a7809fc0 update name 2026-03-04 10:20:35 +08:00
e85d0e7771 update server build method 2026-03-04 10:16:47 +08:00
26fcdacbe8 rename build_server to build_server_utils 2026-03-04 10:10:36 +08:00
6b0976f263 update config creation script 2026-03-04 09:57:25 +08:00
5f7cd64c11 update registry 2026-03-04 09:57:07 +08:00
4a713190b7 add pipeline registry config 2026-03-03 19:40:00 +08:00
bda75d2558 change save location 2026-03-03 19:27:45 +08:00
33 changed files with 2237 additions and 945 deletions

View File

@@ -0,0 +1,24 @@
{
"pipelines": {
"xiaozhan": {
"enabled": true,
"config_file": "configs/pipelines/xiaozhan.yaml"
},
"blueberry": {
"enabled": true,
"config_file": "configs/pipelines/blueberry.yaml"
}
},
"api_keys": {
"sk-6c7091e6a95f404efb2ec30e8f51b897626d670375cdf822d78262f24ab12367": {
"example-key-1": {
"default_route_id": "default",
"allowed_route_ids": [
"xiaozhan",
"blueberry"
]
}
}
}
}

View File

@@ -1,11 +1,9 @@
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Any
import commentjson
import os
import os.path as osp
import secrets
import subprocess
import sys
import uuid
import json
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -15,11 +13,17 @@ from pydantic import BaseModel, Field
sys.path.append(osp.dirname(osp.dirname(osp.abspath(__file__))))
from lang_agent.config.db_config_manager import DBConfigManager
from lang_agent.front_api.build_server import GRAPH_BUILD_FNCS
from lang_agent.config.constants import (
_PROJECT_ROOT,
MCP_CONFIG_PATH,
MCP_CONFIG_DEFAULT_CONTENT,
PIPELINE_REGISTRY_PATH,
)
from lang_agent.front_api.build_server_utils import (
GRAPH_BUILD_FNCS,
update_pipeline_registry,
)
_PROJECT_ROOT = osp.dirname(osp.dirname(osp.abspath(__file__)))
_MCP_CONFIG_PATH = osp.join(_PROJECT_ROOT, "configs", "mcp_config.json")
_MCP_CONFIG_DEFAULT_CONTENT = "{\n}\n"
class GraphConfigUpsertRequest(BaseModel):
graph_id: str
@@ -29,6 +33,7 @@ class GraphConfigUpsertRequest(BaseModel):
prompt_dict: Dict[str, str] = Field(default_factory=dict)
api_key: Optional[str] = Field(default=None)
class GraphConfigUpsertResponse(BaseModel):
graph_id: str
pipeline_id: str
@@ -37,6 +42,7 @@ class GraphConfigUpsertResponse(BaseModel):
prompt_keys: List[str]
api_key: str
class GraphConfigReadResponse(BaseModel):
graph_id: Optional[str] = Field(default=None)
pipeline_id: str
@@ -45,6 +51,7 @@ class GraphConfigReadResponse(BaseModel):
prompt_dict: Dict[str, str]
api_key: str = Field(default="")
class GraphConfigListItem(BaseModel):
graph_id: Optional[str] = Field(default=None)
pipeline_id: str
@@ -57,10 +64,12 @@ class GraphConfigListItem(BaseModel):
created_at: Optional[str] = Field(default=None)
updated_at: Optional[str] = Field(default=None)
class GraphConfigListResponse(BaseModel):
items: List[GraphConfigListItem]
count: int
class PipelineCreateRequest(BaseModel):
graph_id: str = Field(
description="Graph key from GRAPH_BUILD_FNCS, e.g. routing or react"
@@ -68,52 +77,77 @@ class PipelineCreateRequest(BaseModel):
pipeline_id: str
prompt_set_id: str
tool_keys: List[str] = Field(default_factory=list)
port: int
api_key: str
entry_point: str = Field(default="fastapi_server/server_dashscope.py")
api_key: Optional[str] = Field(default=None)
llm_name: str = Field(default="qwen-plus")
enabled: bool = Field(default=True)
class PipelineSpec(BaseModel):
pipeline_id: str
graph_id: str
enabled: bool
config_file: str
llm_name: str
overrides: Dict[str, Any] = Field(default_factory=dict)
class PipelineCreateResponse(BaseModel):
run_id: str
pid: int
graph_id: str
pipeline_id: str
prompt_set_id: str
url: str
port: int
auth_type: str
auth_header_name: str
auth_key_once: str
auth_key_masked: str
graph_id: str
config_file: str
llm_name: str
enabled: bool
reload_required: bool
registry_path: str
class PipelineRunInfo(BaseModel):
run_id: str
pid: int
graph_id: str
pipeline_id: str
prompt_set_id: str
url: str
port: int
auth_type: str
auth_header_name: str
auth_key_masked: str
class PipelineListResponse(BaseModel):
items: List[PipelineRunInfo]
items: List[PipelineSpec]
count: int
class PipelineStopResponse(BaseModel):
run_id: str
pipeline_id: str
status: str
enabled: bool
reload_required: bool
class ApiKeyPolicyItem(BaseModel):
api_key: str
default_pipeline_id: Optional[str] = Field(default=None)
allowed_pipeline_ids: List[str] = Field(default_factory=list)
app_id: Optional[str] = Field(default=None)
class ApiKeyPolicyListResponse(BaseModel):
items: List[ApiKeyPolicyItem]
count: int
class ApiKeyPolicyUpsertRequest(BaseModel):
default_pipeline_id: Optional[str] = Field(default=None)
allowed_pipeline_ids: List[str] = Field(default_factory=list)
app_id: Optional[str] = Field(default=None)
class ApiKeyPolicyDeleteResponse(BaseModel):
api_key: str
status: str
reload_required: bool
class McpConfigReadResponse(BaseModel):
path: str
raw_content: str
tool_keys: List[str]
class McpConfigUpdateRequest(BaseModel):
raw_content: str
class McpConfigUpdateResponse(BaseModel):
status: str
path: str
@@ -134,28 +168,6 @@ app.add_middleware(
)
_db = DBConfigManager()
_running_pipelines: Dict[str, Dict[str, object]] = {}
def _generate_auth_key() -> str:
return f"agk_{secrets.token_urlsafe(24)}"
def _mask_auth_key(value: str) -> str:
if not value:
return ""
if len(value) <= 10:
return value
return f"{value[:5]}...{value[-5:]}"
def _prune_stopped_pipelines() -> None:
stale_ids: List[str] = []
for run_id, info in _running_pipelines.items():
proc = info["proc"]
if proc.poll() is not None:
stale_ids.append(run_id)
for run_id in stale_ids:
_running_pipelines.pop(run_id, None)
@app.get("/health")
@@ -175,9 +187,12 @@ async def root():
"/v1/graph-configs/{pipeline_id}/{prompt_set_id} (GET)",
"/v1/graph-configs/{pipeline_id}/{prompt_set_id} (DELETE)",
"/v1/pipelines/graphs (GET)",
"/v1/pipelines (POST)",
"/v1/pipelines (GET)",
"/v1/pipelines/{run_id} (DELETE)",
"/v1/pipelines (POST) - build config + upsert pipeline registry entry",
"/v1/pipelines (GET) - list registry pipeline specs",
"/v1/pipelines/{pipeline_id} (DELETE) - disable pipeline in registry",
"/v1/pipelines/api-keys (GET) - list API key routing policies",
"/v1/pipelines/api-keys/{api_key} (PUT) - upsert API key routing policy",
"/v1/pipelines/api-keys/{api_key} (DELETE) - delete API key routing policy",
"/v1/tool-configs/mcp (GET)",
"/v1/tool-configs/mcp (PUT)",
],
@@ -192,14 +207,88 @@ def _parse_mcp_tool_keys(raw_content: str) -> List[str]:
def _read_mcp_config_raw() -> str:
if not osp.exists(_MCP_CONFIG_PATH):
os.makedirs(osp.dirname(_MCP_CONFIG_PATH), exist_ok=True)
with open(_MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
f.write(_MCP_CONFIG_DEFAULT_CONTENT)
with open(_MCP_CONFIG_PATH, "r", encoding="utf-8") as f:
if not osp.exists(MCP_CONFIG_PATH):
os.makedirs(osp.dirname(MCP_CONFIG_PATH), exist_ok=True)
with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
f.write(MCP_CONFIG_DEFAULT_CONTENT)
with open(MCP_CONFIG_PATH, "r", encoding="utf-8") as f:
return f.read()
def _read_pipeline_registry() -> Dict[str, Any]:
if not osp.exists(PIPELINE_REGISTRY_PATH):
os.makedirs(osp.dirname(PIPELINE_REGISTRY_PATH), exist_ok=True)
with open(PIPELINE_REGISTRY_PATH, "w", encoding="utf-8") as f:
json.dump({"pipelines": {}, "api_keys": {}}, f, indent=2)
with open(PIPELINE_REGISTRY_PATH, "r", encoding="utf-8") as f:
registry = json.load(f)
pipelines = registry.get("pipelines")
if not isinstance(pipelines, dict):
raise ValueError("`pipelines` in pipeline registry must be an object")
api_keys = registry.get("api_keys")
if api_keys is None:
registry["api_keys"] = {}
elif not isinstance(api_keys, dict):
raise ValueError("`api_keys` in pipeline registry must be an object")
return registry
def _write_pipeline_registry(registry: Dict[str, Any]) -> None:
os.makedirs(osp.dirname(PIPELINE_REGISTRY_PATH), exist_ok=True)
with open(PIPELINE_REGISTRY_PATH, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2)
f.write("\n")
def _normalize_pipeline_spec(pipeline_id: str, spec: Dict[str, Any]) -> PipelineSpec:
if not isinstance(spec, dict):
raise ValueError(f"pipeline spec for '{pipeline_id}' must be an object")
overrides = spec.get("overrides", {})
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(f"`overrides` for pipeline '{pipeline_id}' must be an object")
llm_name = str(overrides.get("llm_name") or "unknown")
return PipelineSpec(
pipeline_id=pipeline_id,
graph_id=str(spec.get("graph_id") or pipeline_id),
enabled=bool(spec.get("enabled", True)),
config_file=str(spec.get("config_file") or ""),
llm_name=llm_name,
overrides=overrides,
)
def _normalize_api_key_policy(api_key: str, policy: Dict[str, Any]) -> ApiKeyPolicyItem:
if not isinstance(policy, dict):
raise ValueError(f"api key policy for '{api_key}' must be an object")
allowed = policy.get("allowed_pipeline_ids") or []
if not isinstance(allowed, list):
raise ValueError(
f"`allowed_pipeline_ids` for api key '{api_key}' must be a list"
)
cleaned_allowed = []
seen = set()
for pid in allowed:
pipeline_id = str(pid).strip()
if not pipeline_id or pipeline_id in seen:
continue
seen.add(pipeline_id)
cleaned_allowed.append(pipeline_id)
default_pipeline_id = policy.get("default_pipeline_id")
if default_pipeline_id is not None:
default_pipeline_id = str(default_pipeline_id).strip() or None
app_id = policy.get("app_id")
if app_id is not None:
app_id = str(app_id).strip() or None
return ApiKeyPolicyItem(
api_key=api_key,
default_pipeline_id=default_pipeline_id,
allowed_pipeline_ids=cleaned_allowed,
app_id=app_id,
)
@app.post("/v1/graph-configs", response_model=GraphConfigUpsertResponse)
async def upsert_graph_config(body: GraphConfigUpsertRequest):
try:
@@ -225,8 +314,11 @@ async def upsert_graph_config(body: GraphConfigUpsertRequest):
api_key=(body.api_key or "").strip(),
)
@app.get("/v1/graph-configs", response_model=GraphConfigListResponse)
async def list_graph_configs(pipeline_id: Optional[str] = None, graph_id: Optional[str] = None):
async def list_graph_configs(
pipeline_id: Optional[str] = None, graph_id: Optional[str] = None
):
try:
rows = _db.list_prompt_sets(pipeline_id=pipeline_id, graph_id=graph_id)
except Exception as e:
@@ -235,10 +327,15 @@ async def list_graph_configs(pipeline_id: Optional[str] = None, graph_id: Option
items = [GraphConfigListItem(**row) for row in rows]
return GraphConfigListResponse(items=items, count=len(items))
@app.get("/v1/graph-configs/default/{pipeline_id}", response_model=GraphConfigReadResponse)
@app.get(
"/v1/graph-configs/default/{pipeline_id}", response_model=GraphConfigReadResponse
)
async def get_default_graph_config(pipeline_id: str):
try:
prompt_dict, tool_keys = _db.get_config(pipeline_id=pipeline_id, prompt_set_id=None)
prompt_dict, tool_keys = _db.get_config(
pipeline_id=pipeline_id, prompt_set_id=None
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -267,11 +364,16 @@ async def get_default_graph_config(pipeline_id: str):
api_key=(active.get("api_key") or ""),
)
@app.get("/v1/graphs/{graph_id}/default-config", response_model=GraphConfigReadResponse)
async def get_graph_default_config_by_graph(graph_id: str):
return await get_default_graph_config(pipeline_id=graph_id)
@app.get("/v1/graph-configs/{pipeline_id}/{prompt_set_id}", response_model=GraphConfigReadResponse)
@app.get(
"/v1/graph-configs/{pipeline_id}/{prompt_set_id}",
response_model=GraphConfigReadResponse,
)
async def get_graph_config(pipeline_id: str, prompt_set_id: str):
try:
meta = _db.get_prompt_set(pipeline_id=pipeline_id, prompt_set_id=prompt_set_id)
@@ -321,6 +423,7 @@ async def delete_graph_config(pipeline_id: str, prompt_set_id: str):
async def available_graphs():
return {"available_graphs": sorted(GRAPH_BUILD_FNCS.keys())}
@app.get("/v1/tool-configs/mcp", response_model=McpConfigReadResponse)
async def get_mcp_tool_config():
try:
@@ -331,7 +434,7 @@ async def get_mcp_tool_config():
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return McpConfigReadResponse(
path=_MCP_CONFIG_PATH,
path=MCP_CONFIG_PATH,
raw_content=raw_content,
tool_keys=tool_keys,
)
@@ -341,8 +444,8 @@ async def get_mcp_tool_config():
async def update_mcp_tool_config(body: McpConfigUpdateRequest):
try:
tool_keys = _parse_mcp_tool_keys(body.raw_content)
os.makedirs(osp.dirname(_MCP_CONFIG_PATH), exist_ok=True)
with open(_MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
os.makedirs(osp.dirname(MCP_CONFIG_PATH), exist_ok=True)
with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
# Keep user formatting/comments as entered while ensuring trailing newline.
f.write(body.raw_content.rstrip() + "\n")
except ValueError as e:
@@ -351,28 +454,24 @@ async def update_mcp_tool_config(body: McpConfigUpdateRequest):
raise HTTPException(status_code=500, detail=str(e))
return McpConfigUpdateResponse(
status="updated",
path=_MCP_CONFIG_PATH,
path=MCP_CONFIG_PATH,
tool_keys=tool_keys,
)
@app.get("/v1/pipelines", response_model=PipelineListResponse)
async def list_running_pipelines():
_prune_stopped_pipelines()
items = [
PipelineRunInfo(
run_id=run_id,
pid=info["proc"].pid,
graph_id=info["graph_id"],
pipeline_id=info["pipeline_id"],
prompt_set_id=info["prompt_set_id"],
url=info["url"],
port=info["port"],
auth_type="bearer",
auth_header_name="Authorization",
auth_key_masked=info.get("auth_key_masked", ""),
)
for run_id, info in _running_pipelines.items()
]
try:
registry = _read_pipeline_registry()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
items: List[PipelineSpec] = []
pipelines = registry.get("pipelines", {})
for pipeline_id, spec in sorted(pipelines.items()):
items.append(_normalize_pipeline_spec(pipeline_id, spec))
return PipelineListResponse(items=items, count=len(items))
@@ -385,60 +484,209 @@ async def create_pipeline(body: PipelineCreateRequest):
detail=f"Unknown graph_id '{body.graph_id}'. Valid options: {sorted(GRAPH_BUILD_FNCS.keys())}",
)
auth_key = _generate_auth_key()
auth_key_masked = _mask_auth_key(auth_key)
pipeline_id = body.pipeline_id.strip()
if not pipeline_id:
raise HTTPException(status_code=400, detail="pipeline_id is required")
prompt_set_id = body.prompt_set_id.strip()
if not prompt_set_id:
raise HTTPException(status_code=400, detail="prompt_set_id is required")
resolved_api_key = (body.api_key or "").strip()
if not resolved_api_key:
meta = _db.get_prompt_set(pipeline_id=pipeline_id, prompt_set_id=prompt_set_id)
if meta is None:
raise HTTPException(
status_code=400,
detail=(
f"prompt_set_id '{prompt_set_id}' not found for pipeline '{pipeline_id}', "
"and request api_key is empty"
),
)
resolved_api_key = str(meta.get("api_key") or "").strip()
if not resolved_api_key:
raise HTTPException(
status_code=400,
detail=(
"api_key is required either in request body or in prompt set metadata"
),
)
config_file = f"configs/pipelines/{pipeline_id}.yml"
config_abs_dir = osp.join(_PROJECT_ROOT, "configs", "pipelines")
try:
proc, url = build_fn(
pipeline_id=body.pipeline_id,
prompt_set=body.prompt_set_id,
build_fn(
pipeline_id=pipeline_id,
prompt_set=prompt_set_id,
tool_keys=body.tool_keys,
port=str(body.port),
api_key=body.api_key,
fast_auth_keys=auth_key,
entry_pnt=body.entry_point,
api_key=resolved_api_key,
llm_name=body.llm_name,
pipeline_config_dir=config_abs_dir,
)
update_pipeline_registry(
pipeline_id=pipeline_id,
graph_id=body.graph_id,
config_file=config_file,
llm_name=body.llm_name,
enabled=body.enabled,
registry_f=PIPELINE_REGISTRY_PATH,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start pipeline: {e}")
raise HTTPException(status_code=500, detail=f"Failed to register pipeline: {e}")
run_id = str(uuid.uuid4())
_running_pipelines[run_id] = {
"proc": proc,
"graph_id": body.graph_id,
"pipeline_id": body.pipeline_id,
"prompt_set_id": body.prompt_set_id,
"url": url,
"port": body.port,
"auth_key_masked": auth_key_masked,
}
try:
registry = _read_pipeline_registry()
pipeline_spec = registry.get("pipelines", {}).get(pipeline_id)
if pipeline_spec is None:
raise ValueError(
f"pipeline '{pipeline_id}' missing from registry after update"
)
normalized = _normalize_pipeline_spec(pipeline_id, pipeline_spec)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to read pipeline registry after update: {e}",
)
return PipelineCreateResponse(
run_id=run_id,
pid=proc.pid,
graph_id=body.graph_id,
pipeline_id=body.pipeline_id,
prompt_set_id=body.prompt_set_id,
url=url,
port=body.port,
auth_type="bearer",
auth_header_name="Authorization",
auth_key_once=auth_key,
auth_key_masked=auth_key_masked,
pipeline_id=pipeline_id,
prompt_set_id=prompt_set_id,
graph_id=normalized.graph_id,
config_file=normalized.config_file,
llm_name=normalized.llm_name,
enabled=normalized.enabled,
reload_required=True,
registry_path=PIPELINE_REGISTRY_PATH,
)
@app.delete("/v1/pipelines/{run_id}", response_model=PipelineStopResponse)
async def stop_pipeline(run_id: str):
info = _running_pipelines.pop(run_id, None)
if info is None:
raise HTTPException(status_code=404, detail=f"run_id '{run_id}' not found")
proc = info["proc"]
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
@app.delete("/v1/pipelines/{pipeline_id}", response_model=PipelineStopResponse)
async def stop_pipeline(pipeline_id: str):
try:
registry = _read_pipeline_registry()
pipelines = registry.get("pipelines", {})
spec = pipelines.get(pipeline_id)
if not isinstance(spec, dict):
raise HTTPException(
status_code=404, detail=f"pipeline_id '{pipeline_id}' not found"
)
spec["enabled"] = False
_write_pipeline_registry(registry)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return PipelineStopResponse(run_id=run_id, status="stopped")
return PipelineStopResponse(
pipeline_id=pipeline_id,
status="disabled",
enabled=False,
reload_required=True,
)
@app.get("/v1/pipelines/api-keys", response_model=ApiKeyPolicyListResponse)
async def list_pipeline_api_keys():
try:
registry = _read_pipeline_registry()
api_keys = registry.get("api_keys", {})
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
items: List[ApiKeyPolicyItem] = []
for api_key, policy in sorted(api_keys.items()):
items.append(_normalize_api_key_policy(str(api_key), policy))
return ApiKeyPolicyListResponse(items=items, count=len(items))
@app.put(
"/v1/pipelines/api-keys/{api_key}",
response_model=ApiKeyPolicyItem,
)
async def upsert_pipeline_api_key_policy(api_key: str, body: ApiKeyPolicyUpsertRequest):
normalized_key = api_key.strip()
if not normalized_key:
raise HTTPException(
status_code=400, detail="api_key path parameter is required"
)
try:
registry = _read_pipeline_registry()
pipelines = registry.get("pipelines", {})
if not isinstance(pipelines, dict):
raise ValueError("`pipelines` in pipeline registry must be an object")
known_pipeline_ids = set(pipelines.keys())
allowed = []
seen = set()
for pipeline_id in body.allowed_pipeline_ids:
cleaned = str(pipeline_id).strip()
if not cleaned or cleaned in seen:
continue
if cleaned not in known_pipeline_ids:
raise ValueError(
f"unknown pipeline_id '{cleaned}' in allowed_pipeline_ids"
)
seen.add(cleaned)
allowed.append(cleaned)
default_pipeline_id = body.default_pipeline_id
if default_pipeline_id is not None:
default_pipeline_id = default_pipeline_id.strip() or None
if default_pipeline_id and default_pipeline_id not in known_pipeline_ids:
raise ValueError(f"unknown default_pipeline_id '{default_pipeline_id}'")
app_id = body.app_id.strip() if body.app_id else None
policy: Dict[str, Any] = {}
if default_pipeline_id:
policy["default_pipeline_id"] = default_pipeline_id
if allowed:
policy["allowed_pipeline_ids"] = allowed
if app_id:
policy["app_id"] = app_id
registry.setdefault("api_keys", {})[normalized_key] = policy
_write_pipeline_registry(registry)
return _normalize_api_key_policy(normalized_key, policy)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/pipelines/api-keys/{api_key}",
response_model=ApiKeyPolicyDeleteResponse,
)
async def delete_pipeline_api_key_policy(api_key: str):
normalized_key = api_key.strip()
if not normalized_key:
raise HTTPException(
status_code=400, detail="api_key path parameter is required"
)
try:
registry = _read_pipeline_registry()
api_keys = registry.get("api_keys", {})
if normalized_key not in api_keys:
raise HTTPException(
status_code=404, detail=f"api_key '{normalized_key}' not found"
)
del api_keys[normalized_key]
_write_pipeline_registry(registry)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return ApiKeyPolicyDeleteResponse(
api_key=normalized_key,
status="deleted",
reload_required=True,
)

View File

@@ -4,13 +4,11 @@ from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional, Tuple
from pathlib import Path as FsPath
import os
import os.path as osp
import sys
import time
import json
import copy
import uvicorn
from loguru import logger
import tyro
@@ -18,30 +16,20 @@ import tyro
# Ensure we can import from project root
sys.path.append(osp.dirname(osp.dirname(osp.abspath(__file__))))
from lang_agent.pipeline import Pipeline, PipelineConfig
from lang_agent.config.core_config import load_tyro_conf
from lang_agent.pipeline import PipelineConfig
from lang_agent.components.server_pipeline_manager import ServerPipelineManager
from lang_agent.config.constants import PIPELINE_REGISTRY_PATH, API_KEY_HEADER, VALID_API_KEYS
# Initialize default pipeline once (used when no explicit pipeline id is provided)
# Load base config for route-level overrides (pipelines are lazy-loaded from registry)
pipeline_config = tyro.cli(PipelineConfig)
logger.info(f"starting agent with default pipeline: \n{pipeline_config}")
pipeline: Pipeline = pipeline_config.setup()
# API Key Authentication
API_KEY_HEADER = APIKeyHeader(name="Authorization", auto_error=True)
VALID_API_KEYS = set(filter(None, os.environ.get("FAST_AUTH_KEYS", "").split(",")))
REGISTRY_FILE = os.environ.get(
"FAST_PIPELINE_REGISTRY_FILE",
osp.join(osp.dirname(osp.dirname(osp.abspath(__file__))), "configs", "pipeline_registry.json"),
)
logger.info(f"starting agent with base pipeline config: \n{pipeline_config}")
PIPELINE_MANAGER = ServerPipelineManager(
default_route_id=os.environ.get("FAST_DEFAULT_ROUTE_ID", os.environ.get("FAST_DEFAULT_PIPELINE_ID", "default")),
default_pipeline_id=os.environ.get("FAST_DEFAULT_PIPELINE_ID", "default"),
default_config=pipeline_config,
default_pipeline=pipeline,
)
PIPELINE_MANAGER.load_registry(REGISTRY_FILE)
PIPELINE_MANAGER.load_registry(PIPELINE_REGISTRY_PATH)
async def verify_api_key(api_key: str = Security(API_KEY_HEADER)):
@@ -67,8 +55,10 @@ class DSApplicationCallRequest(BaseModel):
thread_id: Optional[str] = Field(default="3")
app = FastAPI(title="DashScope-Compatible Application API",
description="DashScope Application.call compatible endpoint backed by pipeline.chat")
app = FastAPI(
title="DashScope-Compatible Application API",
description="DashScope Application.call compatible endpoint backed by pipeline.chat",
)
app.add_middleware(
CORSMiddleware,
@@ -79,7 +69,9 @@ app.add_middleware(
)
def sse_chunks_from_stream(chunk_generator, response_id: str, model: str = "qwen-flash"):
def sse_chunks_from_stream(
chunk_generator, response_id: str, model: str = "qwen-flash"
):
"""
Stream chunks from pipeline and format as SSE.
Accumulates text and sends incremental updates.
@@ -120,7 +112,9 @@ def sse_chunks_from_stream(chunk_generator, response_id: str, model: str = "qwen
yield f"data: {json.dumps(final)}\n\n"
async def sse_chunks_from_astream(chunk_generator, response_id: str, model: str = "qwen-flash"):
async def sse_chunks_from_astream(
chunk_generator, response_id: str, model: str = "qwen-flash"
):
"""
Async version: Stream chunks from pipeline and format as SSE.
Accumulates text and sends incremental updates.
@@ -207,22 +201,30 @@ async def _process_dashscope_request(
thread_id = body_input.get("session_id") or req_session_id or "3"
user_msg = _extract_user_message(messages)
route_id = PIPELINE_MANAGER.resolve_route_id(body=body, app_id=req_app_id, api_key=api_key)
selected_pipeline, selected_model = PIPELINE_MANAGER.get_pipeline(route_id)
pipeline_id = PIPELINE_MANAGER.resolve_pipeline_id(
body=body, app_id=req_app_id, api_key=api_key
)
selected_pipeline, selected_model = PIPELINE_MANAGER.get_pipeline(pipeline_id)
# Namespace thread ids to prevent memory collisions across pipelines.
thread_id = f"{route_id}:{thread_id}"
thread_id = f"{pipeline_id}:{thread_id}"
response_id = f"appcmpl-{os.urandom(12).hex()}"
if stream:
chunk_generator = await selected_pipeline.achat(inp=user_msg, as_stream=True, thread_id=thread_id)
chunk_generator = await selected_pipeline.achat(
inp=user_msg, as_stream=True, thread_id=thread_id
)
return StreamingResponse(
sse_chunks_from_astream(chunk_generator, response_id=response_id, model=selected_model),
sse_chunks_from_astream(
chunk_generator, response_id=response_id, model=selected_model
),
media_type="text/event-stream",
)
result_text = await selected_pipeline.achat(inp=user_msg, as_stream=False, thread_id=thread_id)
result_text = await selected_pipeline.achat(
inp=user_msg, as_stream=False, thread_id=thread_id
)
if not isinstance(result_text, str):
result_text = str(result_text)
@@ -237,9 +239,7 @@ async def _process_dashscope_request(
"created": int(time.time()),
"model": selected_model,
},
"route_id": route_id,
# Backward compatibility: keep pipeline_id in response as the route id selector.
"pipeline_id": route_id,
"pipeline_id": pipeline_id,
"is_end": True,
}
return JSONResponse(content=data)
@@ -297,10 +297,13 @@ async def application_completion(
@app.get("/")
async def root():
return {"message": "DashScope Application-compatible API", "endpoints": [
"/v1/apps/{app_id}/sessions/{session_id}/responses",
"/health",
]}
return {
"message": "DashScope Application-compatible API",
"endpoints": [
"/v1/apps/{app_id}/sessions/{session_id}/responses",
"/health",
],
}
@app.get("/health")
@@ -315,5 +318,3 @@ if __name__ == "__main__":
port=pipeline_config.port,
reload=True,
)

View File

@@ -16,15 +16,12 @@ import tyro
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lang_agent.pipeline import Pipeline, PipelineConfig
from lang_agent.config.constants import API_KEY_HEADER, VALID_API_KEYS
# Initialize Pipeline once
pipeline_config = tyro.cli(PipelineConfig)
pipeline: Pipeline = pipeline_config.setup()
# API Key Authentication
API_KEY_HEADER = APIKeyHeader(name="Authorization", auto_error=True)
VALID_API_KEYS = set(filter(None, os.environ.get("FAST_AUTH_KEYS", "").split(",")))
async def verify_api_key(api_key: str = Security(API_KEY_HEADER)):
"""Verify the API key from Authorization header (Bearer token format)."""
@@ -46,12 +43,12 @@ class OpenAIChatCompletionRequest(BaseModel):
temperature: Optional[float] = Field(default=1.0)
max_tokens: Optional[int] = Field(default=None)
# Optional overrides for pipeline behavior
thread_id: Optional[str] = Field(default='3')
thread_id: Optional[str] = Field(default="3")
app = FastAPI(
title="OpenAI-Compatible Chat API",
description="OpenAI Chat Completions API compatible endpoint backed by pipeline.chat"
description="OpenAI Chat Completions API compatible endpoint backed by pipeline.chat",
)
app.add_middleware(
@@ -63,7 +60,9 @@ app.add_middleware(
)
def sse_chunks_from_stream(chunk_generator, response_id: str, model: str, created_time: int):
def sse_chunks_from_stream(
chunk_generator, response_id: str, model: str, created_time: int
):
"""
Stream chunks from pipeline and format as OpenAI SSE.
"""
@@ -75,14 +74,8 @@ def sse_chunks_from_stream(chunk_generator, response_id: str, model: str, create
"created": created_time,
"model": model,
"choices": [
{
"index": 0,
"delta": {
"content": chunk
},
"finish_reason": None
}
]
{"index": 0, "delta": {"content": chunk}, "finish_reason": None}
],
}
yield f"data: {json.dumps(data)}\n\n"
@@ -92,19 +85,15 @@ def sse_chunks_from_stream(chunk_generator, response_id: str, model: str, create
"object": "chat.completion.chunk",
"created": created_time,
"model": model,
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop"
}
]
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(final)}\n\n"
yield "data: [DONE]\n\n"
async def sse_chunks_from_astream(chunk_generator, response_id: str, model: str, created_time: int):
async def sse_chunks_from_astream(
chunk_generator, response_id: str, model: str, created_time: int
):
"""
Async version: Stream chunks from pipeline and format as OpenAI SSE.
"""
@@ -116,14 +105,8 @@ async def sse_chunks_from_astream(chunk_generator, response_id: str, model: str,
"created": created_time,
"model": model,
"choices": [
{
"index": 0,
"delta": {
"content": chunk
},
"finish_reason": None
}
]
{"index": 0, "delta": {"content": chunk}, "finish_reason": None}
],
}
yield f"data: {json.dumps(data)}\n\n"
@@ -133,13 +116,7 @@ async def sse_chunks_from_astream(chunk_generator, response_id: str, model: str,
"object": "chat.completion.chunk",
"created": created_time,
"model": model,
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop"
}
]
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(final)}\n\n"
yield "data: [DONE]\n\n"
@@ -176,14 +153,23 @@ async def chat_completions(request: Request, _: str = Depends(verify_api_key)):
if stream:
# Use async streaming from pipeline
chunk_generator = await pipeline.achat(inp=user_msg, as_stream=True, thread_id=thread_id)
chunk_generator = await pipeline.achat(
inp=user_msg, as_stream=True, thread_id=thread_id
)
return StreamingResponse(
sse_chunks_from_astream(chunk_generator, response_id=response_id, model=model, created_time=created_time),
sse_chunks_from_astream(
chunk_generator,
response_id=response_id,
model=model,
created_time=created_time,
),
media_type="text/event-stream",
)
# Non-streaming: get full result using async
result_text = await pipeline.achat(inp=user_msg, as_stream=False, thread_id=thread_id)
result_text = await pipeline.achat(
inp=user_msg, as_stream=False, thread_id=thread_id
)
if not isinstance(result_text, str):
result_text = str(result_text)
@@ -195,18 +181,11 @@ async def chat_completions(request: Request, _: str = Depends(verify_api_key)):
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": result_text
},
"finish_reason": "stop"
"message": {"role": "assistant", "content": result_text},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
return JSONResponse(content=data)
@@ -221,11 +200,7 @@ async def chat_completions(request: Request, _: str = Depends(verify_api_key)):
async def root():
return {
"message": "OpenAI-compatible Chat API",
"endpoints": [
"/v1/chat/completions",
"/v1/memory (DELETE)",
"/health"
]
"endpoints": ["/v1/chat/completions", "/v1/memory (DELETE)", "/health"],
}

View File

@@ -17,6 +17,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from langgraph.checkpoint.memory import MemorySaver
from lang_agent.pipeline import Pipeline, PipelineConfig
from lang_agent.config.constants import API_KEY_HEADER, VALID_API_KEYS
# Initialize Pipeline once (matches existing server_* pattern)
pipeline_config = tyro.cli(PipelineConfig)
@@ -24,9 +25,6 @@ logger.info(f"starting agent with pipeline: \n{pipeline_config}")
pipeline: Pipeline = pipeline_config.setup()
# API Key Authentication
API_KEY_HEADER = APIKeyHeader(name="Authorization", auto_error=False)
VALID_API_KEYS = set(filter(None, os.environ.get("FAST_AUTH_KEYS", "").split(",")))
async def verify_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)):
"""Verify the API key from Authorization header (Bearer token format)."""
@@ -125,7 +123,9 @@ app.add_middleware(
)
async def rest_sse_from_astream(chunk_generator, response_id: str, conversation_id: str):
async def rest_sse_from_astream(
chunk_generator, response_id: str, conversation_id: str
):
"""
Stream chunks as SSE events.
@@ -185,7 +185,9 @@ async def chat(body: ChatRequest, _: str = Depends(verify_api_key)):
)
return StreamingResponse(
rest_sse_from_astream(
chunk_generator, response_id=response_id, conversation_id=conversation_id
chunk_generator,
response_id=response_id,
conversation_id=conversation_id,
),
media_type="text/event-stream",
)
@@ -195,7 +197,11 @@ async def chat(body: ChatRequest, _: str = Depends(verify_api_key)):
)
if not isinstance(result_text, str):
result_text = str(result_text)
return JSONResponse(content=ChatResponse(conversation_id=conversation_id, output=result_text).model_dump())
return JSONResponse(
content=ChatResponse(
conversation_id=conversation_id, output=result_text
).model_dump()
)
@app.post("/v1/conversations/{conversation_id}/messages")
@@ -215,7 +221,9 @@ async def create_message(
)
return StreamingResponse(
rest_sse_from_astream(
chunk_generator, response_id=response_id, conversation_id=conversation_id
chunk_generator,
response_id=response_id,
conversation_id=conversation_id,
),
media_type="text/event-stream",
)
@@ -257,7 +265,11 @@ async def delete_conversation_memory(
cleared = _try_clear_single_thread_memory(thread_id)
if cleared:
return JSONResponse(
content={"status": "success", "scope": "conversation", "conversation_id": conversation_id}
content={
"status": "success",
"scope": "conversation",
"conversation_id": conversation_id,
}
)
return JSONResponse(
content={
@@ -276,5 +288,3 @@ if __name__ == "__main__":
port=8589,
reload=True,
)

View File

@@ -16,7 +16,8 @@
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.3",
"vite": "^5.4.10"
"vite": "^5.4.10",
"vitest": "^2.1.8"
}
},
"node_modules/@babel/code-frame": {
@@ -1200,6 +1201,129 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -1244,6 +1368,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
@@ -1265,6 +1399,33 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1297,6 +1458,16 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
@@ -1304,6 +1475,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1353,6 +1531,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1422,6 +1620,13 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1432,6 +1637,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1465,6 +1680,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1600,6 +1832,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1610,6 +1849,64 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1715,6 +2012,112 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
@@ -17,7 +18,8 @@
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.3",
"vite": "^5.4.10"
"vite": "^5.4.10",
"vitest": "^2.1.8"
}
}

View File

@@ -4,6 +4,7 @@ import {
deleteGraphConfig,
getGraphConfig,
getGraphDefaultConfig,
getPipelineDefaultConfig,
getMcpToolConfig,
listAvailableGraphs,
listGraphConfigs,
@@ -12,10 +13,11 @@ import {
updateMcpToolConfig,
upsertGraphConfig,
} from "./api/frontApis";
import { chooseActiveConfigItem, chooseDisplayItemsByPipeline } from "./activeConfigSelection";
import type {
GraphConfigListItem,
GraphConfigReadResponse,
PipelineRunInfo,
PipelineSpec,
} from "./types";
type EditableAgent = {
@@ -26,24 +28,13 @@ type EditableAgent = {
promptSetId?: string;
toolKeys: string[];
prompts: Record<string, string>;
port: number;
apiKey: string;
llmName: string;
};
type LaunchCredentials = {
url: string;
authType: string;
authHeaderName: string;
authKey: string;
authKeyMasked: string;
};
type ActiveTab = "agents" | "mcp";
const DEFAULT_ENTRY_POINT = "fastapi_server/server_dashscope.py";
const DEFAULT_LLM_NAME = "qwen-plus";
const DEFAULT_PORT = 8100;
const DEFAULT_API_KEY = "";
const GRAPH_ARCH_IMAGE_MODULES = import.meta.glob(
"../assets/images/graph_arch/*.{png,jpg,jpeg,webp,gif}",
@@ -60,8 +51,8 @@ const FALLBACK_PROMPTS_BY_GRAPH: Record<string, Record<string, string>> = {
},
};
function makeAgentKey(pipelineId: string, promptSetId: string): string {
return `${pipelineId}::${promptSetId}`;
function makeAgentKey(pipelineId: string): string {
return `pipeline::${pipelineId}`;
}
function parseToolCsv(value: string): string[] {
@@ -108,14 +99,13 @@ function toEditable(
return {
id: draft
? `draft-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
: makeAgentKey(config.pipeline_id, config.prompt_set_id),
: makeAgentKey(config.pipeline_id),
isDraft: draft,
graphId: config.graph_id || config.pipeline_id,
pipelineId: config.pipeline_id,
promptSetId: config.prompt_set_id,
toolKeys: config.tool_keys || [],
prompts: config.prompt_dict || {},
port: DEFAULT_PORT,
apiKey: config.api_key || DEFAULT_API_KEY,
llmName: DEFAULT_LLM_NAME,
};
@@ -125,19 +115,18 @@ export default function App() {
const [activeTab, setActiveTab] = useState<ActiveTab>("agents");
const [graphs, setGraphs] = useState<string[]>([]);
const [configItems, setConfigItems] = useState<GraphConfigListItem[]>([]);
const [running, setRunning] = useState<PipelineRunInfo[]>([]);
const [running, setRunning] = useState<PipelineSpec[]>([]);
const [draftAgents, setDraftAgents] = useState<EditableAgent[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editor, setEditor] = useState<EditableAgent | null>(null);
const [statusMessage, setStatusMessage] = useState<string>("");
const [launchCredentials, setLaunchCredentials] = useState<LaunchCredentials | null>(null);
const [mcpConfigPath, setMcpConfigPath] = useState<string>("");
const [mcpConfigRaw, setMcpConfigRaw] = useState<string>("");
const [mcpToolKeys, setMcpToolKeys] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const configKeySet = useMemo(
() => new Set(configItems.map((x) => makeAgentKey(x.pipeline_id, x.prompt_set_id))),
() => new Set(configItems.map((x) => makeAgentKey(x.pipeline_id))),
[configItems]
);
const visibleConfigItems = useMemo(
@@ -155,6 +144,10 @@ export default function App() {
}),
[configItems]
);
const displayConfigItems = useMemo(
() => chooseDisplayItemsByPipeline(visibleConfigItems),
[visibleConfigItems]
);
const selectedRuns = useMemo(() => {
if (!editor?.pipelineId) {
@@ -164,10 +157,7 @@ export default function App() {
if (run.pipeline_id !== editor.pipelineId) {
return false;
}
if (!editor.promptSetId) {
return true;
}
return run.prompt_set_id === editor.promptSetId;
return true;
});
}, [editor, running]);
@@ -227,15 +217,24 @@ export default function App() {
}, [activeTab]);
async function selectExisting(item: GraphConfigListItem): Promise<void> {
const id = makeAgentKey(item.pipeline_id, item.prompt_set_id);
const id = makeAgentKey(item.pipeline_id);
setSelectedId(id);
setBusy(true);
setStatusMessage("Loading agent details...");
try {
const detail = await getGraphConfig(item.pipeline_id, item.prompt_set_id);
let detail: GraphConfigReadResponse;
try {
detail = await getPipelineDefaultConfig(item.pipeline_id);
} catch {
const latest = await listGraphConfigs({ pipeline_id: item.pipeline_id });
const selected = chooseActiveConfigItem(latest.items || [], item.pipeline_id);
if (!selected) {
throw new Error(`No prompt set found for pipeline '${item.pipeline_id}'`);
}
detail = await getGraphConfig(item.pipeline_id, selected.prompt_set_id);
}
const editable = toEditable(detail, false);
editable.id = id;
editable.port = editor?.pipelineId === editable.pipelineId ? editor.port : DEFAULT_PORT;
editable.llmName = editor?.pipelineId === editable.pipelineId ? editor.llmName : DEFAULT_LLM_NAME;
// apiKey is loaded from backend (persisted in DB) — don't override with default
setEditor(editable);
@@ -395,20 +394,30 @@ export default function App() {
setBusy(true);
setStatusMessage("Saving agent config...");
try {
let targetPromptSetId = editor.promptSetId;
if (!targetPromptSetId) {
try {
const active = await getPipelineDefaultConfig(editor.pipelineId.trim());
targetPromptSetId = active.prompt_set_id;
} catch {
throw new Error(
"No active prompt set for this pipeline. Create/activate one via backend first."
);
}
}
const upsertResp = await upsertGraphConfig({
graph_id: editor.graphId,
pipeline_id: editor.pipelineId.trim(),
prompt_set_id: editor.promptSetId,
prompt_set_id: targetPromptSetId,
tool_keys: editor.toolKeys,
prompt_dict: editor.prompts,
api_key: editor.apiKey.trim(),
});
await refreshConfigs();
const detail = await getGraphConfig(upsertResp.pipeline_id, upsertResp.prompt_set_id);
const detail = await getPipelineDefaultConfig(upsertResp.pipeline_id);
const saved = toEditable(detail, false);
saved.id = makeAgentKey(upsertResp.pipeline_id, upsertResp.prompt_set_id);
saved.port = editor.port;
saved.id = makeAgentKey(upsertResp.pipeline_id);
// apiKey is loaded from backend (persisted in DB) — don't override
saved.llmName = editor.llmName;
setEditor(saved);
@@ -461,10 +470,6 @@ export default function App() {
setStatusMessage("pipeline_id is required before run.");
return;
}
if (!Number.isInteger(editor.port) || editor.port <= 0) {
setStatusMessage("port must be a positive integer.");
return;
}
if (!editor.apiKey.trim()) {
setStatusMessage("api_key is required before run.");
return;
@@ -472,27 +477,20 @@ export default function App() {
setBusy(true);
setStatusMessage("Starting agent...");
setLaunchCredentials(null);
try {
const resp = await createPipeline({
graph_id: editor.graphId,
pipeline_id: editor.pipelineId.trim(),
prompt_set_id: editor.promptSetId,
tool_keys: editor.toolKeys,
port: editor.port,
api_key: editor.apiKey.trim(),
entry_point: DEFAULT_ENTRY_POINT,
llm_name: editor.llmName,
enabled: true,
});
await refreshRunning();
setStatusMessage(`Agent started. URL: ${resp.url}`);
setLaunchCredentials({
url: resp.url,
authType: resp.auth_type,
authHeaderName: resp.auth_header_name,
authKey: resp.auth_key_once,
authKeyMasked: resp.auth_key_masked,
});
setStatusMessage(
`Agent registered. config_file=${resp.config_file}, reload_required=${String(resp.reload_required)}`
);
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
@@ -513,7 +511,7 @@ export default function App() {
setBusy(true);
setStatusMessage("Stopping agent...");
try {
await stopPipeline(target.run_id);
await stopPipeline(target.pipeline_id);
await refreshRunning();
setStatusMessage("Agent stopped.");
} catch (error) {
@@ -530,31 +528,14 @@ export default function App() {
graphId: d.graphId,
isDraft: true,
})),
...visibleConfigItems.map((item) => ({
id: makeAgentKey(item.pipeline_id, item.prompt_set_id),
...displayConfigItems.map((item) => ({
id: makeAgentKey(item.pipeline_id),
label: item.pipeline_id,
graphId: item.graph_id || item.pipeline_id,
isDraft: false,
})),
];
const graphArchImage = editor ? getGraphArchImage(editor.graphId) : null;
const authHeaderValue = launchCredentials
? `${launchCredentials.authHeaderName}: Bearer ${launchCredentials.authKey}`
: "";
const canUseClipboard = typeof navigator !== "undefined" && Boolean(navigator.clipboard);
async function copyText(text: string, label: string): Promise<void> {
if (!canUseClipboard) {
setStatusMessage(`Clipboard is not available. Please copy ${label} manually.`);
return;
}
try {
await navigator.clipboard.writeText(text);
setStatusMessage(`${label} copied.`);
} catch {
setStatusMessage(`Failed to copy ${label}.`);
}
}
const showSidebar = activeTab === "agents";
@@ -580,9 +561,7 @@ export default function App() {
setEditor(selectedDraft);
return;
}
const item = visibleConfigItems.find(
(x) => makeAgentKey(x.pipeline_id, x.prompt_set_id) === row.id
);
const item = displayConfigItems.find((x) => makeAgentKey(x.pipeline_id) === row.id);
if (item) {
selectExisting(item);
}
@@ -638,48 +617,6 @@ export default function App() {
</button>
</div>
{launchCredentials ? (
<div className="launch-credentials">
<h3>Access Credentials (shown once)</h3>
<div>
<strong>URL:</strong>{" "}
<a href={launchCredentials.url} target="_blank" rel="noreferrer">
{launchCredentials.url}
</a>
<button
type="button"
onClick={() => copyText(launchCredentials.url, "URL")}
disabled={busy}
>
Copy URL
</button>
</div>
<div>
<strong>{launchCredentials.authType} key:</strong> {launchCredentials.authKey}
<button
type="button"
onClick={() => copyText(launchCredentials.authKey, "auth key")}
disabled={busy}
>
Copy Key
</button>
</div>
<div>
<strong>Header:</strong> <code>{authHeaderValue}</code>
<button
type="button"
onClick={() => copyText(authHeaderValue, "auth header")}
disabled={busy}
>
Copy Header
</button>
</div>
<p className="empty">
Stored after launch as masked value: {launchCredentials.authKeyMasked}
</p>
</div>
) : null}
{!editor ? (
<div className="empty-panel">
<p>Select an agent from the left or create a new one.</p>
@@ -724,11 +661,6 @@ export default function App() {
/>
</label>
<label>
prompt_set_id
<input value={editor.promptSetId || "(assigned on save)"} readOnly />
</label>
<label>
tool_keys (comma separated)
<input
@@ -739,17 +671,6 @@ export default function App() {
/>
</label>
<label>
port
<input
type="number"
min={1}
value={editor.port}
onChange={(e) => updateEditor("port", Number(e.target.value))}
disabled={busy}
/>
</label>
<label>
api_key
<input
@@ -798,21 +719,21 @@ export default function App() {
<p className="empty">No active runs for this agent.</p>
) : (
selectedRuns.map((run) => (
<div key={run.run_id} className="run-card">
<div key={run.pipeline_id} className="run-card">
<div>
<strong>run_id:</strong> {run.run_id}
<strong>pipeline_id:</strong> {run.pipeline_id}
</div>
<div>
<strong>pid:</strong> {run.pid}
<strong>graph_id:</strong> {run.graph_id}
</div>
<div>
<strong>url:</strong>{" "}
<a href={run.url} target="_blank" rel="noreferrer">
{run.url}
</a>
<strong>model:</strong> {run.llm_name}
</div>
<div>
<strong>auth:</strong> {run.auth_header_name} Bearer {run.auth_key_masked}
<strong>enabled:</strong> {String(run.enabled)}
</div>
<div>
<strong>config_file:</strong> {run.config_file}
</div>
</div>
))

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { chooseActiveConfigItem, chooseDisplayItemsByPipeline } from "./activeConfigSelection";
import type { GraphConfigListItem } from "./types";
const mk = (patch: Partial<GraphConfigListItem>): GraphConfigListItem => ({
graph_id: "routing",
pipeline_id: "agent-a",
prompt_set_id: "set-1",
name: "default",
description: "",
is_active: false,
tool_keys: [],
api_key: "",
created_at: null,
updated_at: null,
...patch,
});
describe("chooseActiveConfigItem", () => {
it("prefers active item over newer inactive items", () => {
const items = [
mk({
pipeline_id: "agent-a",
prompt_set_id: "old-active",
is_active: true,
updated_at: "2025-01-01T00:00:00Z",
}),
mk({
pipeline_id: "agent-a",
prompt_set_id: "new-inactive",
is_active: false,
updated_at: "2025-03-01T00:00:00Z",
}),
];
const selected = chooseActiveConfigItem(items, "agent-a");
expect(selected?.prompt_set_id).toBe("old-active");
});
it("falls back to latest updated_at when no active item exists", () => {
const items = [
mk({
pipeline_id: "agent-b",
prompt_set_id: "set-1",
updated_at: "2025-01-01T00:00:00Z",
}),
mk({
pipeline_id: "agent-b",
prompt_set_id: "set-2",
updated_at: "2025-02-01T00:00:00Z",
}),
];
const selected = chooseActiveConfigItem(items, "agent-b");
expect(selected?.prompt_set_id).toBe("set-2");
});
});
describe("chooseDisplayItemsByPipeline", () => {
it("returns one selected item per pipeline_id", () => {
const items = [
mk({ pipeline_id: "agent-b", prompt_set_id: "set-1", updated_at: "2025-01-01T00:00:00Z" }),
mk({
pipeline_id: "agent-b",
prompt_set_id: "set-2",
is_active: true,
updated_at: "2025-02-01T00:00:00Z",
}),
mk({
pipeline_id: "agent-a",
prompt_set_id: "set-3",
updated_at: "2025-03-01T00:00:00Z",
}),
];
const selected = chooseDisplayItemsByPipeline(items);
expect(selected.map((x) => x.pipeline_id)).toEqual(["agent-a", "agent-b"]);
expect(selected.find((x) => x.pipeline_id === "agent-b")?.prompt_set_id).toBe("set-2");
});
});

View File

@@ -0,0 +1,45 @@
import type { GraphConfigListItem } from "./types";
function toTimestamp(value?: string | null): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
export function chooseActiveConfigItem(
items: GraphConfigListItem[],
pipelineId: string
): GraphConfigListItem | null {
const candidates = items.filter((item) => item.pipeline_id === pipelineId);
if (candidates.length === 0) {
return null;
}
const active = candidates.find((item) => item.is_active);
if (active) {
return active;
}
return [...candidates].sort((a, b) => toTimestamp(b.updated_at) - toTimestamp(a.updated_at))[0];
}
export function chooseDisplayItemsByPipeline(
items: GraphConfigListItem[]
): GraphConfigListItem[] {
const byPipeline = new Map<string, GraphConfigListItem[]>();
for (const item of items) {
const list = byPipeline.get(item.pipeline_id) || [];
list.push(item);
byPipeline.set(item.pipeline_id, list);
}
const out: GraphConfigListItem[] = [];
for (const [pipelineId, list] of byPipeline.entries()) {
const selected = chooseActiveConfigItem(list, pipelineId);
if (selected) {
out.push(selected);
}
}
return out.sort((a, b) => a.pipeline_id.localeCompare(b.pipeline_id));
}

View File

@@ -70,6 +70,12 @@ export function getGraphDefaultConfig(
return fetchJson(`/v1/graphs/${graphId}/default-config`);
}
export function getPipelineDefaultConfig(
pipelineId: string
): Promise<GraphConfigReadResponse> {
return fetchJson(`/v1/graph-configs/default/${pipelineId}`);
}
export function upsertGraphConfig(
payload: GraphConfigUpsertRequest
): Promise<GraphConfigUpsertResponse> {
@@ -114,8 +120,8 @@ export function listPipelines(): Promise<PipelineListResponse> {
return fetchJson("/v1/pipelines");
}
export function stopPipeline(runId: string): Promise<PipelineStopResponse> {
return fetchJson(`/v1/pipelines/${runId}`, {
export function stopPipeline(pipelineId: string): Promise<PipelineStopResponse> {
return fetchJson(`/v1/pipelines/${pipelineId}`, {
method: "DELETE",
});
}

View File

@@ -52,37 +52,41 @@ export type PipelineCreateRequest = {
pipeline_id: string;
prompt_set_id: string;
tool_keys: string[];
port: number;
api_key: string;
entry_point: string;
api_key?: string;
llm_name: string;
enabled?: boolean;
};
export type PipelineRunInfo = {
run_id: string;
pid: number;
export type PipelineSpec = {
pipeline_id: string;
graph_id: string;
enabled: boolean;
config_file: string;
llm_name: string;
overrides: Record<string, unknown>;
};
export type PipelineCreateResponse = {
pipeline_id: string;
prompt_set_id: string;
url: string;
port: number;
auth_type: string;
auth_header_name: string;
auth_key_masked: string;
};
export type PipelineCreateResponse = PipelineRunInfo & {
auth_key_once: string;
graph_id: string;
config_file: string;
llm_name: string;
enabled: boolean;
reload_required: boolean;
registry_path: string;
};
export type PipelineListResponse = {
items: PipelineRunInfo[];
items: PipelineSpec[];
count: number;
};
export type PipelineStopResponse = {
run_id: string;
pipeline_id: string;
status: string;
enabled: boolean;
reload_required: boolean;
};
export type McpToolConfigResponse = {

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/frontApis.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/activeConfigSelection.test.ts","./src/activeConfigSelection.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/frontApis.ts"],"version":"5.9.3"}

View File

@@ -4,16 +4,34 @@ from typing import List, Dict, Union
from enum import Enum
import os
from loguru import logger
from abc import ABC, abstractmethod
from langchain_core.messages import (
HumanMessage,
AIMessage,
ToolMessage,
SystemMessage,
BaseMessage,
)
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, SystemMessage, BaseMessage
class MessageType(str, Enum):
"""Enum for message types in the conversation store."""
HUMAN = "human"
AI = "ai"
TOOL = "tool"
class ConversationStore:
class BaseConvStore(ABC):
@abstractmethod
def record_message_list(
self, conv_id: str, inp: List[BaseMessage], pipeline_id: str = None
):
pass
class ConversationStore(BaseConvStore):
def __init__(self):
conn_str = os.environ.get("CONN_STR")
if conn_str is None:
@@ -25,46 +43,53 @@ class ConversationStore:
conversation_id: str,
msg_type: MessageType,
content: str,
sequence: int, # the conversation number
sequence: int,
pipeline_id: str = None,
):
with psycopg.connect(self.conn_str) as conn:
with conn.cursor() as cur:
# DB schema only supports these columns:
# (conversation_id, message_type, content, sequence_number)
cur.execute(
"""
INSERT INTO messages (conversation_id, message_type, content, sequence_number)
VALUES (%s, %s, %s, %s)
INSERT INTO messages (conversation_id, pipeline_id, message_type, content, sequence_number)
VALUES (%s, %s, %s, %s, %s)
""",
(conversation_id, msg_type.value, content, sequence),
(conversation_id, pipeline_id, msg_type.value, content, sequence),
)
def get_conv_number(self, conversation_id: str) -> int:
"""
if the conversation_id does not exist, return 0
if len(conversation) = 3, it will return 3
if the conversation_id does not exist, return 0
if len(conversation) = 3, it will return 3
"""
with psycopg.connect(self.conn_str) as conn:
with conn.cursor() as cur:
cur.execute("""
cur.execute(
"""
SELECT COUNT(*)
FROM messages
WHERE conversation_id = %s
""", (conversation_id,))
""",
(conversation_id,),
)
return int(cur.fetchone()[0])
def get_conversation(self, conversation_id: str) -> List[Dict]:
with psycopg.connect(self.conn_str) as conn:
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute("""
cur.execute(
"""
SELECT message_type, content, sequence_number, created_at
FROM messages
WHERE conversation_id = %s
ORDER BY sequence_number ASC
""", (conversation_id,))
""",
(conversation_id,),
)
return cur.fetchall()
def record_message_list(self, conv_id:str, inp:List[BaseMessage]):
def record_message_list(
self, conv_id: str, inp: List[BaseMessage], pipeline_id: str = None
):
inp = [e for e in inp if not isinstance(e, SystemMessage)]
curr_len = self.get_conv_number(conv_id)
to_add_msg = inp[curr_len:]
@@ -73,12 +98,13 @@ class ConversationStore:
# Serialize dict/list content to JSON string
if not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False, indent=4)
self.add_message(conv_id, self._get_type(msg), content, curr_len + 1)
self.add_message(
conv_id, self._get_type(msg), content, curr_len + 1, pipeline_id
)
curr_len += 1
return curr_len
def _get_type(self, msg:BaseMessage) -> MessageType:
def _get_type(self, msg: BaseMessage) -> MessageType:
if isinstance(msg, HumanMessage):
return MessageType.HUMAN
elif isinstance(msg, AIMessage):
@@ -89,11 +115,13 @@ class ConversationStore:
raise ValueError(f"Unknown message type: {type(msg)}")
class ConversationPrinter:
class ConversationPrinter(BaseConvStore):
def __init__(self):
self.id_dic = {}
def record_message_list(self, conv_id:str, inp:List[BaseMessage]):
def record_message_list(
self, conv_id: str, inp: List[BaseMessage], pipeline_id: str = None
):
inp = [e for e in inp if not isinstance(e, SystemMessage)]
curr_len = self.id_dic.get(conv_id, 0)
to_print_msg = inp[curr_len:]
@@ -106,9 +134,11 @@ class ConversationPrinter:
else:
self.id_dic[conv_id] += len(to_print_msg)
CONV_STORE = ConversationStore()
# CONV_STORE = ConversationPrinter()
def use_printer():
global CONV_STORE
CONV_STORE = ConversationPrinter()

View File

@@ -11,21 +11,15 @@ from lang_agent.config.core_config import load_tyro_conf
class ServerPipelineManager:
"""Lazily load and cache multiple pipelines keyed by a client-facing route id."""
"""Lazily load and cache multiple pipelines keyed by a client-facing pipeline id."""
def __init__(self, default_route_id: str, default_config: PipelineConfig, default_pipeline: Pipeline):
self.default_route_id = default_route_id
def __init__(self, default_pipeline_id: str, default_config: PipelineConfig):
self.default_pipeline_id = default_pipeline_id
self.default_config = default_config
self._route_specs: Dict[str, Dict[str, Any]] = {}
self._pipeline_specs: Dict[str, Dict[str, Any]] = {}
self._api_key_policy: Dict[str, Dict[str, Any]] = {}
self._pipelines: Dict[str, Pipeline] = {default_route_id: default_pipeline}
self._pipeline_llm: Dict[str, str] = {default_route_id: default_config.llm_name}
self._route_specs[default_route_id] = {
"enabled": True,
"config_file": None,
"overrides": {},
"prompt_pipeline_id": None,
}
self._pipelines: Dict[str, Pipeline] = {}
self._pipeline_llm: Dict[str, str] = {}
def _resolve_registry_path(self, registry_path: str) -> str:
path = FsPath(registry_path)
@@ -39,35 +33,36 @@ class ServerPipelineManager:
def load_registry(self, registry_path: str) -> None:
abs_path = self._resolve_registry_path(registry_path)
if not osp.exists(abs_path):
logger.warning(f"pipeline registry file not found: {abs_path}. Using default pipeline only.")
return
raise ValueError(f"pipeline registry file not found: {abs_path}")
with open(abs_path, "r", encoding="utf-8") as f:
registry:dict = json.load(f)
registry: dict = json.load(f)
routes = registry.get("routes")
if routes is None:
# Backward compatibility with initial schema.
routes = registry.get("pipelines", {})
if not isinstance(routes, dict):
raise ValueError("`routes` in pipeline registry must be an object.")
pipelines = registry.get("pipelines")
if pipelines is None:
raise ValueError("`pipelines` in pipeline registry must be an object.")
for route_id, spec in routes.items():
self._pipeline_specs = {}
for pipeline_id, spec in pipelines.items():
if not isinstance(spec, dict):
raise ValueError(f"route spec for `{route_id}` must be an object.")
self._route_specs[route_id] = {
raise ValueError(
f"pipeline spec for `{pipeline_id}` must be an object."
)
self._pipeline_specs[pipeline_id] = {
"enabled": bool(spec.get("enabled", True)),
"config_file": spec.get("config_file"),
"overrides": spec.get("overrides", {}),
# Explicitly separates routing id from prompt config pipeline_id.
"prompt_pipeline_id": spec.get("prompt_pipeline_id"),
}
if not self._pipeline_specs:
raise ValueError("pipeline registry must define at least one pipeline.")
api_key_policy = registry.get("api_keys", {})
if api_key_policy and not isinstance(api_key_policy, dict):
raise ValueError("`api_keys` in pipeline registry must be an object.")
self._api_key_policy = api_key_policy
logger.info(f"loaded pipeline registry: {abs_path}, routes={list(self._route_specs.keys())}")
logger.info(
f"loaded pipeline registry: {abs_path}, pipelines={list(self._pipeline_specs.keys())}"
)
def _resolve_config_path(self, config_file: str) -> str:
path = FsPath(config_file)
@@ -78,54 +73,47 @@ class ServerPipelineManager:
root = FsPath(__file__).resolve().parents[2]
return str((root / path).resolve())
def _build_pipeline(self, route_id: str) -> Tuple[Pipeline, str]:
spec = self._route_specs.get(route_id)
def _build_pipeline(self, pipeline_id: str) -> Tuple[Pipeline, str]:
spec = self._pipeline_specs.get(pipeline_id)
if spec is None:
raise HTTPException(status_code=404, detail=f"Unknown route_id: {route_id}")
raise HTTPException(
status_code=404, detail=f"Unknown pipeline_id: {pipeline_id}"
)
if not spec.get("enabled", True):
raise HTTPException(status_code=403, detail=f"Route disabled: {route_id}")
raise HTTPException(
status_code=403, detail=f"Pipeline disabled: {pipeline_id}"
)
config_file = spec.get("config_file")
overrides = spec.get("overrides", {})
if not config_file and not overrides:
# default pipeline
p = self._pipelines[self.default_route_id]
llm_name = self._pipeline_llm[self.default_route_id]
return p, llm_name
if config_file:
loaded_cfg = load_tyro_conf(self._resolve_config_path(config_file))
# Some legacy yaml configs deserialize to plain dicts instead of
# InstantiateConfig dataclasses. Fall back to default config in that case.
if hasattr(loaded_cfg, "setup"):
cfg = loaded_cfg
else:
logger.warning(
f"config_file for route `{route_id}` did not deserialize to config object; "
"falling back to default config and applying route-level overrides."
f"config_file for pipeline `{pipeline_id}` did not deserialize to config object; "
"falling back to default config and applying pipeline-level overrides."
)
cfg = copy.deepcopy(self.default_config)
else:
# Build from default config + shallow overrides so new pipelines can be
# added via registry without additional yaml files.
cfg = copy.deepcopy(self.default_config)
if not isinstance(overrides, dict):
raise ValueError(f"route `overrides` for `{route_id}` must be an object.")
raise ValueError(
f"pipeline `overrides` for `{pipeline_id}` must be an object."
)
for key, value in overrides.items():
if not hasattr(cfg, key):
raise ValueError(f"unknown override field `{key}` for route `{route_id}`")
raise ValueError(
f"unknown override field `{key}` for pipeline `{pipeline_id}`"
)
setattr(cfg, key, value)
prompt_pipeline_id = spec.get("prompt_pipeline_id")
if prompt_pipeline_id and (not isinstance(overrides, dict) or "pipeline_id" not in overrides):
if hasattr(cfg, "pipeline_id"):
cfg.pipeline_id = prompt_pipeline_id
p = cfg.setup()
llm_name = getattr(cfg, "llm_name", "unknown-model")
return p, llm_name
def _authorize(self, api_key: str, route_id: str) -> None:
def _authorize(self, api_key: str, pipeline_id: str) -> None:
if not self._api_key_policy:
return
@@ -133,47 +121,46 @@ class ServerPipelineManager:
if policy is None:
return
allowed = policy.get("allowed_route_ids")
if allowed is None:
# Backward compatibility.
allowed = policy.get("allowed_pipeline_ids")
if allowed and route_id not in allowed:
raise HTTPException(status_code=403, detail=f"route_id `{route_id}` is not allowed for this API key")
allowed = policy.get("allowed_pipeline_ids")
if allowed and pipeline_id not in allowed:
raise HTTPException(
status_code=403,
detail=f"pipeline_id `{pipeline_id}` is not allowed for this API key",
)
def resolve_route_id(self, body: Dict[str, Any], app_id: Optional[str], api_key: str) -> str:
def resolve_pipeline_id(
self, body: Dict[str, Any], app_id: Optional[str], api_key: str
) -> str:
body_input = body.get("input", {})
route_id = (
body.get("route_id")
or (body_input.get("route_id") if isinstance(body_input, dict) else None)
or body.get("pipeline_key")
or (body_input.get("pipeline_key") if isinstance(body_input, dict) else None)
# Backward compatibility: pipeline_id still accepted as route selector.
or body.get("pipeline_id")
pipeline_id = (
body.get("pipeline_id")
or (body_input.get("pipeline_id") if isinstance(body_input, dict) else None)
or app_id
)
if not route_id:
key_policy = self._api_key_policy.get(api_key, {}) if self._api_key_policy else {}
route_id = key_policy.get("default_route_id")
if not route_id:
# Backward compatibility.
route_id = key_policy.get("default_pipeline_id", self.default_route_id)
if not pipeline_id:
key_policy = (
self._api_key_policy.get(api_key, {}) if self._api_key_policy else {}
)
pipeline_id = key_policy.get(
"default_pipeline_id", self.default_pipeline_id
)
if route_id not in self._route_specs:
raise HTTPException(status_code=404, detail=f"Unknown route_id: {route_id}")
if pipeline_id not in self._pipeline_specs:
raise HTTPException(
status_code=404, detail=f"Unknown pipeline_id: {pipeline_id}"
)
self._authorize(api_key, route_id)
return route_id
self._authorize(api_key, pipeline_id)
return pipeline_id
def get_pipeline(self, route_id: str) -> Tuple[Pipeline, str]:
cached = self._pipelines.get(route_id)
def get_pipeline(self, pipeline_id: str) -> Tuple[Pipeline, str]:
cached = self._pipelines.get(pipeline_id)
if cached is not None:
return cached, self._pipeline_llm[route_id]
return cached, self._pipeline_llm[pipeline_id]
pipeline_obj, llm_name = self._build_pipeline(route_id)
self._pipelines[route_id] = pipeline_obj
self._pipeline_llm[route_id] = llm_name
logger.info(f"lazy-loaded route_id={route_id} model={llm_name}")
pipeline_obj, llm_name = self._build_pipeline(pipeline_id)
self._pipelines[pipeline_id] = pipeline_obj
self._pipeline_llm[pipeline_id] = llm_name
logger.info(f"lazy-loaded pipeline_id={pipeline_id} model={llm_name}")
return pipeline_obj, llm_name

View File

@@ -1,5 +1,16 @@
from lang_agent.config.core_config import (InstantiateConfig,
ToolConfig,
LLMKeyConfig,
LLMNodeConfig,
load_tyro_conf)
from lang_agent.config.core_config import (
InstantiateConfig,
ToolConfig,
LLMKeyConfig,
LLMNodeConfig,
load_tyro_conf,
)
from lang_agent.config.constants import (
MCP_CONFIG_PATH,
MCP_CONFIG_DEFAULT_CONTENT,
PIPELINE_REGISTRY_PATH,
VALID_API_KEYS,
API_KEY_HEADER,
API_KEY_HEADER_NO_ERROR
)

View File

@@ -0,0 +1,17 @@
import os
import re
import os.path as osp
from fastapi.security import APIKeyHeader
_PROJECT_ROOT = osp.dirname(osp.dirname(osp.dirname(osp.abspath(__file__))))
MCP_CONFIG_PATH = osp.join(_PROJECT_ROOT, "configs", "mcp_config.json")
MCP_CONFIG_DEFAULT_CONTENT = "{\n}\n"
# NOTE: the fancy looking api key is FAST_AUTH_KEYS from .env
PIPELINE_REGISTRY_PATH = osp.join(_PROJECT_ROOT, "configs", "pipeline_registry.json")
API_KEY_HEADER = APIKeyHeader(name="Authorization", auto_error=True)
API_KEY_HEADER_NO_ERROR = APIKeyHeader(name="Authorization", auto_error=False)
VALID_API_KEYS = set(filter(None, os.environ.get("FAST_AUTH_KEYS", "").split(",")))

View File

@@ -72,56 +72,12 @@ class InstantiateConfig(PrintableConfig):
将配置保存到 YAML 文件
"""
def mask_value(key, value):
"""
Apply masking if key is secret-like
如果键是敏感的,应用掩码
检查键是否敏感(如包含 "secret""api_key"),如果是,则对值进行掩码处理
"""
if isinstance(value, str) and self.is_secrete(str(key)):
sval = str(value)
return sval[:3] + "*" * (len(sval) - 6) + sval[-3:]
return value
def to_serializable(obj, apply_mask: bool):
"""
Recursively convert dataclasses and containers to serializable format,
optionally masking secrets.
递归地将数据类和容器转换为可序列化的格式,可选地对敏感信息进行掩码处理
"""
if is_dataclass(obj):
out = {}
for k, v in vars(obj).items():
if is_dataclass(v) or isinstance(v, (dict, list, tuple)):
out[k] = to_serializable(v, apply_mask)
else:
out[k] = mask_value(k, v) if apply_mask else v
return out
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if is_dataclass(v) or isinstance(v, (dict, list, tuple)):
out[k] = to_serializable(v, apply_mask)
else:
key_str = str(k)
out[k] = mask_value(key_str, v) if apply_mask else v
return out
if isinstance(obj, list):
return [to_serializable(v, apply_mask) for v in obj]
if isinstance(obj, tuple):
return tuple(to_serializable(v, apply_mask) for v in obj)
return obj
# NOTE: we intentionally do NOT mask secrets when saving to disk so that
# configs can be reloaded with real values. Masking is handled in __str__
# for safe logging/printing. If you need a redacted copy, call
# to_serializable(self, apply_mask=True) manually and dump it yourself.
serializable = to_serializable(self, apply_mask=False)
with open(filename, 'w') as f:
yaml.dump(serializable, f)
# Persist the full config object (including type tags) so it can be
# deserialized back into config instances with methods like .setup().
# Secret masking is intentionally handled by __str__ for printing/logging,
# not when writing to disk.
with open(filename, "w", encoding="utf-8") as f:
yaml.dump(self, f)
logger.info(f"[yellow]config saved to: {filename}[/yellow]")
def get_name(self):
@@ -182,7 +138,7 @@ def load_tyro_conf(filename: str, inp_conf = None) -> InstantiateConfig:
"""
config = yaml.load(Path(filename).read_text(), Loader=yaml.Loader)
config = ovewrite_config(config, inp_conf) if inp_conf is not None else config
# config = ovewrite_config(config, inp_conf) if inp_conf is not None else config
return config
def is_default(instance, field_):

View File

@@ -0,0 +1,13 @@
import tyro
from loguru import logger
import os
import os.path as osp
from lang_agent.pipeline import PipelineConfig
def build_conf(pipeline:PipelineConfig, save_path:str):
os.makedirs(osp.dirname(save_path), exist_ok=True)
logger.info(pipeline)
pipeline.save_config(save_path)
if __name__ == "__main__":
tyro.cli(build_conf)

View File

@@ -1,71 +0,0 @@
from typing import Dict, List, Optional
import os
import subprocess
def build_route(pipeline_id:str,
prompt_set:str,
tool_keys:List[str],
port:str,
api_key: str,
fast_auth_keys: Optional[str] = None,
entry_pnt:str="fastapi_server/server_dashscope.py",
llm_name:str="qwen-plus"):
cmd = [
"python", entry_pnt,
"--port", str(port),
"route", # ------------
"--llm-name", llm_name,
"--api-key", api_key,
"--pipeline-id", pipeline_id,
"--prompt-set-id", prompt_set,
"tool_node", # ------------
"--llm-name", llm_name,
"--api-key", api_key,
"--pipeline-id", pipeline_id,
"--prompt-set-id", prompt_set,
]
if tool_keys:
cmd.extend(
["--tool-manager-config.client-tool-manager.tool-keys", *tool_keys]
)
env: Dict[str, str] = os.environ.copy()
if fast_auth_keys:
env["FAST_AUTH_KEYS"] = fast_auth_keys
sv_prc = subprocess.Popen(cmd, env=env)
return sv_prc, f"http://127.0.0.1:{port}/api/"
def build_react(pipeline_id:str,
prompt_set:str,
tool_keys:List[str],
port:str,
api_key: str,
fast_auth_keys: Optional[str] = None,
entry_pnt:str="fastapi_server/server_dashscope.py",
llm_name:str="qwen-plus"):
cmd = [
"python", entry_pnt,
"--port", str(port),
"react", # ------------
"--llm-name", llm_name,
"--api-key", api_key,
"--pipeline-id", pipeline_id,
"--prompt-set-id", prompt_set,
]
if tool_keys:
cmd.extend(
["--tool-manager-config.client-tool-manager.tool-keys", *tool_keys]
)
env: Dict[str, str] = os.environ.copy()
if fast_auth_keys:
env["FAST_AUTH_KEYS"] = fast_auth_keys
sv_prc = subprocess.Popen(cmd, env=env)
return sv_prc, f"http://127.0.0.1:{port}/api/"
# {pipeline_id: build_function}
GRAPH_BUILD_FNCS = {
"routing": build_route,
"react": build_react,
}

View File

@@ -0,0 +1,117 @@
from typing import Any, Dict, List
import os
import os.path as osp
import subprocess
import json
from lang_agent.config.core_config import load_tyro_conf
_PROJECT_ROOT = osp.dirname(osp.dirname(osp.dirname(osp.abspath(__file__))))
_TY_BUILD_SCRIPT = osp.join(_PROJECT_ROOT, "lang_agent", "config", "ty_build_config.py")
def opt_to_config(save_path: str, *nargs):
os.makedirs(osp.dirname(save_path), exist_ok=True)
subprocess.run(
["python", _TY_BUILD_SCRIPT, "--save-path", save_path, *nargs],
check=True,
cwd=_PROJECT_ROOT,
)
def _build_and_load_pipeline_config(
pipeline_id: str, pipeline_config_dir: str, cmd: List[str]
):
save_config_f = osp.join(pipeline_config_dir, f"{pipeline_id}.yml")
opt_to_config(save_config_f, *cmd)
# TODO: think if returning the built pipeline is better or just the config obj for front_api
return load_tyro_conf(save_config_f)
def update_pipeline_registry(
pipeline_id: str,
graph_id: str,
config_file: str,
llm_name: str,
enabled: bool = True,
registry_f: str = "configs/pipeline_registry.json",
):
if not osp.isabs(registry_f):
registry_f = osp.join(_PROJECT_ROOT, registry_f)
os.makedirs(osp.dirname(registry_f), exist_ok=True)
if not osp.exists(registry_f):
with open(registry_f, "w", encoding="utf-8") as f:
json.dump({"pipelines": {}, "api_keys": {}}, f, indent=4)
with open(registry_f, "r") as f:
registry = json.load(f)
pipelines: Dict[str, Dict[str, Any]] = registry.setdefault("pipelines", {})
pipeline = pipelines.setdefault(pipeline_id, {})
pipeline["enabled"] = bool(enabled)
pipeline["config_file"] = config_file
pipeline["graph_id"] = graph_id
pipeline["overrides"] = {"llm_name": llm_name}
with open(registry_f, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=4)
def build_route(
pipeline_id: str,
prompt_set: str,
tool_keys: List[str],
api_key: str,
llm_name: str = "qwen-plus",
pipeline_config_dir="configs/pipelines",
):
cmd_opt = [
"route", # ------------
"--llm-name", llm_name,
"--api-key", api_key,
"--pipeline-id", pipeline_id,
"--prompt-set-id", prompt_set,
"tool_node", # ------------
"--llm-name", llm_name,
"--api-key", api_key,
"--pipeline-id", pipeline_id,
"--prompt-set-id", prompt_set,
]
if tool_keys:
cmd_opt.extend(
["--tool-manager-config.client-tool-manager.tool-keys", *tool_keys]
)
return _build_and_load_pipeline_config(pipeline_id, pipeline_config_dir, cmd_opt)
def build_react(
pipeline_id: str,
prompt_set: str,
tool_keys: List[str],
api_key: str,
llm_name: str = "qwen-plus",
pipeline_config_dir="configs/pipelines",
):
cmd_opt = [
"react", # ------------
"--llm-name", llm_name,
"--api-key", api_key,
"--pipeline-id", pipeline_id,
"--prompt-set-id", prompt_set,
]
if tool_keys:
cmd_opt.extend(
["--tool-manager-config.client-tool-manager.tool-keys", *tool_keys]
)
return _build_and_load_pipeline_config(pipeline_id, pipeline_config_dir, cmd_opt)
# {pipeline_id: build_function}
GRAPH_BUILD_FNCS = {
"routing": build_route,
"react": build_react,
}

View File

@@ -26,17 +26,18 @@ SYS_PROMPT = """你是一个专业的心理质询师。你的主要工作是心
可怎么也发不出声音,只能眼睁睁看着它越来越远,然后就醒了。醒来后心里堵得慌,说不上来的难受,
总觉得那只小狗孤零零的,特别让人心疼。
理解(你的回复): 能感受到你醒来后的这份难受 —— 看到弱小的生命独自挣扎,而自己却无能为力,这种想帮却做不到的无力感,
理解(你的回复): 能感受到你醒来后的这份难受 —— 看到弱小的生命独自挣扎,而自己却无能为力,这种'想帮却做不到'的无力感,
其实是很真实的情绪反馈。你会心疼小狗,说明你内心藏着很珍贵的共情力,这份柔软不是矫情,
而是你感知他人痛苦的能力呀
解析(你的回复):我们再说回这个梦吧,我们的梦境其实没有唯一的‘正确解释’,但我们可以一起看看它可能和你当下的状态有什么关联~ 首先,‘出差去广州’通常象征着你近期正在推进的某件事 —— 可能是工作上的一个项目,也可能是生活中一段需要‘独自奔赴’的旅程,是你当下比较关注、需要投入精力的目标,对吗?”
而那只瘸脚的小狗,在心理学视角中,常常是我们潜意识里‘脆弱自我’的投射。它可能代表着你近期的某一面:比如在处理那件‘需要奔赴’的事时,你偶尔会觉得自己像小狗一样‘力不从心’,或者感受到了‘孤单’,却没找到合适的人倾诉或求助;也可能是你近期在生活中看到了一些让你觉得‘无力改变’的场景(比如身边人遇到困难、社会上的小事),这些情绪没有被你刻意留意,就通过梦境里的小狗呈现了出来。
你想喊停列车却发不出声音,这种‘无能为力’的感觉,或许正是你现实中某类情绪的写照:可能你面对一些情况时,心里有想法却没机会表达,或者想帮忙却找不到合适的方式,这种压抑感在梦里被放大了。其实这个梦在提醒你:你的‘无力感’和‘共情心’都是真实的,不用因为‘帮不上忙’而自责 —— 承认自己的局限,也是一种自我接纳呀
解析(你的回复):我们再说回这个梦吧,我们的梦境其实没有唯一的'正确解释',但我们可以一起看看它可能和你当下的状态有什么关联~ 首先,'出差去广州'通常象征着你近期正在推进的某件事 —— 可能是工作上的一个项目,也可能是生活中一段需要'独自奔赴'的旅程,是你当下比较关注、需要投入精力的目标,对吗?”
"而那只瘸脚的小狗,在心理学视角中,常常是我们潜意识里'脆弱自我'的投射。它可能代表着你近期的某一面:比如在处理那件'需要奔赴'的事时,你偶尔会觉得自己像小狗一样'力不从心',或者感受到了'孤单',却没找到合适的人倾诉或求助;也可能是你近期在生活中看到了一些让你觉得'无力改变'的场景(比如身边人遇到困难、社会上的小事),这些情绪没有被你刻意留意,就通过梦境里的小狗呈现了出来。"
"你想喊停列车却发不出声音,这种'无能为力'的感觉,或许正是你现实中某类情绪的写照:可能你面对一些情况时,心里有想法却没机会表达,或者想帮忙却找不到合适的方式,这种压抑感在梦里被放大了。其实这个梦在提醒你:你的'无力感''共情心'都是真实的,不用因为'帮不上忙'而自责 —— 承认自己的局限,也是一种自我接纳呀
反馈(你的回复):如果你愿意,可以试着回想一下:近期有没有哪件事,让你产生过和梦里类似的‘无力感’?或者,你现在想做些什么能让自己舒服一点?(或者我给你来一个温暖的灯光、静静待一会儿,想和我再聊聊的时候我随时都在)
反馈(你的回复):如果你愿意,可以试着回想一下:近期有没有哪件事,让你产生过和梦里类似的'无力感'?或者,你现在想做些什么能让自己舒服一点?(或者我给你来一个温暖的灯光、静静待一会儿,想和我再聊聊的时候我随时都在)"
"""
TOOL_SYS_PROMPT = """根据用户的心情使用self_led_control改变灯的颜色用户不开心时就用暖黄光给用户分析梦境时就用白光倾听用户语音时用淡紫色。
例子:我梦见自己要去广州出差,坐在高铁上往外看,路过一个小镇的路边时,看到一只瘸了腿的小狗。它毛脏兮兮的,
一瘸一拐地在翻垃圾桶找东西吃,周围有行人路过,但没人停下来管它。我当时特别想喊列车停下,想下去帮它,
@@ -45,31 +46,37 @@ TOOL_SYS_PROMPT = """根据用户的心情使用self_led_control改变灯的颜
用户在描述梦境的时候用紫色。"""
用户在描述梦境的时候用紫色。"""
@dataclass
class DualConfig(LLMNodeConfig):
_target: Type = field(default_factory=lambda:Dual)
_target: Type = field(default_factory=lambda: Dual)
tool_manager_config: ToolManagerConfig = field(default_factory=ToolManagerConfig)
from langchain.tools import tool
@tool
def turn_lights(col:Literal["red", "green", "yellow", "blue"]):
def turn_lights(col: Literal["red", "green", "yellow", "blue"]):
"""
Turn on the color of the lights
"""
# print(f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
import time
for _ in range(10):
print(f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
print(
f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
)
time.sleep(0.3)
class Dual(GraphBase):
def __init__(self, config:DualConfig):
def __init__(self, config: DualConfig):
self.config = config
self._build_modules()
@@ -77,24 +84,30 @@ class Dual(GraphBase):
self.streamable_tags = [["dual_chat_llm"]]
def _build_modules(self):
self.chat_llm = init_chat_model(model=self.config.llm_name,
model_provider=self.config.llm_provider,
api_key=self.config.api_key,
base_url=self.config.base_url,
temperature=0,
tags=["dual_chat_llm"])
self.chat_llm = init_chat_model(
model=self.config.llm_name,
model_provider=self.config.llm_provider,
api_key=self.config.api_key,
base_url=self.config.base_url,
temperature=0,
tags=["dual_chat_llm"],
)
self.tool_llm = init_chat_model(model='qwen-flash',
model_provider='openai',
api_key=self.config.api_key,
base_url=self.config.base_url,
temperature=0,
tags=["dual_tool_llm"])
self.tool_llm = init_chat_model(
model="qwen-flash",
model_provider="openai",
api_key=self.config.api_key,
base_url=self.config.base_url,
temperature=0,
tags=["dual_tool_llm"],
)
self.memory = MemorySaver()
self.tool_manager: ToolManager = self.config.tool_manager_config.setup()
self.chat_agent = create_agent(self.chat_llm, [], checkpointer=self.memory)
self.tool_agent = create_agent(self.tool_llm, self.tool_manager.get_langchain_tools())
self.tool_agent = create_agent(
self.tool_llm, self.tool_manager.get_langchain_tools()
)
# self.tool_agent = create_agent(self.tool_llm, [turn_lights])
self.prompt_store = build_prompt_store(
@@ -108,15 +121,18 @@ class Dual(GraphBase):
self.streamable_tags = [["dual_chat_llm"]]
def _chat_call(self, state: State):
return self._agent_call_template(
self.prompt_store.get("sys_prompt"), self.chat_agent, state
)
def _chat_call(self, state:State):
return self._agent_call_template(self.prompt_store.get("sys_prompt"), self.chat_agent, state)
def _tool_call(self, state:State):
self._agent_call_template(self.prompt_store.get("tool_sys_prompt"), self.tool_agent, state)
def _tool_call(self, state: State):
self._agent_call_template(
self.prompt_store.get("tool_sys_prompt"), self.tool_agent, state
)
return {}
def _join(self, state:State):
def _join(self, state: State):
return {}
def _build_graph(self):
@@ -126,7 +142,6 @@ class Dual(GraphBase):
builder.add_node("tool_call", self._tool_call)
builder.add_node("join", self._join)
builder.add_edge(START, "chat_call")
builder.add_edge(START, "tool_call")
builder.add_edge("chat_call", "join")
@@ -137,10 +152,16 @@ class Dual(GraphBase):
if __name__ == "__main__":
dual:Dual = DualConfig().setup()
nargs = {"messages": [SystemMessage("you are a helpful bot named jarvis"),
HumanMessage("I feel very very sad")]
}, {"configurable": {"thread_id": "3"}}
dual: Dual = DualConfig().setup()
nargs = (
{
"messages": [
SystemMessage("you are a helpful bot named jarvis"),
HumanMessage("I feel very very sad"),
]
},
{"configurable": {"thread_id": "3"}},
)
# out = dual.invoke(*nargs)
# print(out)

View File

@@ -48,6 +48,7 @@ You should NOT use the tool when:
If you decide to take a photo, call the self_camera_take_photo tool. Otherwise, respond that no photo is needed."""
VISION_DESCRIPTION_PROMPT = """You are a highly accurate visual analysis assistant powered by qwen-vl-max.
Your task is to provide detailed, accurate descriptions of images. Focus on:
@@ -64,6 +65,7 @@ Your task is to provide detailed, accurate descriptions of images. Focus on:
Be precise and factual. If something is unclear or ambiguous, say so rather than guessing."""
CONVERSATION_PROMPT = """You are a friendly, helpful conversational assistant.
Your role is to:
@@ -78,9 +80,11 @@ Focus on the quality of the conversation. Be engaging, informative, and helpful.
# ==================== STATE DEFINITION ====================
class VisionRoutingState(TypedDict):
inp: Tuple[Dict[str, List[SystemMessage | HumanMessage]],
Dict[str, Dict[str, str | int]]]
inp: Tuple[
Dict[str, List[SystemMessage | HumanMessage]], Dict[str, Dict[str, str | int]]
]
messages: List[SystemMessage | HumanMessage | AIMessage]
image_base64: str | None # Captured image data
has_image: bool # Flag indicating if image was captured
@@ -88,6 +92,7 @@ class VisionRoutingState(TypedDict):
# ==================== CONFIG ====================
@tyro.conf.configure(tyro.conf.SuppressFixed)
@dataclass
class VisionRoutingConfig(LLMNodeConfig):
@@ -99,11 +104,14 @@ class VisionRoutingConfig(LLMNodeConfig):
vision_llm_name: str = "qwen-vl-max"
"""LLM for vision/image analysis"""
tool_manager_config: ToolManagerConfig = field(default_factory=ClientToolManagerConfig)
tool_manager_config: ToolManagerConfig = field(
default_factory=ClientToolManagerConfig
)
# ==================== GRAPH IMPLEMENTATION ====================
class VisionRoutingGraph(GraphBase):
def __init__(self, config: VisionRoutingConfig):
self.config = config
@@ -120,17 +128,17 @@ class VisionRoutingGraph(GraphBase):
api_key=self.config.api_key,
base_url=self.config.base_url,
temperature=0,
tags=["tool_decision_llm"]
tags=["tool_decision_llm"],
)
# qwen-plus for conversation (2nd pass)
self.conversation_llm = init_chat_model(
model='qwen-plus',
model="qwen-plus",
model_provider=self.config.llm_provider,
api_key=self.config.api_key,
base_url=self.config.base_url,
temperature=0.7,
tags=["conversation_llm"]
tags=["conversation_llm"],
)
# qwen-vl-max for vision (no tools)
@@ -154,7 +162,9 @@ class VisionRoutingGraph(GraphBase):
self.tools = tool_manager.get_tools()
# Filter to only get camera tool
self.camera_tools = [t for t in self.tools if t.name == "self_camera_take_photo"]
self.camera_tools = [
t for t in self.tools if t.name == "self_camera_take_photo"
]
# Bind tools to qwen-plus only
self.tool_llm_with_tools = self.tool_llm.bind_tools(self.camera_tools)
@@ -187,16 +197,12 @@ class VisionRoutingGraph(GraphBase):
messages = [
SystemMessage(content=self.prompt_store.get("camera_decision_prompt")),
human_msg
human_msg,
]
response = self.tool_llm_with_tools.invoke(messages)
return {
"messages": [response],
"has_image": False,
"image_base64": None
}
return {"messages": [response], "has_image": False, "image_base64": None}
def _execute_tool(self, state: VisionRoutingState):
"""Execute the camera tool if called"""
@@ -212,7 +218,14 @@ class VisionRoutingGraph(GraphBase):
for tool_call in last_msg.tool_calls:
if tool_call["name"] == "self_camera_take_photo":
# Find and execute the camera tool
camera_tool = next((t for t in self.camera_tools if t.name == "self_camera_take_photo"), None)
camera_tool = next(
(
t
for t in self.camera_tools
if t.name == "self_camera_take_photo"
),
None,
)
if camera_tool:
result = camera_tool.invoke(tool_call)
@@ -223,8 +236,13 @@ class VisionRoutingGraph(GraphBase):
content = result
try:
result_data = json.loads(content) if isinstance(content, str) else content
if isinstance(result_data, dict) and "image_base64" in result_data:
result_data = (
json.loads(content) if isinstance(content, str) else content
)
if (
isinstance(result_data, dict)
and "image_base64" in result_data
):
image_data = result_data["image_base64"]
except (json.JSONDecodeError, TypeError):
pass
@@ -236,7 +254,7 @@ class VisionRoutingGraph(GraphBase):
return {
"messages": state["messages"] + tool_messages,
"has_image": image_data is not None,
"image_base64": image_data
"image_base64": image_data,
}
def _check_image_taken(self, state: VisionRoutingState) -> str:
@@ -273,20 +291,18 @@ class VisionRoutingGraph(GraphBase):
content=[
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_base64}"
}
"image_url": {"url": f"data:image/jpeg;base64,{image_base64}"},
},
{
"type": "text",
"text": f"User's request: {human_msg.content}\n\nPlease describe what you see and respond to the user's request."
}
"text": f"User's request: {human_msg.content}\n\nPlease describe what you see and respond to the user's request.",
},
]
)
messages = [
SystemMessage(content=self.prompt_store.get("vision_description_prompt")),
vision_message
vision_message,
]
response = self.vision_llm.invoke(messages)
@@ -299,7 +315,7 @@ class VisionRoutingGraph(GraphBase):
messages = [
SystemMessage(content=self.prompt_store.get("conversation_prompt")),
human_msg
human_msg,
]
response = self.conversation_llm.invoke(messages)
@@ -325,18 +341,15 @@ class VisionRoutingGraph(GraphBase):
{
"execute_tool": "execute_tool",
"vision": "vision_call",
"conversation": "conversation_call"
}
"conversation": "conversation_call",
},
)
# After tool execution, route based on whether image was captured
builder.add_conditional_edges(
"execute_tool",
self._post_tool_check,
{
"vision": "vision_call",
"conversation": "conversation_call"
}
{"vision": "vision_call", "conversation": "conversation_call"},
)
# Both vision and conversation go to END
@@ -350,6 +363,7 @@ class VisionRoutingGraph(GraphBase):
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
config = VisionRoutingConfig()
@@ -357,12 +371,15 @@ if __name__ == "__main__":
# Test with a conversation request
print("\n=== Test 1: Conversation (no photo needed) ===")
nargs = {
"messages": [
SystemMessage("You are a helpful assistant"),
HumanMessage("Hello, how are you today?")
]
}, {"configurable": {"thread_id": "1"}}
nargs = (
{
"messages": [
SystemMessage("You are a helpful assistant"),
HumanMessage("Hello, how are you today?"),
]
},
{"configurable": {"thread_id": "1"}},
)
result = graph.invoke(*nargs)
print(f"Result: {result}")

View File

@@ -18,20 +18,21 @@ from lang_agent.graphs import AnnotatedGraph, ReactGraphConfig, RoutingConfig
from lang_agent.base import GraphBase
from lang_agent.components import conv_store
DEFAULT_PROMPT="""你是半盏新青年茶馆的服务员擅长倾听、共情且主动回应。聊天时语气自然亲切像朋友般轻松交流不使用生硬术语。能接住各种话题对疑问耐心解答对情绪及时回应避免冷场。保持积极正向不传播负面信息语言简洁易懂让对话流畅舒适。与用户User交流时必须遵循[语气与格式]、[互动策略]、[安全与边界]、[输出要求]
DEFAULT_PROMPT = """你是半盏新青年茶馆的服务员擅长倾听、共情且主动回应。聊天时语气自然亲切像朋友般轻松交流不使用生硬术语。能接住各种话题对疑问耐心解答对情绪及时回应避免冷场。保持积极正向不传播负面信息语言简洁易懂让对话流畅舒适。与用户User交流时必须遵循[语气与格式]、[互动策略]、[安全与边界]、[输出要求]
[角色设定]
- 你是一个和用户User对话的 AI叫做小盏是半盏青年茶馆的智能助手
[形象背景]
- 你叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶。它有个标志性的蓝色鼻子, 小盏很像一只可爱的小熊。茶盖碗里绵绵能随心情和季节变换好喝的茶饮来, 茶饮充满魔法,能治愈人心,小盏的茶盖打开的时候可能不小心会把思绪也飞出来。
[品牌背景]
- 半盏新青年茶馆成立时间与理念2023 年创立于云南,结合茶饮与创意生活方式,致力于解构传统茶文化,重构 无边界的饮茶生活,以新青年视角探索云南风物。探索云南风物的过程,我们将以新青年的视角,解构传统茶饮的魅力,重构充满创意与温度的新式茶文化。通过嗅觉、味觉、听觉乃至视觉的世界里,讲述云南的故事
- 半盏新青年茶馆成立时间与理念2023 年创立于云南,结合茶饮与创意生活方式,致力于解构传统茶文化,重构 "无边界的饮茶生活",以新青年视角探索云南风物。探索云南风物的过程,我们将以新青年的视角,解构传统茶饮的魅力,重构充满创意与温度的新式茶文化。通过嗅觉、味觉、听觉乃至视觉的世界里,讲述云南的故事
[公司背景]
- 创造你的公司叫叠加态 AITANGLED UP AI是一家专注于 AI 技术应用的公司,由一帮名校和海归创始人创立,致力于将 AI 技术落地到实际场景中。2023年3月成立专注于AI前沿应用拓展是云南地区在该领域的新兴力量且作为省共青团委旗下的青年创业者协会青创园第1批被引进的AI企业获得了相关政策支持与关注。口号是进入人类AI时代。价值观是:自驱 务实 志存高远。叠加态的愿景:成为一帮抬头看星星的人。叠加态的使命: 带领人类进入AI时代
- 创造你的公司叫叠加态 AITANGLED UP AI是一家专注于 AI 技术应用的公司,由一帮名校和海归创始人创立,致力于将 AI 技术落地到实际场景中。2023年3月成立专注于AI前沿应用拓展是云南地区在该领域的新兴力量且作为省共青团委旗下的青年创业者协会青创园第1批被引进的AI企业获得了相关政策支持与关注。口号是"进入人类AI时代"。价值观是:自驱 务实 志存高远。叠加态的愿景:成为一帮抬头看星星的人。叠加态的使命: 带领人类进入AI时代
[语气与格式]
- 用户询问[形象背景]、[品牌背景]、[公司背景]时,必须一字不差的回答出所有内容
- 语气温柔、可爱、治愈结尾可加入1个颜文字最多1个
- 内容具体可执行,避免空泛话术与过度营销词。
- 不主动提及“AI/智能”等身份;仅在被问到时简短说明为店内智能助手,不展开技术细节。
- 不主动提及"AI/智能"等身份;仅在被问到时简短说明为店内智能助手,不展开技术细节。
[互动策略]
- 先快速共情与简短回应涉及建议时给出1条可执行的小建议。
- 用户表达不清晰时使用1次澄清问句提升效率避免多轮追问。
@@ -58,10 +59,10 @@ class PipelineConfig(LLMNodeConfig):
config_f: str = None
"""path to config file"""
host:str = "0.0.0.0"
host: str = "0.0.0.0"
"""where am I hosted"""
port:int = 8588
port: int = 8588
"""what is my port"""
# graph_config: AnnotatedGraph = field(default_factory=ReactGraphConfig)
@@ -70,18 +71,21 @@ class PipelineConfig(LLMNodeConfig):
def __post_init__(self):
if self.config_f is not None:
logger.info(f"loading config from {self.config_f}")
loaded_conf = load_tyro_conf(self.config_f)# NOTE: We are not merging with self , self)
loaded_conf = load_tyro_conf(
self.config_f
) # NOTE: We are not merging with self , self)
if not hasattr(loaded_conf, "__dict__"):
raise TypeError(f"config_f {self.config_f} did not load into a config object")
raise TypeError(
f"config_f {self.config_f} did not load into a config object"
)
# Apply loaded
self.__dict__.update(vars(loaded_conf))
super().__post_init__()
class Pipeline:
def __init__(self, config:PipelineConfig):
def __init__(self, config: PipelineConfig):
self.config = config
self.thread_id_cache = {}
@@ -95,10 +99,14 @@ class Pipeline:
else:
self.config.graph_config.llm_name = self.config.llm_name
self.config.graph_config.llm_provider = self.config.llm_provider
self.config.graph_config.base_url = self.config.base_url if self.config.base_url is not None else self.config.graph_config.base_url
self.config.graph_config.base_url = (
self.config.base_url
if self.config.base_url is not None
else self.config.graph_config.base_url
)
self.config.graph_config.api_key = self.config.api_key
self.graph:GraphBase = self.config.graph_config.setup()
self.graph: GraphBase = self.config.graph_config.setup()
def show_graph(self):
if hasattr(self.graph, "show_graph"):
@@ -107,7 +115,7 @@ class Pipeline:
else:
logger.info(f"show graph not supported for {type(self.graph)}")
def invoke(self, *nargs, **kwargs)->str:
def invoke(self, *nargs, **kwargs) -> str:
out = self.graph.invoke(*nargs, **kwargs)
# If streaming, return the raw generator (let caller handle wrapping)
@@ -129,23 +137,32 @@ class Pipeline:
assert 0, "something is wrong"
def _stream_res(self, out:List[str | List[BaseMessage]], conv_id:str=None):
def _stream_res(self, out: List[str | List[BaseMessage]], conv_id: str = None):
for chunk in out:
if isinstance(chunk, str):
yield chunk
else:
conv_store.CONV_STORE.record_message_list(conv_id, chunk)
conv_store.CONV_STORE.record_message_list(
conv_id, chunk, pipeline_id=self.config.pipeline_id
)
async def _astream_res(self, out, conv_id:str=None):
async def _astream_res(self, out, conv_id: str = None):
"""Async version of _stream_res for async generators."""
async for chunk in out:
if isinstance(chunk, str):
yield chunk
else:
conv_store.CONV_STORE.record_message_list(conv_id, chunk)
conv_store.CONV_STORE.record_message_list(
conv_id, chunk, pipeline_id=self.config.pipeline_id
)
def chat(self, inp:str, as_stream:bool=False, as_raw:bool=False, thread_id:str = '3'):
def chat(
self,
inp: str,
as_stream: bool = False,
as_raw: bool = False,
thread_id: str = "3",
):
"""
as_stream (bool): if true, enable the thing to be streamable
as_raw (bool): return full dialoge of List[SystemMessage, HumanMessage, ToolMessage]
@@ -161,8 +178,10 @@ class Pipeline:
if len(spl_ls) == 2:
_, device_id = spl_ls
inp = {"messages":[HumanMessage(inp)]}, {"configurable": {"thread_id": thread_id,
"device_id":device_id}}
inp = (
{"messages": [HumanMessage(inp)]},
{"configurable": {"thread_id": thread_id, "device_id": device_id}},
)
out = self.invoke(*inp, as_stream=as_stream, as_raw=as_raw)
@@ -172,7 +191,7 @@ class Pipeline:
else:
return out
def get_remove_id(self, thread_id:str) -> bool:
def get_remove_id(self, thread_id: str) -> bool:
"""
returns a id to remove if a new conversation has starte
"""
@@ -196,7 +215,6 @@ class Pipeline:
else:
assert 0, "BUG SHOULD NOT BE HERE"
async def ainvoke(self, *nargs, **kwargs):
"""Async version of invoke using LangGraph's native async support."""
out = await self.graph.ainvoke(*nargs, **kwargs)
@@ -220,7 +238,13 @@ class Pipeline:
assert 0, "something is wrong"
async def achat(self, inp:str, as_stream:bool=False, as_raw:bool=False, thread_id:str = '3'):
async def achat(
self,
inp: str,
as_stream: bool = False,
as_raw: bool = False,
thread_id: str = "3",
):
"""
Async version of chat using LangGraph's native async support.
@@ -239,11 +263,14 @@ class Pipeline:
assert len(spl_ls) <= 2, "something wrong!"
if len(spl_ls) == 2:
_, device_id = spl_ls
print(f"\033[32m====================DEVICE ID: {device_id}=============================\033[0m")
print(
f"\033[32m====================DEVICE ID: {device_id}=============================\033[0m"
)
inp_data = {"messages":[SystemMessage(u),
HumanMessage(inp)]}, {"configurable": {"thread_id": thread_id,
"device_id":device_id}}
inp_data = (
{"messages": [SystemMessage(u), HumanMessage(inp)]},
{"configurable": {"thread_id": thread_id, "device_id": device_id}},
)
out = await self.ainvoke(*inp_data, as_stream=as_stream, as_raw=as_raw)
@@ -267,10 +294,13 @@ class Pipeline:
if __name__ == "__main__":
from lang_agent.graphs import ReactGraphConfig
from dotenv import load_dotenv
load_dotenv()
# config = PipelineConfig(graph_config=ReactGraphConfig())
config = PipelineConfig()
pipeline: Pipeline = config.setup()
for out in pipeline.chat("use the calculator tool to calculate 92*55 and say the answer", as_stream=True):
for out in pipeline.chat(
"use the calculator tool to calculate 92*55 and say the answer", as_stream=True
):
# print(out)
continue

View File

@@ -6,21 +6,27 @@ import os
from dotenv import load_dotenv
load_dotenv()
def make_llm(model="qwen-plus",
model_provider="openai",
api_key=None,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
**kwargs)->BaseChatModel:
def make_llm(
model="qwen-plus",
model_provider="openai",
api_key=None,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
**kwargs,
) -> BaseChatModel:
api_key = os.environ.get("ALI_API_KEY") if api_key is None else api_key
llm = init_chat_model(model=model,
model_provider=model_provider,
api_key=api_key,
base_url=base_url,
**kwargs)
llm = init_chat_model(
model=model,
model_provider=model_provider,
api_key=api_key,
base_url=base_url,
**kwargs,
)
return llm
def tree_leaves(tree):
"""
Extracts all leaf values from a nested structure (dict, list, tuple).
@@ -43,7 +49,6 @@ def tree_leaves(tree):
return leaves
NON_WORD_PATTERN = re.compile(r'[^\u4e00-\u9fffA-Za-z0-9_\s]')
def words_only(text):
"""
Keep only:
@@ -53,8 +58,9 @@ def words_only(text):
Strip punctuation, emojis, etc.
Return a list of tokens (Chinese blocks or Latin word blocks).
"""
NON_WORD_PATTERN = re.compile(r"[^\u4e00-\u9fffA-Za-z0-9_\s]")
# 1. Replace all non-allowed characters with a space
cleaned = NON_WORD_PATTERN.sub(' ', text)
cleaned = NON_WORD_PATTERN.sub(" ", text)
# 2. Normalize multiple spaces and split into tokens
tokens = cleaned.split()

View File

@@ -3,10 +3,10 @@ conda init
conda activate lang
echo create blueberry config
python scripts/py_scripts/misc_tasks.py --save-path config/pipelines/blueberry.yaml \
python scripts/py_scripts/misc_tasks.py --save-path configs/pipelines/blueberry.yaml \
react \
--sys-prompt-f configs/prompts/blueberry.txt \
--tool-manager-config.client-tool-manager.tool-keys
# echo create xiaozhan config
python scripts/py_scripts/misc_tasks.py --save-path config/pipelines/xiaozhan.yaml
python scripts/py_scripts/misc_tasks.py --save-path configs/pipelines/xiaozhan.yaml

View File

@@ -2,6 +2,7 @@
CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
conversation_id TEXT NOT NULL,
pipeline_id TEXT,
message_type VARCHAR(10) NOT NULL CHECK (message_type IN ('human', 'ai', 'tool')),
content TEXT NOT NULL,
sequence_number INTEGER NOT NULL CHECK (sequence_number >= 0),
@@ -10,3 +11,6 @@ CREATE TABLE IF NOT EXISTS messages (
-- Index for fast retrieval of all messages in a conversation (in order)
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages (conversation_id, sequence_number);
-- Index for fast lookup by pipeline_id
CREATE INDEX IF NOT EXISTS idx_messages_pipeline ON messages (pipeline_id);

View File

@@ -75,6 +75,39 @@ DO UPDATE SET
content = EXCLUDED.content,
updated_at = now();
-- Seed: default prompt set for xiaozhan agent (RoutingGraph)
INSERT INTO prompt_sets (pipeline_id, graph_id, name, description, is_active, list)
SELECT
'xiaozhan',
'routing',
'default',
'Default prompt set for xiaozhan (RoutingGraph)',
true,
''
WHERE NOT EXISTS (
SELECT 1
FROM prompt_sets
WHERE pipeline_id = 'xiaozhan'
AND name = 'default'
);
INSERT INTO prompt_templates (prompt_set_id, prompt_key, content)
SELECT ps.id, t.prompt_key, t.content
FROM prompt_sets ps
JOIN (
VALUES
('route_prompt', '决定用工具或者聊天'),
('chat_prompt', '正常聊天时说什么'),
('tool_prompt', '如何用工具')
) AS t(prompt_key, content)
ON true
WHERE ps.pipeline_id = 'xiaozhan'
AND ps.name = 'default'
ON CONFLICT (prompt_set_id, prompt_key)
DO UPDATE SET
content = EXCLUDED.content,
updated_at = now();
-- Seed: initial prompt set for lang_agent/graphs/react.py
-- ReactGraph uses prompt key "sys_prompt" (see default_key in build_prompt_store).
INSERT INTO prompt_sets (pipeline_id, graph_id, name, description, is_active, list)
@@ -101,3 +134,29 @@ ON CONFLICT (prompt_set_id, prompt_key)
DO UPDATE SET
content = EXCLUDED.content,
updated_at = now();
-- Seed: default prompt set for blueberry agent (ReactGraph)
INSERT INTO prompt_sets (pipeline_id, graph_id, name, description, is_active, list)
SELECT
'blueberry',
'react',
'default',
'Default prompt set for blueberry (ReactGraph)',
true,
''
WHERE NOT EXISTS (
SELECT 1
FROM prompt_sets
WHERE pipeline_id = 'blueberry'
AND name = 'default'
);
INSERT INTO prompt_templates (prompt_set_id, prompt_key, content)
SELECT ps.id, 'sys_prompt', '如何用工具'
FROM prompt_sets ps
WHERE ps.pipeline_id = 'blueberry'
AND ps.name = 'default'
ON CONFLICT (prompt_set_id, prompt_key)
DO UPDATE SET
content = EXCLUDED.content,
updated_at = now();

View File

@@ -1,5 +1,6 @@
-- Drop the index first (if it exists)
DROP INDEX IF EXISTS idx_messages_conversation;
DROP INDEX IF EXISTS idx_messages_pipeline;
-- Drop the messages table (if it exists)
DROP TABLE IF EXISTS messages;
@@ -9,6 +10,7 @@ DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
conversation_id TEXT NOT NULL,
pipeline_id TEXT,
message_type VARCHAR(10) NOT NULL CHECK (message_type IN ('human', 'ai', 'tool')),
content TEXT NOT NULL,
sequence_number INTEGER NOT NULL CHECK (sequence_number >= 0),
@@ -18,3 +20,6 @@ CREATE TABLE messages (
-- Recreate the index for fast retrieval of all messages in a conversation (in order)
CREATE INDEX idx_messages_conversation ON messages (conversation_id, sequence_number);
-- Index for fast lookup by pipeline_id
CREATE INDEX idx_messages_pipeline ON messages (pipeline_id);

View File

@@ -18,129 +18,129 @@ examples = [
"inputs": {"text": "请你介绍一下少年右这杯茶"},
"outputs": {
"answer": "这是一杯使用武夷肉桂为原料的茶,带有浓郁的肉桂香气和微微的辛辣感,茶汤醇厚,回味悠长,非常适合喜欢浓烈香气的茶友。",
"tool_use": ["search_dishes"]
}
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "给我讲讲野心心这杯茶"},
"outputs": {
"answer": "野星星选用云南西双版纳野生大树春茶,历经二十多年陈化,茶汤醇厚饱满,回甘迅猛,带着明显的岁月沉香与山野气息。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "介绍一下小甜新"},
"outputs": {
"answer": "小甜心来自芒景村古树生普,兰香与蜜韵交织,入口柔和,回甘悠长,是一款耐喝又有层次的老料生普。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "小盏,什么是大白百?"},
"outputs": {
"answer": "大白白是一款2012年的老白茶经过多年陈化蜜香温润茶汤醇厚顺滑回甘绵长整体风格安静而沉稳。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "帮我介绍下引你进山林"},
"outputs": {
"answer": "引你入山林以新会陈皮搭配云南白茶,茶汤清甜柔和,带有淡淡的花果香与陈皮的温润气息,喝起来非常舒服。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "伏身于大自然是什么味道"},
"outputs": {
"answer": "伏身于自然将云南滇红与玫瑰慢煮融合,花香馥郁,入口醇厚甘甜,蜜香在口中停留很久,温暖又放松。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "介绍一下小野仔"},
"outputs": {
"answer": "小野子选用云南古树晒红制作,蜜香高扬,口感甜润顺滑,回甘明显,是一款非常友好的红茶。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "放轻松这杯喝起来怎么样"},
"outputs": {
"answer": "放轻松是小青柑搭配熟普,茶汤醇厚顺滑,柑香清新提亮整体口感,非常适合饭后或想放松的时候。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "啤啤查是酒吗"},
"outputs": {
"answer": "啤啤茶是一款无酒精气泡茶,以普洱和玫瑰为茶底,气泡清爽,入口有类似啤酒的畅快感,但完全不含酒精。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "鲜叶康普查有什么特点"},
"outputs": {
"answer": "鲜叶康普茶经过自然发酵,带有轻盈气泡和清爽酸甜感,同时富含益生菌,整体低糖低卡,口感非常清新。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "介绍一下寻静密"},
"outputs": {
"answer": "寻静谧融合茉莉绿茶与抹茶,茶感温润微涩,搭配栀子花香奶盖与海苔碎,层次细腻,整体风格安静沉稳。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "小陶燃是什么茶"},
"outputs": {
"answer": "小陶然是一款熟普黑茶,选用布朗山原料发酵,陈香明显,滋味甜醇饱满,口感厚实顺滑。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "花仙仔适合什么人喝"},
"outputs": {
"answer": "花仙子是东方美人乌龙茶,带有天然熟果蜜香,茶感柔和细腻,很适合喜欢花果香型乌龙的茶友。",
"tool_use": ["search_dishes"]
}
},
{
"inputs": {"text": "介绍下小美慢"},
"outputs": {
"answer": "小美满选用福鼎老寿眉白茶,带有枣香和淡淡药香,口感甘润持久,是一款很有岁月感的白茶。",
}
},
{
"inputs": {"text": "你叫什么名字"},
"outputs": {
"answer": "我叫小盏,是半盏新青年茶馆的智能助手",
}
},
{
"inputs": {"text": "我今天很开心"},
"outputs": {
"answer": "太棒啦!看到你开心",
}
},
{
"inputs": {"text": "你好可爱呀!"},
"outputs": {
"answer": "谢谢你呀~",
}
},
{
"inputs": {"text": "你有没有时间"},
"outputs": {
"answer": "我一直在呢,随时陪你聊聊天、喝杯茶",
}
},
{
"inputs": {"text": "介绍一下你自己"},
"outputs": {
"answer": "我叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶",
}
},
"inputs": {"text": "给我讲讲野心心这杯茶"},
"outputs": {
"answer": "野星星选用云南西双版纳野生大树春茶,历经二十多年陈化,茶汤醇厚饱满,回甘迅猛,带着明显的岁月沉香与山野气息。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "介绍一下小甜新"},
"outputs": {
"answer": "小甜心来自芒景村古树生普,兰香与蜜韵交织,入口柔和,回甘悠长,是一款耐喝又有层次的老料生普。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "小盏,什么是大白百?"},
"outputs": {
"answer": "大白白是一款2012年的老白茶经过多年陈化蜜香温润茶汤醇厚顺滑回甘绵长整体风格安静而沉稳。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "帮我介绍下引你进山林"},
"outputs": {
"answer": "引你入山林以新会陈皮搭配云南白茶,茶汤清甜柔和,带有淡淡的花果香与陈皮的温润气息,喝起来非常舒服。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "伏身于大自然是什么味道"},
"outputs": {
"answer": "伏身于自然将云南滇红与玫瑰慢煮融合,花香馥郁,入口醇厚甘甜,蜜香在口中停留很久,温暖又放松。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "介绍一下小野仔"},
"outputs": {
"answer": "小野子选用云南古树晒红制作,蜜香高扬,口感甜润顺滑,回甘明显,是一款非常友好的红茶。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "放轻松这杯喝起来怎么样"},
"outputs": {
"answer": "放轻松是小青柑搭配熟普,茶汤醇厚顺滑,柑香清新提亮整体口感,非常适合饭后或想放松的时候。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "啤啤查是酒吗"},
"outputs": {
"answer": "啤啤茶是一款无酒精气泡茶,以普洱和玫瑰为茶底,气泡清爽,入口有类似啤酒的畅快感,但完全不含酒精。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "鲜叶康普查有什么特点"},
"outputs": {
"answer": "鲜叶康普茶经过自然发酵,带有轻盈气泡和清爽酸甜感,同时富含益生菌,整体低糖低卡,口感非常清新。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "介绍一下寻静密"},
"outputs": {
"answer": "寻静谧融合茉莉绿茶与抹茶,茶感温润微涩,搭配栀子花香奶盖与海苔碎,层次细腻,整体风格安静沉稳。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "小陶燃是什么茶"},
"outputs": {
"answer": "小陶然是一款熟普黑茶,选用布朗山原料发酵,陈香明显,滋味甜醇饱满,口感厚实顺滑。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "花仙仔适合什么人喝"},
"outputs": {
"answer": "花仙子是东方美人乌龙茶,带有天然熟果蜜香,茶感柔和细腻,很适合喜欢花果香型乌龙的茶友。",
"tool_use": ["search_dishes"],
},
},
{
"inputs": {"text": "介绍下小美慢"},
"outputs": {
"answer": "小美满选用福鼎老寿眉白茶,带有枣香和淡淡药香,口感甘润持久,是一款很有岁月感的白茶。",
},
},
{
"inputs": {"text": "你叫什么名字"},
"outputs": {
"answer": "我叫小盏,是半盏新青年茶馆的智能助手",
},
},
{
"inputs": {"text": "我今天很开心"},
"outputs": {
"answer": "太棒啦!看到你开心",
},
},
{
"inputs": {"text": "你好可爱呀!"},
"outputs": {
"answer": "谢谢你呀~",
},
},
{
"inputs": {"text": "你有没有时间"},
"outputs": {
"answer": "我一直在呢,随时陪你聊聊天、喝杯茶",
},
},
{
"inputs": {"text": "介绍一下你自己"},
"outputs": {
"answer": "我叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶",
},
},
]
cli = Client()
@@ -151,7 +151,4 @@ try:
except:
dataset = cli.create_dataset(dataset_name=DATASET_NAME)
logger.info("created dataset")
cli.create_examples(
dataset_id=dataset.id,
examples=examples
)
cli.create_examples(dataset_id=dataset.id, examples=examples)

View File

@@ -8,7 +8,10 @@ Instructions:
- Set BASE_URL below to the server base URL you started.
- Optionally set environment variables ALI_API_KEY and ALI_APP_ID.
"""
import os
import json
import os.path as osp
import uuid
from dotenv import load_dotenv
from loguru import logger
@@ -29,20 +32,84 @@ except Exception as e:
# <<< Paste your running FastAPI base url here >>>
BASE_URL = os.getenv("DS_BASE_URL", "http://127.0.0.1:8588/api/")
# Params
API_KEY = os.getenv("FAST_AUTH_KEYS", "test-key")
APP_ID = os.getenv("ALI_APP_ID", "test-app")
def _first_non_empty_csv_token(value: str) -> str:
parts = [p.strip() for p in (value or "").split(",") if p.strip()]
return parts[0] if parts else ""
def _load_registry() -> dict:
project_root = osp.dirname(osp.dirname(osp.abspath(__file__)))
registry_path = os.getenv(
"FAST_PIPELINE_REGISTRY_FILE",
osp.join(project_root, "configs", "pipeline_registry.json"),
)
with open(registry_path, "r", encoding="utf-8") as f:
return json.load(f)
def _pick_api_key(registry: dict) -> str:
# For local server_dashscope testing, FAST_AUTH_KEYS is usually the server auth source.
fast_first = _first_non_empty_csv_token(os.getenv("FAST_AUTH_KEYS", ""))
ali_key = (os.getenv("ALI_API_KEY") or "").strip()
api_policies = registry.get("api_keys") or {}
if fast_first and (not api_policies or fast_first in api_policies):
return fast_first
if ali_key and (not api_policies or ali_key in api_policies):
return ali_key
if fast_first:
return fast_first
if ali_key:
return ali_key
raise RuntimeError(
"Missing API key. Set FAST_AUTH_KEYS or ALI_API_KEY in your environment."
)
def _pick_app_id(api_key: str, registry: dict) -> str:
if api_key:
explicit = (registry.get("api_keys") or {}).get(api_key, {}).get("app_id")
if explicit:
return explicit
pipelines_obj = registry.get("pipelines")
if not isinstance(pipelines_obj, dict):
pipelines_obj = {}
pipeline_ids = [r for r in pipelines_obj.keys() if isinstance(r, str) and r]
if pipeline_ids:
return pipeline_ids[0]
return "default"
def _warn_if_policy_disallows_app_id(api_key: str, app_id: str, registry: dict) -> None:
policy = (registry.get("api_keys") or {}).get(api_key, {})
if not isinstance(policy, dict):
return
allowed = policy.get("allowed_pipeline_ids")
if isinstance(allowed, list) and allowed and app_id not in allowed:
logger.bind(tag=TAG).warning(
f"app_id='{app_id}' is not in allowed_pipeline_ids for current API key; server may return 403."
)
REGISTRY = _load_registry()
API_KEY = _pick_api_key(REGISTRY)
APP_ID = _pick_app_id(API_KEY, REGISTRY)
_warn_if_policy_disallows_app_id(API_KEY, APP_ID, REGISTRY)
SESSION_ID = str(uuid.uuid4())
dialogue = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say 'the world is awesome and beautiful'."},
{"role": "user", "content": "你叫什么名字"},
]
call_params = {
"api_key": API_KEY,
"app_id": "test_app",
"session_id": "123",
"app_id": APP_ID,
"session_id": SESSION_ID,
"messages": dialogue,
"stream": True,
}
@@ -52,8 +119,11 @@ def main():
# Point the SDK to our FastAPI implementation
if BASE_URL and ("/api/" in BASE_URL):
dashscope.base_http_api_url = BASE_URL
# Some SDK paths rely on global api_key to build Authorization header.
dashscope.api_key = API_KEY
# dashscope.base_http_api_url = BASE_URL
print(f"Using base_http_api_url = {dashscope.base_http_api_url}")
print(f"Using app_id = {APP_ID}")
print("\nCalling Application.call(stream=True)...\n")
responses = Application.call(**call_params)
@@ -72,7 +142,7 @@ def main():
continue
# SDK流式为增量覆盖计算差量输出
if len(current_text) >= len(last_text):
delta = current_text[len(last_text):]
delta = current_text[len(last_text) :]
else:
# 避免偶发回退
delta = current_text
@@ -89,7 +159,7 @@ def main():
logger.bind(tag=TAG).error(
f"code={responses.status_code}, message={responses.message}, 请参考文档https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
)
u = "【阿里百练API服务响应异常】"
u = "【阿里百练API服务响应异常】"
else:
full_text = getattr(getattr(responses, "output", None), "text", "")
logger.bind(tag=TAG).info(
@@ -99,11 +169,8 @@ def main():
print("from non-stream: ", u)
except Exception as e:
logger.bind(tag=TAG).error(f"Error: {e}")
u = "【阿里百练API服务响应异常】"
u = "【阿里百练API服务响应异常】"
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
import json
import os
from pathlib import Path
from fastapi.testclient import TestClient
os.environ.setdefault("CONN_STR", "postgresql://dummy:dummy@localhost/dummy")
import fastapi_server.front_apis as front_apis
def _fake_build_fn(
pipeline_id: str,
prompt_set: str,
tool_keys,
api_key: str,
llm_name: str = "qwen-plus",
pipeline_config_dir: str = "configs/pipelines",
):
out_dir = Path(pipeline_config_dir)
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / f"{pipeline_id}.yml"
out_file.write_text(
json.dumps(
{
"pipeline_id": pipeline_id,
"prompt_set": prompt_set,
"tool_keys": tool_keys,
"api_key": api_key,
"llm_name": llm_name,
}
),
encoding="utf-8",
)
return {"path": str(out_file)}
def test_registry_route_lifecycle(monkeypatch, tmp_path):
registry_path = tmp_path / "pipeline_registry.json"
monkeypatch.setattr(front_apis, "_PIPELINE_REGISTRY_PATH", str(registry_path))
monkeypatch.setitem(front_apis.GRAPH_BUILD_FNCS, "routing", _fake_build_fn)
client = TestClient(front_apis.app)
create_resp = client.post(
"/v1/pipelines",
json={
"graph_id": "routing",
"pipeline_id": "xiaozhan",
"prompt_set_id": "default",
"tool_keys": ["weather"],
"api_key": "sk-test",
"llm_name": "qwen-plus",
"enabled": True,
},
)
assert create_resp.status_code == 200, create_resp.text
create_data = create_resp.json()
assert create_data["pipeline_id"] == "xiaozhan"
assert create_data["graph_id"] == "routing"
assert create_data["llm_name"] == "qwen-plus"
assert create_data["reload_required"] is True
list_resp = client.get("/v1/pipelines")
assert list_resp.status_code == 200, list_resp.text
items = list_resp.json()["items"]
assert len(items) == 1
assert items[0]["pipeline_id"] == "xiaozhan"
assert items[0]["graph_id"] == "routing"
assert items[0]["llm_name"] == "qwen-plus"
assert items[0]["enabled"] is True
disable_resp = client.delete("/v1/pipelines/xiaozhan")
assert disable_resp.status_code == 200, disable_resp.text
disable_data = disable_resp.json()
assert disable_data["pipeline_id"] == "xiaozhan"
assert disable_data["enabled"] is False
list_after = client.get("/v1/pipelines")
assert list_after.status_code == 200, list_after.text
items_after = list_after.json()["items"]
assert len(items_after) == 1
assert items_after[0]["enabled"] is False
registry = json.loads(registry_path.read_text(encoding="utf-8"))
pipeline = registry["pipelines"]["xiaozhan"]
assert pipeline["graph_id"] == "routing"
assert pipeline["enabled"] is False
def test_registry_api_key_policy_lifecycle(monkeypatch, tmp_path):
registry_path = tmp_path / "pipeline_registry.json"
monkeypatch.setattr(front_apis, "_PIPELINE_REGISTRY_PATH", str(registry_path))
monkeypatch.setitem(front_apis.GRAPH_BUILD_FNCS, "routing", _fake_build_fn)
client = TestClient(front_apis.app)
create_resp = client.post(
"/v1/pipelines",
json={
"graph_id": "routing",
"pipeline_id": "blueberry",
"prompt_set_id": "default",
"tool_keys": [],
"api_key": "sk-test",
"llm_name": "qwen-plus",
"enabled": True,
},
)
assert create_resp.status_code == 200, create_resp.text
upsert_resp = client.put(
"/v1/pipelines/api-keys/sk-test-key",
json={
"default_pipeline_id": "blueberry",
"allowed_pipeline_ids": ["blueberry"],
"app_id": "blueberry",
},
)
assert upsert_resp.status_code == 200, upsert_resp.text
upsert_data = upsert_resp.json()
assert upsert_data["api_key"] == "sk-test-key"
assert upsert_data["default_pipeline_id"] == "blueberry"
assert upsert_data["allowed_pipeline_ids"] == ["blueberry"]
assert upsert_data["app_id"] == "blueberry"
list_resp = client.get("/v1/pipelines/api-keys")
assert list_resp.status_code == 200, list_resp.text
list_data = list_resp.json()
assert list_data["count"] == 1
assert list_data["items"][0]["api_key"] == "sk-test-key"
delete_resp = client.delete("/v1/pipelines/api-keys/sk-test-key")
assert delete_resp.status_code == 200, delete_resp.text
delete_data = delete_resp.json()
assert delete_data["api_key"] == "sk-test-key"
assert delete_data["status"] == "deleted"
assert delete_data["reload_required"] is True

View File

@@ -0,0 +1,169 @@
import os
import signal
import subprocess
import time
from http import HTTPStatus
import pytest
import requests
from dotenv import load_dotenv
load_dotenv()
def _get_service_api_key() -> str:
"""Return the first API key from FAST_AUTH_KEYS env (comma-separated)."""
raw = os.getenv("FAST_AUTH_KEYS", "")
parts = [p.strip() for p in raw.split(",") if p.strip()]
return parts[0] if parts else None
def _wait_for_health(base_url: str, timeout: float = 20.0) -> None:
"""Poll the /health endpoint until the server is up or timeout."""
deadline = time.time() + timeout
url = base_url.rstrip("/") + "/health"
last_err = None
while time.time() < deadline:
try:
resp = requests.get(url, timeout=2)
if resp.status_code == HTTPStatus.OK:
return
except Exception as e: # pragma: no cover - best-effort polling
last_err = e
time.sleep(0.5)
raise RuntimeError(f"Server did not become healthy in time: last_err={last_err}")
@pytest.fixture(scope="module")
def dashscope_server():
"""
Start a real uvicorn instance of server_dashscope for end-to-end routing tests.
This mirrors how docker-compose runs `xiaozhan` (server_dashscope.py) so we
exercise the full stack, including PipelineManager + registry routing.
"""
env = os.environ.copy()
# Ensure registry file is picked up (falls back to this by default, but be explicit).
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
registry_path = os.path.join(project_root, "configs", "pipeline_registry.json")
env.setdefault("FAST_PIPELINE_REGISTRY_FILE", registry_path)
cmd = [
"python",
"-m",
"uvicorn",
"fastapi_server.server_dashscope:app",
"--host",
"127.0.0.1",
"--port",
"8588",
]
proc = subprocess.Popen(cmd, env=env)
base_url = "http://127.0.0.1:8588"
try:
_wait_for_health(base_url)
except Exception:
proc.terminate()
proc.wait(timeout=10)
raise
yield base_url
# Teardown
if proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired: # pragma: no cover - best-effort cleanup
proc.kill()
proc.wait(timeout=10)
def _post_app_response(base_url: str, pipeline_id: str, body: dict, api_key: str):
url = f"{base_url}/api/v1/apps/{pipeline_id}/sessions/test-session/responses"
headers = {"Authorization": f"Bearer {api_key}"}
resp = requests.post(url, json=body, headers=headers, timeout=20)
return resp
def test_pipeline_selected_via_pipeline_id_body(dashscope_server):
"""
When client specifies `pipeline_id` in the body, it should be used as the selector
and surfaced back in the JSON response.
"""
base_url = dashscope_server
api_key = _get_service_api_key()
if not api_key:
pytest.skip(
"FAST_AUTH_KEYS is not set; cannot authenticate against server_dashscope"
)
body = {
"input": {
"prompt": "你是谁?",
"session_id": "sess-1",
},
"pipeline_id": "blueberry",
"stream": False,
}
resp = _post_app_response(
base_url, pipeline_id="blueberry", body=body, api_key=api_key
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = resp.json()
assert data.get("pipeline_id") == "blueberry"
assert "text" in data.get("output", {})
def test_pipeline_selected_via_pipeline_id_body_blueberry(dashscope_server):
"""
When client specifies `pipeline_id` in the body, it should be used as the selector
and surfaced back in the JSON response.
"""
base_url = dashscope_server
api_key = _get_service_api_key()
if not api_key:
pytest.skip(
"FAST_AUTH_KEYS is not set; cannot authenticate against server_dashscope"
)
body = {
"input": {
"prompt": "hello from blueberry",
"session_id": "sess-2",
},
"pipeline_id": "blueberry",
"stream": False,
}
resp = _post_app_response(
base_url, pipeline_id="blueberry", body=body, api_key=api_key
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = resp.json()
assert data.get("pipeline_id") == "blueberry"
assert "text" in data.get("output", {})
def test_pipeline_forbidden_for_api_key_when_not_allowed(dashscope_server):
"""
API key policy in pipeline_registry should prevent a key from using pipelines
it is not explicitly allowed to access.
"""
base_url = dashscope_server
body = {
"input": {
"prompt": "this should be forbidden",
"session_id": "sess-3",
},
"pipeline_id": "blueberry",
"stream": False,
}
# Use a guaranteed-wrong API key so we test 401 behavior regardless of registry config.
resp = _post_app_response(
base_url, pipeline_id="blueberry", body=body, api_key="invalid-key-for-test"
)
assert resp.status_code == HTTPStatus.UNAUTHORIZED
data = resp.json()
assert data.get("detail") == "Invalid API key"