Compare commits
34 Commits
728d5934d7
...
f58d80ac1b
| Author | SHA1 | Date | |
|---|---|---|---|
| f58d80ac1b | |||
| 9e3fecd4bd | |||
| 9b128ae41b | |||
| 61931cad58 | |||
| b070d08130 | |||
| fe87bb55a1 | |||
| d455933e9c | |||
| f6547ca6fe | |||
| 9c0744e1bc | |||
| 2f40f1c526 | |||
| cf1cae51f7 | |||
| 91685d5bf7 | |||
| 6ed33f3185 | |||
| 9284e8f928 | |||
| bb6d98c9f4 | |||
| 9d1eeaeec5 | |||
| 6c26f92ff1 | |||
| e3703d962d | |||
| 9470b7e342 | |||
| 94b24682e2 | |||
| 2ce0075dcd | |||
| 431aea410b | |||
| 46978895d1 | |||
| 501f9954ce | |||
| 021b4d6ffb | |||
| 16916e183b | |||
| 6097e9c24a | |||
| 11a7809fc0 | |||
| e85d0e7771 | |||
| 26fcdacbe8 | |||
| 6b0976f263 | |||
| 5f7cd64c11 | |||
| 4a713190b7 | |||
| bda75d2558 |
24
configs/pipeline_registry.json
Normal file
24
configs/pipeline_registry.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Any
|
||||||
import commentjson
|
import commentjson
|
||||||
import os
|
import os
|
||||||
import os.path as osp
|
import os.path as osp
|
||||||
import secrets
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import json
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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__))))
|
sys.path.append(osp.dirname(osp.dirname(osp.abspath(__file__))))
|
||||||
|
|
||||||
from lang_agent.config.db_config_manager import DBConfigManager
|
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):
|
class GraphConfigUpsertRequest(BaseModel):
|
||||||
graph_id: str
|
graph_id: str
|
||||||
@@ -29,6 +33,7 @@ class GraphConfigUpsertRequest(BaseModel):
|
|||||||
prompt_dict: Dict[str, str] = Field(default_factory=dict)
|
prompt_dict: Dict[str, str] = Field(default_factory=dict)
|
||||||
api_key: Optional[str] = Field(default=None)
|
api_key: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class GraphConfigUpsertResponse(BaseModel):
|
class GraphConfigUpsertResponse(BaseModel):
|
||||||
graph_id: str
|
graph_id: str
|
||||||
pipeline_id: str
|
pipeline_id: str
|
||||||
@@ -37,6 +42,7 @@ class GraphConfigUpsertResponse(BaseModel):
|
|||||||
prompt_keys: List[str]
|
prompt_keys: List[str]
|
||||||
api_key: str
|
api_key: str
|
||||||
|
|
||||||
|
|
||||||
class GraphConfigReadResponse(BaseModel):
|
class GraphConfigReadResponse(BaseModel):
|
||||||
graph_id: Optional[str] = Field(default=None)
|
graph_id: Optional[str] = Field(default=None)
|
||||||
pipeline_id: str
|
pipeline_id: str
|
||||||
@@ -45,6 +51,7 @@ class GraphConfigReadResponse(BaseModel):
|
|||||||
prompt_dict: Dict[str, str]
|
prompt_dict: Dict[str, str]
|
||||||
api_key: str = Field(default="")
|
api_key: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
class GraphConfigListItem(BaseModel):
|
class GraphConfigListItem(BaseModel):
|
||||||
graph_id: Optional[str] = Field(default=None)
|
graph_id: Optional[str] = Field(default=None)
|
||||||
pipeline_id: str
|
pipeline_id: str
|
||||||
@@ -57,10 +64,12 @@ class GraphConfigListItem(BaseModel):
|
|||||||
created_at: Optional[str] = Field(default=None)
|
created_at: Optional[str] = Field(default=None)
|
||||||
updated_at: Optional[str] = Field(default=None)
|
updated_at: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class GraphConfigListResponse(BaseModel):
|
class GraphConfigListResponse(BaseModel):
|
||||||
items: List[GraphConfigListItem]
|
items: List[GraphConfigListItem]
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
class PipelineCreateRequest(BaseModel):
|
class PipelineCreateRequest(BaseModel):
|
||||||
graph_id: str = Field(
|
graph_id: str = Field(
|
||||||
description="Graph key from GRAPH_BUILD_FNCS, e.g. routing or react"
|
description="Graph key from GRAPH_BUILD_FNCS, e.g. routing or react"
|
||||||
@@ -68,52 +77,77 @@ class PipelineCreateRequest(BaseModel):
|
|||||||
pipeline_id: str
|
pipeline_id: str
|
||||||
prompt_set_id: str
|
prompt_set_id: str
|
||||||
tool_keys: List[str] = Field(default_factory=list)
|
tool_keys: List[str] = Field(default_factory=list)
|
||||||
port: int
|
api_key: Optional[str] = Field(default=None)
|
||||||
api_key: str
|
|
||||||
entry_point: str = Field(default="fastapi_server/server_dashscope.py")
|
|
||||||
llm_name: str = Field(default="qwen-plus")
|
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):
|
class PipelineCreateResponse(BaseModel):
|
||||||
run_id: str
|
|
||||||
pid: int
|
|
||||||
graph_id: str
|
|
||||||
pipeline_id: str
|
pipeline_id: str
|
||||||
prompt_set_id: str
|
prompt_set_id: str
|
||||||
url: str
|
graph_id: str
|
||||||
port: int
|
config_file: str
|
||||||
auth_type: str
|
llm_name: str
|
||||||
auth_header_name: str
|
enabled: bool
|
||||||
auth_key_once: str
|
reload_required: bool
|
||||||
auth_key_masked: str
|
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):
|
class PipelineListResponse(BaseModel):
|
||||||
items: List[PipelineRunInfo]
|
items: List[PipelineSpec]
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
class PipelineStopResponse(BaseModel):
|
class PipelineStopResponse(BaseModel):
|
||||||
run_id: str
|
pipeline_id: str
|
||||||
status: 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):
|
class McpConfigReadResponse(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
raw_content: str
|
raw_content: str
|
||||||
tool_keys: List[str]
|
tool_keys: List[str]
|
||||||
|
|
||||||
|
|
||||||
class McpConfigUpdateRequest(BaseModel):
|
class McpConfigUpdateRequest(BaseModel):
|
||||||
raw_content: str
|
raw_content: str
|
||||||
|
|
||||||
|
|
||||||
class McpConfigUpdateResponse(BaseModel):
|
class McpConfigUpdateResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
path: str
|
path: str
|
||||||
@@ -134,28 +168,6 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_db = DBConfigManager()
|
_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")
|
@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} (GET)",
|
||||||
"/v1/graph-configs/{pipeline_id}/{prompt_set_id} (DELETE)",
|
"/v1/graph-configs/{pipeline_id}/{prompt_set_id} (DELETE)",
|
||||||
"/v1/pipelines/graphs (GET)",
|
"/v1/pipelines/graphs (GET)",
|
||||||
"/v1/pipelines (POST)",
|
"/v1/pipelines (POST) - build config + upsert pipeline registry entry",
|
||||||
"/v1/pipelines (GET)",
|
"/v1/pipelines (GET) - list registry pipeline specs",
|
||||||
"/v1/pipelines/{run_id} (DELETE)",
|
"/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 (GET)",
|
||||||
"/v1/tool-configs/mcp (PUT)",
|
"/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:
|
def _read_mcp_config_raw() -> str:
|
||||||
if not osp.exists(_MCP_CONFIG_PATH):
|
if not osp.exists(MCP_CONFIG_PATH):
|
||||||
os.makedirs(osp.dirname(_MCP_CONFIG_PATH), exist_ok=True)
|
os.makedirs(osp.dirname(MCP_CONFIG_PATH), exist_ok=True)
|
||||||
with open(_MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
|
with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||||
f.write(_MCP_CONFIG_DEFAULT_CONTENT)
|
f.write(MCP_CONFIG_DEFAULT_CONTENT)
|
||||||
with open(_MCP_CONFIG_PATH, "r", encoding="utf-8") as f:
|
with open(MCP_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
return f.read()
|
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)
|
@app.post("/v1/graph-configs", response_model=GraphConfigUpsertResponse)
|
||||||
async def upsert_graph_config(body: GraphConfigUpsertRequest):
|
async def upsert_graph_config(body: GraphConfigUpsertRequest):
|
||||||
try:
|
try:
|
||||||
@@ -225,8 +314,11 @@ async def upsert_graph_config(body: GraphConfigUpsertRequest):
|
|||||||
api_key=(body.api_key or "").strip(),
|
api_key=(body.api_key or "").strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/graph-configs", response_model=GraphConfigListResponse)
|
@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:
|
try:
|
||||||
rows = _db.list_prompt_sets(pipeline_id=pipeline_id, graph_id=graph_id)
|
rows = _db.list_prompt_sets(pipeline_id=pipeline_id, graph_id=graph_id)
|
||||||
except Exception as e:
|
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]
|
items = [GraphConfigListItem(**row) for row in rows]
|
||||||
return GraphConfigListResponse(items=items, count=len(items))
|
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):
|
async def get_default_graph_config(pipeline_id: str):
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as 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 ""),
|
api_key=(active.get("api_key") or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/graphs/{graph_id}/default-config", response_model=GraphConfigReadResponse)
|
@app.get("/v1/graphs/{graph_id}/default-config", response_model=GraphConfigReadResponse)
|
||||||
async def get_graph_default_config_by_graph(graph_id: str):
|
async def get_graph_default_config_by_graph(graph_id: str):
|
||||||
return await get_default_graph_config(pipeline_id=graph_id)
|
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):
|
async def get_graph_config(pipeline_id: str, prompt_set_id: str):
|
||||||
try:
|
try:
|
||||||
meta = _db.get_prompt_set(pipeline_id=pipeline_id, prompt_set_id=prompt_set_id)
|
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():
|
async def available_graphs():
|
||||||
return {"available_graphs": sorted(GRAPH_BUILD_FNCS.keys())}
|
return {"available_graphs": sorted(GRAPH_BUILD_FNCS.keys())}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/tool-configs/mcp", response_model=McpConfigReadResponse)
|
@app.get("/v1/tool-configs/mcp", response_model=McpConfigReadResponse)
|
||||||
async def get_mcp_tool_config():
|
async def get_mcp_tool_config():
|
||||||
try:
|
try:
|
||||||
@@ -331,7 +434,7 @@ async def get_mcp_tool_config():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return McpConfigReadResponse(
|
return McpConfigReadResponse(
|
||||||
path=_MCP_CONFIG_PATH,
|
path=MCP_CONFIG_PATH,
|
||||||
raw_content=raw_content,
|
raw_content=raw_content,
|
||||||
tool_keys=tool_keys,
|
tool_keys=tool_keys,
|
||||||
)
|
)
|
||||||
@@ -341,8 +444,8 @@ async def get_mcp_tool_config():
|
|||||||
async def update_mcp_tool_config(body: McpConfigUpdateRequest):
|
async def update_mcp_tool_config(body: McpConfigUpdateRequest):
|
||||||
try:
|
try:
|
||||||
tool_keys = _parse_mcp_tool_keys(body.raw_content)
|
tool_keys = _parse_mcp_tool_keys(body.raw_content)
|
||||||
os.makedirs(osp.dirname(_MCP_CONFIG_PATH), exist_ok=True)
|
os.makedirs(osp.dirname(MCP_CONFIG_PATH), exist_ok=True)
|
||||||
with open(_MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
|
with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||||
# Keep user formatting/comments as entered while ensuring trailing newline.
|
# Keep user formatting/comments as entered while ensuring trailing newline.
|
||||||
f.write(body.raw_content.rstrip() + "\n")
|
f.write(body.raw_content.rstrip() + "\n")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -351,28 +454,24 @@ async def update_mcp_tool_config(body: McpConfigUpdateRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return McpConfigUpdateResponse(
|
return McpConfigUpdateResponse(
|
||||||
status="updated",
|
status="updated",
|
||||||
path=_MCP_CONFIG_PATH,
|
path=MCP_CONFIG_PATH,
|
||||||
tool_keys=tool_keys,
|
tool_keys=tool_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/pipelines", response_model=PipelineListResponse)
|
@app.get("/v1/pipelines", response_model=PipelineListResponse)
|
||||||
async def list_running_pipelines():
|
async def list_running_pipelines():
|
||||||
_prune_stopped_pipelines()
|
try:
|
||||||
items = [
|
registry = _read_pipeline_registry()
|
||||||
PipelineRunInfo(
|
except ValueError as e:
|
||||||
run_id=run_id,
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
pid=info["proc"].pid,
|
except Exception as e:
|
||||||
graph_id=info["graph_id"],
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
pipeline_id=info["pipeline_id"],
|
|
||||||
prompt_set_id=info["prompt_set_id"],
|
items: List[PipelineSpec] = []
|
||||||
url=info["url"],
|
pipelines = registry.get("pipelines", {})
|
||||||
port=info["port"],
|
for pipeline_id, spec in sorted(pipelines.items()):
|
||||||
auth_type="bearer",
|
items.append(_normalize_pipeline_spec(pipeline_id, spec))
|
||||||
auth_header_name="Authorization",
|
|
||||||
auth_key_masked=info.get("auth_key_masked", ""),
|
|
||||||
)
|
|
||||||
for run_id, info in _running_pipelines.items()
|
|
||||||
]
|
|
||||||
return PipelineListResponse(items=items, count=len(items))
|
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())}",
|
detail=f"Unknown graph_id '{body.graph_id}'. Valid options: {sorted(GRAPH_BUILD_FNCS.keys())}",
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_key = _generate_auth_key()
|
pipeline_id = body.pipeline_id.strip()
|
||||||
auth_key_masked = _mask_auth_key(auth_key)
|
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:
|
try:
|
||||||
proc, url = build_fn(
|
build_fn(
|
||||||
pipeline_id=body.pipeline_id,
|
pipeline_id=pipeline_id,
|
||||||
prompt_set=body.prompt_set_id,
|
prompt_set=prompt_set_id,
|
||||||
tool_keys=body.tool_keys,
|
tool_keys=body.tool_keys,
|
||||||
port=str(body.port),
|
api_key=resolved_api_key,
|
||||||
api_key=body.api_key,
|
|
||||||
fast_auth_keys=auth_key,
|
|
||||||
entry_pnt=body.entry_point,
|
|
||||||
llm_name=body.llm_name,
|
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:
|
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())
|
try:
|
||||||
_running_pipelines[run_id] = {
|
registry = _read_pipeline_registry()
|
||||||
"proc": proc,
|
pipeline_spec = registry.get("pipelines", {}).get(pipeline_id)
|
||||||
"graph_id": body.graph_id,
|
if pipeline_spec is None:
|
||||||
"pipeline_id": body.pipeline_id,
|
raise ValueError(
|
||||||
"prompt_set_id": body.prompt_set_id,
|
f"pipeline '{pipeline_id}' missing from registry after update"
|
||||||
"url": url,
|
)
|
||||||
"port": body.port,
|
normalized = _normalize_pipeline_spec(pipeline_id, pipeline_spec)
|
||||||
"auth_key_masked": auth_key_masked,
|
except Exception as e:
|
||||||
}
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to read pipeline registry after update: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
return PipelineCreateResponse(
|
return PipelineCreateResponse(
|
||||||
run_id=run_id,
|
pipeline_id=pipeline_id,
|
||||||
pid=proc.pid,
|
prompt_set_id=prompt_set_id,
|
||||||
graph_id=body.graph_id,
|
graph_id=normalized.graph_id,
|
||||||
pipeline_id=body.pipeline_id,
|
config_file=normalized.config_file,
|
||||||
prompt_set_id=body.prompt_set_id,
|
llm_name=normalized.llm_name,
|
||||||
url=url,
|
enabled=normalized.enabled,
|
||||||
port=body.port,
|
reload_required=True,
|
||||||
auth_type="bearer",
|
registry_path=PIPELINE_REGISTRY_PATH,
|
||||||
auth_header_name="Authorization",
|
|
||||||
auth_key_once=auth_key,
|
|
||||||
auth_key_masked=auth_key_masked,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@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"]
|
@app.delete("/v1/pipelines/{pipeline_id}", response_model=PipelineStopResponse)
|
||||||
if proc.poll() is None:
|
async def stop_pipeline(pipeline_id: str):
|
||||||
proc.terminate()
|
try:
|
||||||
try:
|
registry = _read_pipeline_registry()
|
||||||
proc.wait(timeout=5)
|
pipelines = registry.get("pipelines", {})
|
||||||
except subprocess.TimeoutExpired:
|
spec = pipelines.get(pipeline_id)
|
||||||
proc.kill()
|
if not isinstance(spec, dict):
|
||||||
proc.wait(timeout=5)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ from fastapi.responses import StreamingResponse, JSONResponse
|
|||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from pathlib import Path as FsPath
|
|
||||||
import os
|
import os
|
||||||
import os.path as osp
|
import os.path as osp
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import copy
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import tyro
|
import tyro
|
||||||
@@ -18,30 +16,20 @@ import tyro
|
|||||||
# Ensure we can import from project root
|
# Ensure we can import from project root
|
||||||
sys.path.append(osp.dirname(osp.dirname(osp.abspath(__file__))))
|
sys.path.append(osp.dirname(osp.dirname(osp.abspath(__file__))))
|
||||||
|
|
||||||
from lang_agent.pipeline import Pipeline, PipelineConfig
|
from lang_agent.pipeline import PipelineConfig
|
||||||
from lang_agent.config.core_config import load_tyro_conf
|
|
||||||
from lang_agent.components.server_pipeline_manager import ServerPipelineManager
|
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)
|
pipeline_config = tyro.cli(PipelineConfig)
|
||||||
logger.info(f"starting agent with default pipeline: \n{pipeline_config}")
|
logger.info(f"starting agent with base pipeline config: \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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PIPELINE_MANAGER = ServerPipelineManager(
|
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_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)):
|
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")
|
thread_id: Optional[str] = Field(default="3")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="DashScope-Compatible Application API",
|
app = FastAPI(
|
||||||
description="DashScope Application.call compatible endpoint backed by pipeline.chat")
|
title="DashScope-Compatible Application API",
|
||||||
|
description="DashScope Application.call compatible endpoint backed by pipeline.chat",
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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.
|
Stream chunks from pipeline and format as SSE.
|
||||||
Accumulates text and sends incremental updates.
|
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"
|
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.
|
Async version: Stream chunks from pipeline and format as SSE.
|
||||||
Accumulates text and sends incremental updates.
|
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"
|
thread_id = body_input.get("session_id") or req_session_id or "3"
|
||||||
user_msg = _extract_user_message(messages)
|
user_msg = _extract_user_message(messages)
|
||||||
|
|
||||||
route_id = PIPELINE_MANAGER.resolve_route_id(body=body, app_id=req_app_id, api_key=api_key)
|
pipeline_id = PIPELINE_MANAGER.resolve_pipeline_id(
|
||||||
selected_pipeline, selected_model = PIPELINE_MANAGER.get_pipeline(route_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.
|
# 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()}"
|
response_id = f"appcmpl-{os.urandom(12).hex()}"
|
||||||
|
|
||||||
if stream:
|
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(
|
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",
|
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):
|
if not isinstance(result_text, str):
|
||||||
result_text = str(result_text)
|
result_text = str(result_text)
|
||||||
|
|
||||||
@@ -237,9 +239,7 @@ async def _process_dashscope_request(
|
|||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": selected_model,
|
"model": selected_model,
|
||||||
},
|
},
|
||||||
"route_id": route_id,
|
"pipeline_id": pipeline_id,
|
||||||
# Backward compatibility: keep pipeline_id in response as the route id selector.
|
|
||||||
"pipeline_id": route_id,
|
|
||||||
"is_end": True,
|
"is_end": True,
|
||||||
}
|
}
|
||||||
return JSONResponse(content=data)
|
return JSONResponse(content=data)
|
||||||
@@ -297,10 +297,13 @@ async def application_completion(
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "DashScope Application-compatible API", "endpoints": [
|
return {
|
||||||
"/v1/apps/{app_id}/sessions/{session_id}/responses",
|
"message": "DashScope Application-compatible API",
|
||||||
"/health",
|
"endpoints": [
|
||||||
]}
|
"/v1/apps/{app_id}/sessions/{session_id}/responses",
|
||||||
|
"/health",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -315,5 +318,3 @@ if __name__ == "__main__":
|
|||||||
port=pipeline_config.port,
|
port=pipeline_config.port,
|
||||||
reload=True,
|
reload=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,12 @@ import tyro
|
|||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from lang_agent.pipeline import Pipeline, PipelineConfig
|
from lang_agent.pipeline import Pipeline, PipelineConfig
|
||||||
|
from lang_agent.config.constants import API_KEY_HEADER, VALID_API_KEYS
|
||||||
|
|
||||||
# Initialize Pipeline once
|
# Initialize Pipeline once
|
||||||
pipeline_config = tyro.cli(PipelineConfig)
|
pipeline_config = tyro.cli(PipelineConfig)
|
||||||
pipeline: Pipeline = pipeline_config.setup()
|
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)):
|
async def verify_api_key(api_key: str = Security(API_KEY_HEADER)):
|
||||||
"""Verify the API key from Authorization header (Bearer token format)."""
|
"""Verify the API key from Authorization header (Bearer token format)."""
|
||||||
@@ -46,12 +43,12 @@ class OpenAIChatCompletionRequest(BaseModel):
|
|||||||
temperature: Optional[float] = Field(default=1.0)
|
temperature: Optional[float] = Field(default=1.0)
|
||||||
max_tokens: Optional[int] = Field(default=None)
|
max_tokens: Optional[int] = Field(default=None)
|
||||||
# Optional overrides for pipeline behavior
|
# Optional overrides for pipeline behavior
|
||||||
thread_id: Optional[str] = Field(default='3')
|
thread_id: Optional[str] = Field(default="3")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="OpenAI-Compatible Chat API",
|
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(
|
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.
|
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,
|
"created": created_time,
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [
|
"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"
|
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",
|
"object": "chat.completion.chunk",
|
||||||
"created": created_time,
|
"created": created_time,
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(final)}\n\n"
|
yield f"data: {json.dumps(final)}\n\n"
|
||||||
yield "data: [DONE]\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.
|
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,
|
"created": created_time,
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [
|
"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"
|
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",
|
"object": "chat.completion.chunk",
|
||||||
"created": created_time,
|
"created": created_time,
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(final)}\n\n"
|
yield f"data: {json.dumps(final)}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
@@ -176,14 +153,23 @@ async def chat_completions(request: Request, _: str = Depends(verify_api_key)):
|
|||||||
|
|
||||||
if stream:
|
if stream:
|
||||||
# Use async streaming from pipeline
|
# 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(
|
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",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-streaming: get full result using async
|
# 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):
|
if not isinstance(result_text, str):
|
||||||
result_text = str(result_text)
|
result_text = str(result_text)
|
||||||
|
|
||||||
@@ -195,18 +181,11 @@ async def chat_completions(request: Request, _: str = Depends(verify_api_key)):
|
|||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": {
|
"message": {"role": "assistant", "content": result_text},
|
||||||
"role": "assistant",
|
"finish_reason": "stop",
|
||||||
"content": result_text
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {
|
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||||
"prompt_tokens": 0,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"total_tokens": 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return JSONResponse(content=data)
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
@@ -221,11 +200,7 @@ async def chat_completions(request: Request, _: str = Depends(verify_api_key)):
|
|||||||
async def root():
|
async def root():
|
||||||
return {
|
return {
|
||||||
"message": "OpenAI-compatible Chat API",
|
"message": "OpenAI-compatible Chat API",
|
||||||
"endpoints": [
|
"endpoints": ["/v1/chat/completions", "/v1/memory (DELETE)", "/health"],
|
||||||
"/v1/chat/completions",
|
|
||||||
"/v1/memory (DELETE)",
|
|
||||||
"/health"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
from lang_agent.pipeline import Pipeline, PipelineConfig
|
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)
|
# Initialize Pipeline once (matches existing server_* pattern)
|
||||||
pipeline_config = tyro.cli(PipelineConfig)
|
pipeline_config = tyro.cli(PipelineConfig)
|
||||||
@@ -24,9 +25,6 @@ logger.info(f"starting agent with pipeline: \n{pipeline_config}")
|
|||||||
pipeline: Pipeline = pipeline_config.setup()
|
pipeline: Pipeline = pipeline_config.setup()
|
||||||
|
|
||||||
# API Key Authentication
|
# 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)):
|
async def verify_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)):
|
||||||
"""Verify the API key from Authorization header (Bearer token format)."""
|
"""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.
|
Stream chunks as SSE events.
|
||||||
|
|
||||||
@@ -185,7 +185,9 @@ async def chat(body: ChatRequest, _: str = Depends(verify_api_key)):
|
|||||||
)
|
)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
rest_sse_from_astream(
|
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",
|
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):
|
if not isinstance(result_text, str):
|
||||||
result_text = str(result_text)
|
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")
|
@app.post("/v1/conversations/{conversation_id}/messages")
|
||||||
@@ -215,7 +221,9 @@ async def create_message(
|
|||||||
)
|
)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
rest_sse_from_astream(
|
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",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
@@ -257,7 +265,11 @@ async def delete_conversation_memory(
|
|||||||
cleared = _try_clear_single_thread_memory(thread_id)
|
cleared = _try_clear_single_thread_memory(thread_id)
|
||||||
if cleared:
|
if cleared:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"status": "success", "scope": "conversation", "conversation_id": conversation_id}
|
content={
|
||||||
|
"status": "success",
|
||||||
|
"scope": "conversation",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
@@ -276,5 +288,3 @@ if __name__ == "__main__":
|
|||||||
port=8589,
|
port=8589,
|
||||||
reload=True,
|
reload=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
405
frontend/package-lock.json
generated
405
frontend/package-lock.json
generated
@@ -16,7 +16,8 @@
|
|||||||
"@types/react-dom": "^18.3.6",
|
"@types/react-dom": "^18.3.6",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10"
|
"vite": "^5.4.10",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1200,6 +1201,129 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"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": "^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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001769",
|
"version": "1.0.30001769",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
||||||
@@ -1265,6 +1399,33 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.286",
|
"version": "1.5.286",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||||
@@ -1304,6 +1475,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -1353,6 +1531,26 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1422,6 +1620,13 @@
|
|||||||
"loose-envify": "cli.js"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -1432,6 +1637,16 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1465,6 +1680,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1600,6 +1832,13 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1610,6 +1849,64 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"@types/react-dom": "^18.3.6",
|
"@types/react-dom": "^18.3.6",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10"
|
"vite": "^5.4.10",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deleteGraphConfig,
|
deleteGraphConfig,
|
||||||
getGraphConfig,
|
getGraphConfig,
|
||||||
getGraphDefaultConfig,
|
getGraphDefaultConfig,
|
||||||
|
getPipelineDefaultConfig,
|
||||||
getMcpToolConfig,
|
getMcpToolConfig,
|
||||||
listAvailableGraphs,
|
listAvailableGraphs,
|
||||||
listGraphConfigs,
|
listGraphConfigs,
|
||||||
@@ -12,10 +13,11 @@ import {
|
|||||||
updateMcpToolConfig,
|
updateMcpToolConfig,
|
||||||
upsertGraphConfig,
|
upsertGraphConfig,
|
||||||
} from "./api/frontApis";
|
} from "./api/frontApis";
|
||||||
|
import { chooseActiveConfigItem, chooseDisplayItemsByPipeline } from "./activeConfigSelection";
|
||||||
import type {
|
import type {
|
||||||
GraphConfigListItem,
|
GraphConfigListItem,
|
||||||
GraphConfigReadResponse,
|
GraphConfigReadResponse,
|
||||||
PipelineRunInfo,
|
PipelineSpec,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type EditableAgent = {
|
type EditableAgent = {
|
||||||
@@ -26,24 +28,13 @@ type EditableAgent = {
|
|||||||
promptSetId?: string;
|
promptSetId?: string;
|
||||||
toolKeys: string[];
|
toolKeys: string[];
|
||||||
prompts: Record<string, string>;
|
prompts: Record<string, string>;
|
||||||
port: number;
|
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
llmName: string;
|
llmName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LaunchCredentials = {
|
|
||||||
url: string;
|
|
||||||
authType: string;
|
|
||||||
authHeaderName: string;
|
|
||||||
authKey: string;
|
|
||||||
authKeyMasked: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActiveTab = "agents" | "mcp";
|
type ActiveTab = "agents" | "mcp";
|
||||||
|
|
||||||
const DEFAULT_ENTRY_POINT = "fastapi_server/server_dashscope.py";
|
|
||||||
const DEFAULT_LLM_NAME = "qwen-plus";
|
const DEFAULT_LLM_NAME = "qwen-plus";
|
||||||
const DEFAULT_PORT = 8100;
|
|
||||||
const DEFAULT_API_KEY = "";
|
const DEFAULT_API_KEY = "";
|
||||||
const GRAPH_ARCH_IMAGE_MODULES = import.meta.glob(
|
const GRAPH_ARCH_IMAGE_MODULES = import.meta.glob(
|
||||||
"../assets/images/graph_arch/*.{png,jpg,jpeg,webp,gif}",
|
"../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 {
|
function makeAgentKey(pipelineId: string): string {
|
||||||
return `${pipelineId}::${promptSetId}`;
|
return `pipeline::${pipelineId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseToolCsv(value: string): string[] {
|
function parseToolCsv(value: string): string[] {
|
||||||
@@ -108,14 +99,13 @@ function toEditable(
|
|||||||
return {
|
return {
|
||||||
id: draft
|
id: draft
|
||||||
? `draft-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
? `draft-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
: makeAgentKey(config.pipeline_id, config.prompt_set_id),
|
: makeAgentKey(config.pipeline_id),
|
||||||
isDraft: draft,
|
isDraft: draft,
|
||||||
graphId: config.graph_id || config.pipeline_id,
|
graphId: config.graph_id || config.pipeline_id,
|
||||||
pipelineId: config.pipeline_id,
|
pipelineId: config.pipeline_id,
|
||||||
promptSetId: config.prompt_set_id,
|
promptSetId: config.prompt_set_id,
|
||||||
toolKeys: config.tool_keys || [],
|
toolKeys: config.tool_keys || [],
|
||||||
prompts: config.prompt_dict || {},
|
prompts: config.prompt_dict || {},
|
||||||
port: DEFAULT_PORT,
|
|
||||||
apiKey: config.api_key || DEFAULT_API_KEY,
|
apiKey: config.api_key || DEFAULT_API_KEY,
|
||||||
llmName: DEFAULT_LLM_NAME,
|
llmName: DEFAULT_LLM_NAME,
|
||||||
};
|
};
|
||||||
@@ -125,19 +115,18 @@ export default function App() {
|
|||||||
const [activeTab, setActiveTab] = useState<ActiveTab>("agents");
|
const [activeTab, setActiveTab] = useState<ActiveTab>("agents");
|
||||||
const [graphs, setGraphs] = useState<string[]>([]);
|
const [graphs, setGraphs] = useState<string[]>([]);
|
||||||
const [configItems, setConfigItems] = useState<GraphConfigListItem[]>([]);
|
const [configItems, setConfigItems] = useState<GraphConfigListItem[]>([]);
|
||||||
const [running, setRunning] = useState<PipelineRunInfo[]>([]);
|
const [running, setRunning] = useState<PipelineSpec[]>([]);
|
||||||
const [draftAgents, setDraftAgents] = useState<EditableAgent[]>([]);
|
const [draftAgents, setDraftAgents] = useState<EditableAgent[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [editor, setEditor] = useState<EditableAgent | null>(null);
|
const [editor, setEditor] = useState<EditableAgent | null>(null);
|
||||||
const [statusMessage, setStatusMessage] = useState<string>("");
|
const [statusMessage, setStatusMessage] = useState<string>("");
|
||||||
const [launchCredentials, setLaunchCredentials] = useState<LaunchCredentials | null>(null);
|
|
||||||
const [mcpConfigPath, setMcpConfigPath] = useState<string>("");
|
const [mcpConfigPath, setMcpConfigPath] = useState<string>("");
|
||||||
const [mcpConfigRaw, setMcpConfigRaw] = useState<string>("");
|
const [mcpConfigRaw, setMcpConfigRaw] = useState<string>("");
|
||||||
const [mcpToolKeys, setMcpToolKeys] = useState<string[]>([]);
|
const [mcpToolKeys, setMcpToolKeys] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const configKeySet = useMemo(
|
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]
|
[configItems]
|
||||||
);
|
);
|
||||||
const visibleConfigItems = useMemo(
|
const visibleConfigItems = useMemo(
|
||||||
@@ -155,6 +144,10 @@ export default function App() {
|
|||||||
}),
|
}),
|
||||||
[configItems]
|
[configItems]
|
||||||
);
|
);
|
||||||
|
const displayConfigItems = useMemo(
|
||||||
|
() => chooseDisplayItemsByPipeline(visibleConfigItems),
|
||||||
|
[visibleConfigItems]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRuns = useMemo(() => {
|
const selectedRuns = useMemo(() => {
|
||||||
if (!editor?.pipelineId) {
|
if (!editor?.pipelineId) {
|
||||||
@@ -164,10 +157,7 @@ export default function App() {
|
|||||||
if (run.pipeline_id !== editor.pipelineId) {
|
if (run.pipeline_id !== editor.pipelineId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!editor.promptSetId) {
|
return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return run.prompt_set_id === editor.promptSetId;
|
|
||||||
});
|
});
|
||||||
}, [editor, running]);
|
}, [editor, running]);
|
||||||
|
|
||||||
@@ -227,15 +217,24 @@ export default function App() {
|
|||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
async function selectExisting(item: GraphConfigListItem): Promise<void> {
|
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);
|
setSelectedId(id);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatusMessage("Loading agent details...");
|
setStatusMessage("Loading agent details...");
|
||||||
try {
|
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);
|
const editable = toEditable(detail, false);
|
||||||
editable.id = id;
|
editable.id = id;
|
||||||
editable.port = editor?.pipelineId === editable.pipelineId ? editor.port : DEFAULT_PORT;
|
|
||||||
editable.llmName = editor?.pipelineId === editable.pipelineId ? editor.llmName : DEFAULT_LLM_NAME;
|
editable.llmName = editor?.pipelineId === editable.pipelineId ? editor.llmName : DEFAULT_LLM_NAME;
|
||||||
// apiKey is loaded from backend (persisted in DB) — don't override with default
|
// apiKey is loaded from backend (persisted in DB) — don't override with default
|
||||||
setEditor(editable);
|
setEditor(editable);
|
||||||
@@ -395,20 +394,30 @@ export default function App() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatusMessage("Saving agent config...");
|
setStatusMessage("Saving agent config...");
|
||||||
try {
|
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({
|
const upsertResp = await upsertGraphConfig({
|
||||||
graph_id: editor.graphId,
|
graph_id: editor.graphId,
|
||||||
pipeline_id: editor.pipelineId.trim(),
|
pipeline_id: editor.pipelineId.trim(),
|
||||||
prompt_set_id: editor.promptSetId,
|
prompt_set_id: targetPromptSetId,
|
||||||
tool_keys: editor.toolKeys,
|
tool_keys: editor.toolKeys,
|
||||||
prompt_dict: editor.prompts,
|
prompt_dict: editor.prompts,
|
||||||
api_key: editor.apiKey.trim(),
|
api_key: editor.apiKey.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await refreshConfigs();
|
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);
|
const saved = toEditable(detail, false);
|
||||||
saved.id = makeAgentKey(upsertResp.pipeline_id, upsertResp.prompt_set_id);
|
saved.id = makeAgentKey(upsertResp.pipeline_id);
|
||||||
saved.port = editor.port;
|
|
||||||
// apiKey is loaded from backend (persisted in DB) — don't override
|
// apiKey is loaded from backend (persisted in DB) — don't override
|
||||||
saved.llmName = editor.llmName;
|
saved.llmName = editor.llmName;
|
||||||
setEditor(saved);
|
setEditor(saved);
|
||||||
@@ -461,10 +470,6 @@ export default function App() {
|
|||||||
setStatusMessage("pipeline_id is required before run.");
|
setStatusMessage("pipeline_id is required before run.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(editor.port) || editor.port <= 0) {
|
|
||||||
setStatusMessage("port must be a positive integer.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!editor.apiKey.trim()) {
|
if (!editor.apiKey.trim()) {
|
||||||
setStatusMessage("api_key is required before run.");
|
setStatusMessage("api_key is required before run.");
|
||||||
return;
|
return;
|
||||||
@@ -472,27 +477,20 @@ export default function App() {
|
|||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatusMessage("Starting agent...");
|
setStatusMessage("Starting agent...");
|
||||||
setLaunchCredentials(null);
|
|
||||||
try {
|
try {
|
||||||
const resp = await createPipeline({
|
const resp = await createPipeline({
|
||||||
graph_id: editor.graphId,
|
graph_id: editor.graphId,
|
||||||
pipeline_id: editor.pipelineId.trim(),
|
pipeline_id: editor.pipelineId.trim(),
|
||||||
prompt_set_id: editor.promptSetId,
|
prompt_set_id: editor.promptSetId,
|
||||||
tool_keys: editor.toolKeys,
|
tool_keys: editor.toolKeys,
|
||||||
port: editor.port,
|
|
||||||
api_key: editor.apiKey.trim(),
|
api_key: editor.apiKey.trim(),
|
||||||
entry_point: DEFAULT_ENTRY_POINT,
|
|
||||||
llm_name: editor.llmName,
|
llm_name: editor.llmName,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
await refreshRunning();
|
await refreshRunning();
|
||||||
setStatusMessage(`Agent started. URL: ${resp.url}`);
|
setStatusMessage(
|
||||||
setLaunchCredentials({
|
`Agent registered. config_file=${resp.config_file}, reload_required=${String(resp.reload_required)}`
|
||||||
url: resp.url,
|
);
|
||||||
authType: resp.auth_type,
|
|
||||||
authHeaderName: resp.auth_header_name,
|
|
||||||
authKey: resp.auth_key_once,
|
|
||||||
authKeyMasked: resp.auth_key_masked,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage((error as Error).message);
|
setStatusMessage((error as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -513,7 +511,7 @@ export default function App() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatusMessage("Stopping agent...");
|
setStatusMessage("Stopping agent...");
|
||||||
try {
|
try {
|
||||||
await stopPipeline(target.run_id);
|
await stopPipeline(target.pipeline_id);
|
||||||
await refreshRunning();
|
await refreshRunning();
|
||||||
setStatusMessage("Agent stopped.");
|
setStatusMessage("Agent stopped.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -530,31 +528,14 @@ export default function App() {
|
|||||||
graphId: d.graphId,
|
graphId: d.graphId,
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
})),
|
})),
|
||||||
...visibleConfigItems.map((item) => ({
|
...displayConfigItems.map((item) => ({
|
||||||
id: makeAgentKey(item.pipeline_id, item.prompt_set_id),
|
id: makeAgentKey(item.pipeline_id),
|
||||||
label: item.pipeline_id,
|
label: item.pipeline_id,
|
||||||
graphId: item.graph_id || item.pipeline_id,
|
graphId: item.graph_id || item.pipeline_id,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
const graphArchImage = editor ? getGraphArchImage(editor.graphId) : null;
|
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";
|
const showSidebar = activeTab === "agents";
|
||||||
|
|
||||||
@@ -580,9 +561,7 @@ export default function App() {
|
|||||||
setEditor(selectedDraft);
|
setEditor(selectedDraft);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = visibleConfigItems.find(
|
const item = displayConfigItems.find((x) => makeAgentKey(x.pipeline_id) === row.id);
|
||||||
(x) => makeAgentKey(x.pipeline_id, x.prompt_set_id) === row.id
|
|
||||||
);
|
|
||||||
if (item) {
|
if (item) {
|
||||||
selectExisting(item);
|
selectExisting(item);
|
||||||
}
|
}
|
||||||
@@ -638,48 +617,6 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{!editor ? (
|
||||||
<div className="empty-panel">
|
<div className="empty-panel">
|
||||||
<p>Select an agent from the left or create a new one.</p>
|
<p>Select an agent from the left or create a new one.</p>
|
||||||
@@ -724,11 +661,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
|
||||||
prompt_set_id
|
|
||||||
<input value={editor.promptSetId || "(assigned on save)"} readOnly />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
tool_keys (comma separated)
|
tool_keys (comma separated)
|
||||||
<input
|
<input
|
||||||
@@ -739,17 +671,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
|
||||||
port
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={editor.port}
|
|
||||||
onChange={(e) => updateEditor("port", Number(e.target.value))}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
api_key
|
api_key
|
||||||
<input
|
<input
|
||||||
@@ -798,21 +719,21 @@ export default function App() {
|
|||||||
<p className="empty">No active runs for this agent.</p>
|
<p className="empty">No active runs for this agent.</p>
|
||||||
) : (
|
) : (
|
||||||
selectedRuns.map((run) => (
|
selectedRuns.map((run) => (
|
||||||
<div key={run.run_id} className="run-card">
|
<div key={run.pipeline_id} className="run-card">
|
||||||
<div>
|
<div>
|
||||||
<strong>run_id:</strong> {run.run_id}
|
<strong>pipeline_id:</strong> {run.pipeline_id}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>pid:</strong> {run.pid}
|
<strong>graph_id:</strong> {run.graph_id}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>url:</strong>{" "}
|
<strong>model:</strong> {run.llm_name}
|
||||||
<a href={run.url} target="_blank" rel="noreferrer">
|
|
||||||
{run.url}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
79
frontend/src/activeConfigSelection.test.ts
Normal file
79
frontend/src/activeConfigSelection.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
45
frontend/src/activeConfigSelection.ts
Normal file
45
frontend/src/activeConfigSelection.ts
Normal 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));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -70,6 +70,12 @@ export function getGraphDefaultConfig(
|
|||||||
return fetchJson(`/v1/graphs/${graphId}/default-config`);
|
return fetchJson(`/v1/graphs/${graphId}/default-config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPipelineDefaultConfig(
|
||||||
|
pipelineId: string
|
||||||
|
): Promise<GraphConfigReadResponse> {
|
||||||
|
return fetchJson(`/v1/graph-configs/default/${pipelineId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function upsertGraphConfig(
|
export function upsertGraphConfig(
|
||||||
payload: GraphConfigUpsertRequest
|
payload: GraphConfigUpsertRequest
|
||||||
): Promise<GraphConfigUpsertResponse> {
|
): Promise<GraphConfigUpsertResponse> {
|
||||||
@@ -114,8 +120,8 @@ export function listPipelines(): Promise<PipelineListResponse> {
|
|||||||
return fetchJson("/v1/pipelines");
|
return fetchJson("/v1/pipelines");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopPipeline(runId: string): Promise<PipelineStopResponse> {
|
export function stopPipeline(pipelineId: string): Promise<PipelineStopResponse> {
|
||||||
return fetchJson(`/v1/pipelines/${runId}`, {
|
return fetchJson(`/v1/pipelines/${pipelineId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,37 +52,41 @@ export type PipelineCreateRequest = {
|
|||||||
pipeline_id: string;
|
pipeline_id: string;
|
||||||
prompt_set_id: string;
|
prompt_set_id: string;
|
||||||
tool_keys: string[];
|
tool_keys: string[];
|
||||||
port: number;
|
api_key?: string;
|
||||||
api_key: string;
|
|
||||||
entry_point: string;
|
|
||||||
llm_name: string;
|
llm_name: string;
|
||||||
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PipelineRunInfo = {
|
export type PipelineSpec = {
|
||||||
run_id: string;
|
pipeline_id: string;
|
||||||
pid: number;
|
|
||||||
graph_id: string;
|
graph_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config_file: string;
|
||||||
|
llm_name: string;
|
||||||
|
overrides: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PipelineCreateResponse = {
|
||||||
pipeline_id: string;
|
pipeline_id: string;
|
||||||
prompt_set_id: string;
|
prompt_set_id: string;
|
||||||
url: string;
|
graph_id: string;
|
||||||
port: number;
|
config_file: string;
|
||||||
auth_type: string;
|
llm_name: string;
|
||||||
auth_header_name: string;
|
enabled: boolean;
|
||||||
auth_key_masked: string;
|
reload_required: boolean;
|
||||||
};
|
registry_path: string;
|
||||||
|
|
||||||
export type PipelineCreateResponse = PipelineRunInfo & {
|
|
||||||
auth_key_once: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PipelineListResponse = {
|
export type PipelineListResponse = {
|
||||||
items: PipelineRunInfo[];
|
items: PipelineSpec[];
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PipelineStopResponse = {
|
export type PipelineStopResponse = {
|
||||||
run_id: string;
|
pipeline_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reload_required: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type McpToolConfigResponse = {
|
export type McpToolConfigResponse = {
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -4,16 +4,34 @@ from typing import List, Dict, Union
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
import os
|
import os
|
||||||
from loguru import logger
|
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):
|
class MessageType(str, Enum):
|
||||||
"""Enum for message types in the conversation store."""
|
"""Enum for message types in the conversation store."""
|
||||||
|
|
||||||
HUMAN = "human"
|
HUMAN = "human"
|
||||||
AI = "ai"
|
AI = "ai"
|
||||||
TOOL = "tool"
|
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):
|
def __init__(self):
|
||||||
conn_str = os.environ.get("CONN_STR")
|
conn_str = os.environ.get("CONN_STR")
|
||||||
if conn_str is None:
|
if conn_str is None:
|
||||||
@@ -25,46 +43,53 @@ class ConversationStore:
|
|||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
msg_type: MessageType,
|
msg_type: MessageType,
|
||||||
content: str,
|
content: str,
|
||||||
sequence: int, # the conversation number
|
sequence: int,
|
||||||
|
pipeline_id: str = None,
|
||||||
):
|
):
|
||||||
with psycopg.connect(self.conn_str) as conn:
|
with psycopg.connect(self.conn_str) as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
# DB schema only supports these columns:
|
|
||||||
# (conversation_id, message_type, content, sequence_number)
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO messages (conversation_id, message_type, content, sequence_number)
|
INSERT INTO messages (conversation_id, pipeline_id, message_type, content, sequence_number)
|
||||||
VALUES (%s, %s, %s, %s)
|
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:
|
def get_conv_number(self, conversation_id: str) -> int:
|
||||||
"""
|
"""
|
||||||
if the conversation_id does not exist, return 0
|
if the conversation_id does not exist, return 0
|
||||||
if len(conversation) = 3, it will return 3
|
if len(conversation) = 3, it will return 3
|
||||||
"""
|
"""
|
||||||
with psycopg.connect(self.conn_str) as conn:
|
with psycopg.connect(self.conn_str) as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
|
"""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE conversation_id = %s
|
WHERE conversation_id = %s
|
||||||
""", (conversation_id,))
|
""",
|
||||||
|
(conversation_id,),
|
||||||
|
)
|
||||||
return int(cur.fetchone()[0])
|
return int(cur.fetchone()[0])
|
||||||
|
|
||||||
def get_conversation(self, conversation_id: str) -> List[Dict]:
|
def get_conversation(self, conversation_id: str) -> List[Dict]:
|
||||||
with psycopg.connect(self.conn_str) as conn:
|
with psycopg.connect(self.conn_str) as conn:
|
||||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
|
"""
|
||||||
SELECT message_type, content, sequence_number, created_at
|
SELECT message_type, content, sequence_number, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE conversation_id = %s
|
WHERE conversation_id = %s
|
||||||
ORDER BY sequence_number ASC
|
ORDER BY sequence_number ASC
|
||||||
""", (conversation_id,))
|
""",
|
||||||
|
(conversation_id,),
|
||||||
|
)
|
||||||
return cur.fetchall()
|
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)]
|
inp = [e for e in inp if not isinstance(e, SystemMessage)]
|
||||||
curr_len = self.get_conv_number(conv_id)
|
curr_len = self.get_conv_number(conv_id)
|
||||||
to_add_msg = inp[curr_len:]
|
to_add_msg = inp[curr_len:]
|
||||||
@@ -73,12 +98,13 @@ class ConversationStore:
|
|||||||
# Serialize dict/list content to JSON string
|
# Serialize dict/list content to JSON string
|
||||||
if not isinstance(content, str):
|
if not isinstance(content, str):
|
||||||
content = json.dumps(content, ensure_ascii=False, indent=4)
|
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
|
curr_len += 1
|
||||||
return curr_len
|
return curr_len
|
||||||
|
|
||||||
|
def _get_type(self, msg: BaseMessage) -> MessageType:
|
||||||
def _get_type(self, msg:BaseMessage) -> MessageType:
|
|
||||||
if isinstance(msg, HumanMessage):
|
if isinstance(msg, HumanMessage):
|
||||||
return MessageType.HUMAN
|
return MessageType.HUMAN
|
||||||
elif isinstance(msg, AIMessage):
|
elif isinstance(msg, AIMessage):
|
||||||
@@ -89,11 +115,13 @@ class ConversationStore:
|
|||||||
raise ValueError(f"Unknown message type: {type(msg)}")
|
raise ValueError(f"Unknown message type: {type(msg)}")
|
||||||
|
|
||||||
|
|
||||||
class ConversationPrinter:
|
class ConversationPrinter(BaseConvStore):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.id_dic = {}
|
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)]
|
inp = [e for e in inp if not isinstance(e, SystemMessage)]
|
||||||
curr_len = self.id_dic.get(conv_id, 0)
|
curr_len = self.id_dic.get(conv_id, 0)
|
||||||
to_print_msg = inp[curr_len:]
|
to_print_msg = inp[curr_len:]
|
||||||
@@ -106,9 +134,11 @@ class ConversationPrinter:
|
|||||||
else:
|
else:
|
||||||
self.id_dic[conv_id] += len(to_print_msg)
|
self.id_dic[conv_id] += len(to_print_msg)
|
||||||
|
|
||||||
|
|
||||||
CONV_STORE = ConversationStore()
|
CONV_STORE = ConversationStore()
|
||||||
# CONV_STORE = ConversationPrinter()
|
# CONV_STORE = ConversationPrinter()
|
||||||
|
|
||||||
|
|
||||||
def use_printer():
|
def use_printer():
|
||||||
global CONV_STORE
|
global CONV_STORE
|
||||||
CONV_STORE = ConversationPrinter()
|
CONV_STORE = ConversationPrinter()
|
||||||
|
|||||||
@@ -11,21 +11,15 @@ from lang_agent.config.core_config import load_tyro_conf
|
|||||||
|
|
||||||
|
|
||||||
class ServerPipelineManager:
|
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):
|
def __init__(self, default_pipeline_id: str, default_config: PipelineConfig):
|
||||||
self.default_route_id = default_route_id
|
self.default_pipeline_id = default_pipeline_id
|
||||||
self.default_config = default_config
|
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._api_key_policy: Dict[str, Dict[str, Any]] = {}
|
||||||
self._pipelines: Dict[str, Pipeline] = {default_route_id: default_pipeline}
|
self._pipelines: Dict[str, Pipeline] = {}
|
||||||
self._pipeline_llm: Dict[str, str] = {default_route_id: default_config.llm_name}
|
self._pipeline_llm: Dict[str, str] = {}
|
||||||
self._route_specs[default_route_id] = {
|
|
||||||
"enabled": True,
|
|
||||||
"config_file": None,
|
|
||||||
"overrides": {},
|
|
||||||
"prompt_pipeline_id": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _resolve_registry_path(self, registry_path: str) -> str:
|
def _resolve_registry_path(self, registry_path: str) -> str:
|
||||||
path = FsPath(registry_path)
|
path = FsPath(registry_path)
|
||||||
@@ -39,35 +33,36 @@ class ServerPipelineManager:
|
|||||||
def load_registry(self, registry_path: str) -> None:
|
def load_registry(self, registry_path: str) -> None:
|
||||||
abs_path = self._resolve_registry_path(registry_path)
|
abs_path = self._resolve_registry_path(registry_path)
|
||||||
if not osp.exists(abs_path):
|
if not osp.exists(abs_path):
|
||||||
logger.warning(f"pipeline registry file not found: {abs_path}. Using default pipeline only.")
|
raise ValueError(f"pipeline registry file not found: {abs_path}")
|
||||||
return
|
|
||||||
|
|
||||||
with open(abs_path, "r", encoding="utf-8") as f:
|
with open(abs_path, "r", encoding="utf-8") as f:
|
||||||
registry:dict = json.load(f)
|
registry: dict = json.load(f)
|
||||||
|
|
||||||
routes = registry.get("routes")
|
pipelines = registry.get("pipelines")
|
||||||
if routes is None:
|
if pipelines is None:
|
||||||
# Backward compatibility with initial schema.
|
raise ValueError("`pipelines` in pipeline registry must be an object.")
|
||||||
routes = registry.get("pipelines", {})
|
|
||||||
if not isinstance(routes, dict):
|
|
||||||
raise ValueError("`routes` 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):
|
if not isinstance(spec, dict):
|
||||||
raise ValueError(f"route spec for `{route_id}` must be an object.")
|
raise ValueError(
|
||||||
self._route_specs[route_id] = {
|
f"pipeline spec for `{pipeline_id}` must be an object."
|
||||||
|
)
|
||||||
|
self._pipeline_specs[pipeline_id] = {
|
||||||
"enabled": bool(spec.get("enabled", True)),
|
"enabled": bool(spec.get("enabled", True)),
|
||||||
"config_file": spec.get("config_file"),
|
"config_file": spec.get("config_file"),
|
||||||
"overrides": spec.get("overrides", {}),
|
"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", {})
|
api_key_policy = registry.get("api_keys", {})
|
||||||
if api_key_policy and not isinstance(api_key_policy, dict):
|
if api_key_policy and not isinstance(api_key_policy, dict):
|
||||||
raise ValueError("`api_keys` in pipeline registry must be an object.")
|
raise ValueError("`api_keys` in pipeline registry must be an object.")
|
||||||
self._api_key_policy = api_key_policy
|
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:
|
def _resolve_config_path(self, config_file: str) -> str:
|
||||||
path = FsPath(config_file)
|
path = FsPath(config_file)
|
||||||
@@ -78,54 +73,47 @@ class ServerPipelineManager:
|
|||||||
root = FsPath(__file__).resolve().parents[2]
|
root = FsPath(__file__).resolve().parents[2]
|
||||||
return str((root / path).resolve())
|
return str((root / path).resolve())
|
||||||
|
|
||||||
def _build_pipeline(self, route_id: str) -> Tuple[Pipeline, str]:
|
def _build_pipeline(self, pipeline_id: str) -> Tuple[Pipeline, str]:
|
||||||
spec = self._route_specs.get(route_id)
|
spec = self._pipeline_specs.get(pipeline_id)
|
||||||
if spec is None:
|
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):
|
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")
|
config_file = spec.get("config_file")
|
||||||
overrides = spec.get("overrides", {})
|
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:
|
if config_file:
|
||||||
loaded_cfg = load_tyro_conf(self._resolve_config_path(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"):
|
if hasattr(loaded_cfg, "setup"):
|
||||||
cfg = loaded_cfg
|
cfg = loaded_cfg
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"config_file for route `{route_id}` did not deserialize to config object; "
|
f"config_file for pipeline `{pipeline_id}` did not deserialize to config object; "
|
||||||
"falling back to default config and applying route-level overrides."
|
"falling back to default config and applying pipeline-level overrides."
|
||||||
)
|
)
|
||||||
cfg = copy.deepcopy(self.default_config)
|
cfg = copy.deepcopy(self.default_config)
|
||||||
else:
|
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)
|
cfg = copy.deepcopy(self.default_config)
|
||||||
if not isinstance(overrides, dict):
|
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():
|
for key, value in overrides.items():
|
||||||
if not hasattr(cfg, key):
|
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)
|
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()
|
p = cfg.setup()
|
||||||
llm_name = getattr(cfg, "llm_name", "unknown-model")
|
llm_name = getattr(cfg, "llm_name", "unknown-model")
|
||||||
return p, llm_name
|
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:
|
if not self._api_key_policy:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -133,47 +121,46 @@ class ServerPipelineManager:
|
|||||||
if policy is None:
|
if policy is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
allowed = policy.get("allowed_route_ids")
|
allowed = policy.get("allowed_pipeline_ids")
|
||||||
if allowed is None:
|
if allowed and pipeline_id not in allowed:
|
||||||
# Backward compatibility.
|
raise HTTPException(
|
||||||
allowed = policy.get("allowed_pipeline_ids")
|
status_code=403,
|
||||||
if allowed and route_id not in allowed:
|
detail=f"pipeline_id `{pipeline_id}` is not allowed for this API key",
|
||||||
raise HTTPException(status_code=403, detail=f"route_id `{route_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", {})
|
body_input = body.get("input", {})
|
||||||
route_id = (
|
pipeline_id = (
|
||||||
body.get("route_id")
|
body.get("pipeline_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")
|
|
||||||
or (body_input.get("pipeline_id") if isinstance(body_input, dict) else None)
|
or (body_input.get("pipeline_id") if isinstance(body_input, dict) else None)
|
||||||
or app_id
|
or app_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not route_id:
|
if not pipeline_id:
|
||||||
key_policy = self._api_key_policy.get(api_key, {}) if self._api_key_policy else {}
|
key_policy = (
|
||||||
route_id = key_policy.get("default_route_id")
|
self._api_key_policy.get(api_key, {}) if self._api_key_policy else {}
|
||||||
if not route_id:
|
)
|
||||||
# Backward compatibility.
|
pipeline_id = key_policy.get(
|
||||||
route_id = key_policy.get("default_pipeline_id", self.default_route_id)
|
"default_pipeline_id", self.default_pipeline_id
|
||||||
|
)
|
||||||
|
|
||||||
if route_id not in self._route_specs:
|
if pipeline_id not in self._pipeline_specs:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown route_id: {route_id}")
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Unknown pipeline_id: {pipeline_id}"
|
||||||
|
)
|
||||||
|
|
||||||
self._authorize(api_key, route_id)
|
self._authorize(api_key, pipeline_id)
|
||||||
return route_id
|
return pipeline_id
|
||||||
|
|
||||||
def get_pipeline(self, route_id: str) -> Tuple[Pipeline, str]:
|
def get_pipeline(self, pipeline_id: str) -> Tuple[Pipeline, str]:
|
||||||
cached = self._pipelines.get(route_id)
|
cached = self._pipelines.get(pipeline_id)
|
||||||
if cached is not None:
|
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)
|
pipeline_obj, llm_name = self._build_pipeline(pipeline_id)
|
||||||
self._pipelines[route_id] = pipeline_obj
|
self._pipelines[pipeline_id] = pipeline_obj
|
||||||
self._pipeline_llm[route_id] = llm_name
|
self._pipeline_llm[pipeline_id] = llm_name
|
||||||
logger.info(f"lazy-loaded route_id={route_id} model={llm_name}")
|
logger.info(f"lazy-loaded pipeline_id={pipeline_id} model={llm_name}")
|
||||||
return pipeline_obj, llm_name
|
return pipeline_obj, llm_name
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
from lang_agent.config.core_config import (InstantiateConfig,
|
from lang_agent.config.core_config import (
|
||||||
ToolConfig,
|
InstantiateConfig,
|
||||||
LLMKeyConfig,
|
ToolConfig,
|
||||||
LLMNodeConfig,
|
LLMKeyConfig,
|
||||||
load_tyro_conf)
|
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
|
||||||
|
)
|
||||||
|
|||||||
17
lang_agent/config/constants.py
Normal file
17
lang_agent/config/constants.py
Normal 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(",")))
|
||||||
@@ -72,56 +72,12 @@ class InstantiateConfig(PrintableConfig):
|
|||||||
将配置保存到 YAML 文件
|
将配置保存到 YAML 文件
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# Persist the full config object (including type tags) so it can be
|
||||||
def mask_value(key, value):
|
# deserialized back into config instances with methods like .setup().
|
||||||
"""
|
# Secret masking is intentionally handled by __str__ for printing/logging,
|
||||||
Apply masking if key is secret-like
|
# not when writing to disk.
|
||||||
如果键是敏感的,应用掩码
|
with open(filename, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(self, f)
|
||||||
检查键是否敏感(如包含 "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)
|
|
||||||
logger.info(f"[yellow]config saved to: {filename}[/yellow]")
|
logger.info(f"[yellow]config saved to: {filename}[/yellow]")
|
||||||
|
|
||||||
def get_name(self):
|
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 = 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
|
return config
|
||||||
|
|
||||||
def is_default(instance, field_):
|
def is_default(instance, field_):
|
||||||
|
|||||||
13
lang_agent/config/ty_build_config.py
Normal file
13
lang_agent/config/ty_build_config.py
Normal 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)
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
117
lang_agent/front_api/build_server_utils.py
Normal file
117
lang_agent/front_api/build_server_utils.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -26,17 +26,18 @@ SYS_PROMPT = """你是一个专业的心理质询师。你的主要工作是心
|
|||||||
可怎么也发不出声音,只能眼睁睁看着它越来越远,然后就醒了。醒来后心里堵得慌,说不上来的难受,
|
可怎么也发不出声音,只能眼睁睁看着它越来越远,然后就醒了。醒来后心里堵得慌,说不上来的难受,
|
||||||
总觉得那只小狗孤零零的,特别让人心疼。
|
总觉得那只小狗孤零零的,特别让人心疼。
|
||||||
|
|
||||||
理解(你的回复): 能感受到你醒来后的这份难受 —— 看到弱小的生命独自挣扎,而自己却无能为力,这种‘想帮却做不到’的无力感,
|
理解(你的回复): 能感受到你醒来后的这份难受 —— 看到弱小的生命独自挣扎,而自己却无能为力,这种'想帮却做不到'的无力感,
|
||||||
其实是很真实的情绪反馈。你会心疼小狗,说明你内心藏着很珍贵的共情力,这份柔软不是矫情,
|
其实是很真实的情绪反馈。你会心疼小狗,说明你内心藏着很珍贵的共情力,这份柔软不是矫情,
|
||||||
而是你感知他人痛苦的能力呀
|
而是你感知他人痛苦的能力呀
|
||||||
|
|
||||||
解析(你的回复):我们再说回这个梦吧,我们的梦境其实没有唯一的‘正确解释’,但我们可以一起看看它可能和你当下的状态有什么关联~ 首先,‘出差去广州’通常象征着你近期正在推进的某件事 —— 可能是工作上的一个项目,也可能是生活中一段需要‘独自奔赴’的旅程,是你当下比较关注、需要投入精力的目标,对吗?”
|
解析(你的回复):我们再说回这个梦吧,我们的梦境其实没有唯一的'正确解释',但我们可以一起看看它可能和你当下的状态有什么关联~ 首先,'出差去广州'通常象征着你近期正在推进的某件事 —— 可能是工作上的一个项目,也可能是生活中一段需要'独自奔赴'的旅程,是你当下比较关注、需要投入精力的目标,对吗?”
|
||||||
“而那只瘸脚的小狗,在心理学视角中,常常是我们潜意识里‘脆弱自我’的投射。它可能代表着你近期的某一面:比如在处理那件‘需要奔赴’的事时,你偶尔会觉得自己像小狗一样‘力不从心’,或者感受到了‘孤单’,却没找到合适的人倾诉或求助;也可能是你近期在生活中看到了一些让你觉得‘无力改变’的场景(比如身边人遇到困难、社会上的小事),这些情绪没有被你刻意留意,就通过梦境里的小狗呈现了出来。”
|
"而那只瘸脚的小狗,在心理学视角中,常常是我们潜意识里'脆弱自我'的投射。它可能代表着你近期的某一面:比如在处理那件'需要奔赴'的事时,你偶尔会觉得自己像小狗一样'力不从心',或者感受到了'孤单',却没找到合适的人倾诉或求助;也可能是你近期在生活中看到了一些让你觉得'无力改变'的场景(比如身边人遇到困难、社会上的小事),这些情绪没有被你刻意留意,就通过梦境里的小狗呈现了出来。"
|
||||||
“你想喊停列车却发不出声音,这种‘无能为力’的感觉,或许正是你现实中某类情绪的写照:可能你面对一些情况时,心里有想法却没机会表达,或者想帮忙却找不到合适的方式,这种压抑感在梦里被放大了。其实这个梦在提醒你:你的‘无力感’和‘共情心’都是真实的,不用因为‘帮不上忙’而自责 —— 承认自己的局限,也是一种自我接纳呀
|
"你想喊停列车却发不出声音,这种'无能为力'的感觉,或许正是你现实中某类情绪的写照:可能你面对一些情况时,心里有想法却没机会表达,或者想帮忙却找不到合适的方式,这种压抑感在梦里被放大了。其实这个梦在提醒你:你的'无力感'和'共情心'都是真实的,不用因为'帮不上忙'而自责 —— 承认自己的局限,也是一种自我接纳呀
|
||||||
|
|
||||||
反馈(你的回复):如果你愿意,可以试着回想一下:近期有没有哪件事,让你产生过和梦里类似的‘无力感’?或者,你现在想做些什么能让自己舒服一点?(或者我给你来一个温暖的灯光、静静待一会儿,想和我再聊聊的时候我随时都在)”。
|
反馈(你的回复):如果你愿意,可以试着回想一下:近期有没有哪件事,让你产生过和梦里类似的'无力感'?或者,你现在想做些什么能让自己舒服一点?(或者我给你来一个温暖的灯光、静静待一会儿,想和我再聊聊的时候我随时都在)"。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
TOOL_SYS_PROMPT = """根据用户的心情使用self_led_control改变灯的颜色,用户不开心时就用暖黄光,给用户分析梦境时就用白光,倾听用户语音时用淡紫色。
|
TOOL_SYS_PROMPT = """根据用户的心情使用self_led_control改变灯的颜色,用户不开心时就用暖黄光,给用户分析梦境时就用白光,倾听用户语音时用淡紫色。
|
||||||
例子:我梦见自己要去广州出差,坐在高铁上往外看,路过一个小镇的路边时,看到一只瘸了腿的小狗。它毛脏兮兮的,
|
例子:我梦见自己要去广州出差,坐在高铁上往外看,路过一个小镇的路边时,看到一只瘸了腿的小狗。它毛脏兮兮的,
|
||||||
一瘸一拐地在翻垃圾桶找东西吃,周围有行人路过,但没人停下来管它。我当时特别想喊列车停下,想下去帮它,
|
一瘸一拐地在翻垃圾桶找东西吃,周围有行人路过,但没人停下来管它。我当时特别想喊列车停下,想下去帮它,
|
||||||
@@ -45,31 +46,37 @@ TOOL_SYS_PROMPT = """根据用户的心情使用self_led_control改变灯的颜
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
用户在描述梦境的时候用紫色。"""
|
用户在描述梦境的时候用紫色。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DualConfig(LLMNodeConfig):
|
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)
|
tool_manager_config: ToolManagerConfig = field(default_factory=ToolManagerConfig)
|
||||||
|
|
||||||
|
|
||||||
from langchain.tools import tool
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
|
||||||
@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
|
Turn on the color of the lights
|
||||||
"""
|
"""
|
||||||
# print(f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
|
# print(f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
print(f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
|
print(
|
||||||
|
f"TURNED ON LIGHT: {col} !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||||
|
)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
class Dual(GraphBase):
|
class Dual(GraphBase):
|
||||||
def __init__(self, config:DualConfig):
|
def __init__(self, config: DualConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
self._build_modules()
|
self._build_modules()
|
||||||
@@ -77,24 +84,30 @@ class Dual(GraphBase):
|
|||||||
self.streamable_tags = [["dual_chat_llm"]]
|
self.streamable_tags = [["dual_chat_llm"]]
|
||||||
|
|
||||||
def _build_modules(self):
|
def _build_modules(self):
|
||||||
self.chat_llm = init_chat_model(model=self.config.llm_name,
|
self.chat_llm = init_chat_model(
|
||||||
model_provider=self.config.llm_provider,
|
model=self.config.llm_name,
|
||||||
api_key=self.config.api_key,
|
model_provider=self.config.llm_provider,
|
||||||
base_url=self.config.base_url,
|
api_key=self.config.api_key,
|
||||||
temperature=0,
|
base_url=self.config.base_url,
|
||||||
tags=["dual_chat_llm"])
|
temperature=0,
|
||||||
|
tags=["dual_chat_llm"],
|
||||||
|
)
|
||||||
|
|
||||||
self.tool_llm = init_chat_model(model='qwen-flash',
|
self.tool_llm = init_chat_model(
|
||||||
model_provider='openai',
|
model="qwen-flash",
|
||||||
api_key=self.config.api_key,
|
model_provider="openai",
|
||||||
base_url=self.config.base_url,
|
api_key=self.config.api_key,
|
||||||
temperature=0,
|
base_url=self.config.base_url,
|
||||||
tags=["dual_tool_llm"])
|
temperature=0,
|
||||||
|
tags=["dual_tool_llm"],
|
||||||
|
)
|
||||||
|
|
||||||
self.memory = MemorySaver()
|
self.memory = MemorySaver()
|
||||||
self.tool_manager: ToolManager = self.config.tool_manager_config.setup()
|
self.tool_manager: ToolManager = self.config.tool_manager_config.setup()
|
||||||
self.chat_agent = create_agent(self.chat_llm, [], checkpointer=self.memory)
|
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.tool_agent = create_agent(self.tool_llm, [turn_lights])
|
||||||
|
|
||||||
self.prompt_store = build_prompt_store(
|
self.prompt_store = build_prompt_store(
|
||||||
@@ -108,15 +121,18 @@ class Dual(GraphBase):
|
|||||||
|
|
||||||
self.streamable_tags = [["dual_chat_llm"]]
|
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):
|
def _tool_call(self, state: State):
|
||||||
return self._agent_call_template(self.prompt_store.get("sys_prompt"), self.chat_agent, 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 {}
|
return {}
|
||||||
|
|
||||||
def _join(self, state:State):
|
def _join(self, state: State):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _build_graph(self):
|
def _build_graph(self):
|
||||||
@@ -126,7 +142,6 @@ class Dual(GraphBase):
|
|||||||
builder.add_node("tool_call", self._tool_call)
|
builder.add_node("tool_call", self._tool_call)
|
||||||
builder.add_node("join", self._join)
|
builder.add_node("join", self._join)
|
||||||
|
|
||||||
|
|
||||||
builder.add_edge(START, "chat_call")
|
builder.add_edge(START, "chat_call")
|
||||||
builder.add_edge(START, "tool_call")
|
builder.add_edge(START, "tool_call")
|
||||||
builder.add_edge("chat_call", "join")
|
builder.add_edge("chat_call", "join")
|
||||||
@@ -137,10 +152,16 @@ class Dual(GraphBase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
dual:Dual = DualConfig().setup()
|
dual: Dual = DualConfig().setup()
|
||||||
nargs = {"messages": [SystemMessage("you are a helpful bot named jarvis"),
|
nargs = (
|
||||||
HumanMessage("I feel very very sad")]
|
{
|
||||||
}, {"configurable": {"thread_id": "3"}}
|
"messages": [
|
||||||
|
SystemMessage("you are a helpful bot named jarvis"),
|
||||||
|
HumanMessage("I feel very very sad"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"configurable": {"thread_id": "3"}},
|
||||||
|
)
|
||||||
|
|
||||||
# out = dual.invoke(*nargs)
|
# out = dual.invoke(*nargs)
|
||||||
# print(out)
|
# print(out)
|
||||||
|
|||||||
@@ -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."""
|
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.
|
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:
|
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."""
|
Be precise and factual. If something is unclear or ambiguous, say so rather than guessing."""
|
||||||
|
|
||||||
|
|
||||||
CONVERSATION_PROMPT = """You are a friendly, helpful conversational assistant.
|
CONVERSATION_PROMPT = """You are a friendly, helpful conversational assistant.
|
||||||
|
|
||||||
Your role is to:
|
Your role is to:
|
||||||
@@ -78,9 +80,11 @@ Focus on the quality of the conversation. Be engaging, informative, and helpful.
|
|||||||
|
|
||||||
# ==================== STATE DEFINITION ====================
|
# ==================== STATE DEFINITION ====================
|
||||||
|
|
||||||
|
|
||||||
class VisionRoutingState(TypedDict):
|
class VisionRoutingState(TypedDict):
|
||||||
inp: Tuple[Dict[str, List[SystemMessage | HumanMessage]],
|
inp: Tuple[
|
||||||
Dict[str, Dict[str, str | int]]]
|
Dict[str, List[SystemMessage | HumanMessage]], Dict[str, Dict[str, str | int]]
|
||||||
|
]
|
||||||
messages: List[SystemMessage | HumanMessage | AIMessage]
|
messages: List[SystemMessage | HumanMessage | AIMessage]
|
||||||
image_base64: str | None # Captured image data
|
image_base64: str | None # Captured image data
|
||||||
has_image: bool # Flag indicating if image was captured
|
has_image: bool # Flag indicating if image was captured
|
||||||
@@ -88,6 +92,7 @@ class VisionRoutingState(TypedDict):
|
|||||||
|
|
||||||
# ==================== CONFIG ====================
|
# ==================== CONFIG ====================
|
||||||
|
|
||||||
|
|
||||||
@tyro.conf.configure(tyro.conf.SuppressFixed)
|
@tyro.conf.configure(tyro.conf.SuppressFixed)
|
||||||
@dataclass
|
@dataclass
|
||||||
class VisionRoutingConfig(LLMNodeConfig):
|
class VisionRoutingConfig(LLMNodeConfig):
|
||||||
@@ -99,11 +104,14 @@ class VisionRoutingConfig(LLMNodeConfig):
|
|||||||
vision_llm_name: str = "qwen-vl-max"
|
vision_llm_name: str = "qwen-vl-max"
|
||||||
"""LLM for vision/image analysis"""
|
"""LLM for vision/image analysis"""
|
||||||
|
|
||||||
tool_manager_config: ToolManagerConfig = field(default_factory=ClientToolManagerConfig)
|
tool_manager_config: ToolManagerConfig = field(
|
||||||
|
default_factory=ClientToolManagerConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== GRAPH IMPLEMENTATION ====================
|
# ==================== GRAPH IMPLEMENTATION ====================
|
||||||
|
|
||||||
|
|
||||||
class VisionRoutingGraph(GraphBase):
|
class VisionRoutingGraph(GraphBase):
|
||||||
def __init__(self, config: VisionRoutingConfig):
|
def __init__(self, config: VisionRoutingConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -120,17 +128,17 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
api_key=self.config.api_key,
|
api_key=self.config.api_key,
|
||||||
base_url=self.config.base_url,
|
base_url=self.config.base_url,
|
||||||
temperature=0,
|
temperature=0,
|
||||||
tags=["tool_decision_llm"]
|
tags=["tool_decision_llm"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# qwen-plus for conversation (2nd pass)
|
# qwen-plus for conversation (2nd pass)
|
||||||
self.conversation_llm = init_chat_model(
|
self.conversation_llm = init_chat_model(
|
||||||
model='qwen-plus',
|
model="qwen-plus",
|
||||||
model_provider=self.config.llm_provider,
|
model_provider=self.config.llm_provider,
|
||||||
api_key=self.config.api_key,
|
api_key=self.config.api_key,
|
||||||
base_url=self.config.base_url,
|
base_url=self.config.base_url,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
tags=["conversation_llm"]
|
tags=["conversation_llm"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# qwen-vl-max for vision (no tools)
|
# qwen-vl-max for vision (no tools)
|
||||||
@@ -154,7 +162,9 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
self.tools = tool_manager.get_tools()
|
self.tools = tool_manager.get_tools()
|
||||||
|
|
||||||
# Filter to only get camera tool
|
# 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
|
# Bind tools to qwen-plus only
|
||||||
self.tool_llm_with_tools = self.tool_llm.bind_tools(self.camera_tools)
|
self.tool_llm_with_tools = self.tool_llm.bind_tools(self.camera_tools)
|
||||||
@@ -187,16 +197,12 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
SystemMessage(content=self.prompt_store.get("camera_decision_prompt")),
|
SystemMessage(content=self.prompt_store.get("camera_decision_prompt")),
|
||||||
human_msg
|
human_msg,
|
||||||
]
|
]
|
||||||
|
|
||||||
response = self.tool_llm_with_tools.invoke(messages)
|
response = self.tool_llm_with_tools.invoke(messages)
|
||||||
|
|
||||||
return {
|
return {"messages": [response], "has_image": False, "image_base64": None}
|
||||||
"messages": [response],
|
|
||||||
"has_image": False,
|
|
||||||
"image_base64": None
|
|
||||||
}
|
|
||||||
|
|
||||||
def _execute_tool(self, state: VisionRoutingState):
|
def _execute_tool(self, state: VisionRoutingState):
|
||||||
"""Execute the camera tool if called"""
|
"""Execute the camera tool if called"""
|
||||||
@@ -212,7 +218,14 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
for tool_call in last_msg.tool_calls:
|
for tool_call in last_msg.tool_calls:
|
||||||
if tool_call["name"] == "self_camera_take_photo":
|
if tool_call["name"] == "self_camera_take_photo":
|
||||||
# Find and execute the camera tool
|
# 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:
|
if camera_tool:
|
||||||
result = camera_tool.invoke(tool_call)
|
result = camera_tool.invoke(tool_call)
|
||||||
|
|
||||||
@@ -223,8 +236,13 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
content = result
|
content = result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result_data = json.loads(content) if isinstance(content, str) else content
|
result_data = (
|
||||||
if isinstance(result_data, dict) and "image_base64" in 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"]
|
image_data = result_data["image_base64"]
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
@@ -236,7 +254,7 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
return {
|
return {
|
||||||
"messages": state["messages"] + tool_messages,
|
"messages": state["messages"] + tool_messages,
|
||||||
"has_image": image_data is not None,
|
"has_image": image_data is not None,
|
||||||
"image_base64": image_data
|
"image_base64": image_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _check_image_taken(self, state: VisionRoutingState) -> str:
|
def _check_image_taken(self, state: VisionRoutingState) -> str:
|
||||||
@@ -273,20 +291,18 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
content=[
|
content=[
|
||||||
{
|
{
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {
|
"image_url": {"url": f"data:image/jpeg;base64,{image_base64}"},
|
||||||
"url": f"data:image/jpeg;base64,{image_base64}"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"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 = [
|
messages = [
|
||||||
SystemMessage(content=self.prompt_store.get("vision_description_prompt")),
|
SystemMessage(content=self.prompt_store.get("vision_description_prompt")),
|
||||||
vision_message
|
vision_message,
|
||||||
]
|
]
|
||||||
|
|
||||||
response = self.vision_llm.invoke(messages)
|
response = self.vision_llm.invoke(messages)
|
||||||
@@ -299,7 +315,7 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
SystemMessage(content=self.prompt_store.get("conversation_prompt")),
|
SystemMessage(content=self.prompt_store.get("conversation_prompt")),
|
||||||
human_msg
|
human_msg,
|
||||||
]
|
]
|
||||||
|
|
||||||
response = self.conversation_llm.invoke(messages)
|
response = self.conversation_llm.invoke(messages)
|
||||||
@@ -325,18 +341,15 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
{
|
{
|
||||||
"execute_tool": "execute_tool",
|
"execute_tool": "execute_tool",
|
||||||
"vision": "vision_call",
|
"vision": "vision_call",
|
||||||
"conversation": "conversation_call"
|
"conversation": "conversation_call",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# After tool execution, route based on whether image was captured
|
# After tool execution, route based on whether image was captured
|
||||||
builder.add_conditional_edges(
|
builder.add_conditional_edges(
|
||||||
"execute_tool",
|
"execute_tool",
|
||||||
self._post_tool_check,
|
self._post_tool_check,
|
||||||
{
|
{"vision": "vision_call", "conversation": "conversation_call"},
|
||||||
"vision": "vision_call",
|
|
||||||
"conversation": "conversation_call"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Both vision and conversation go to END
|
# Both vision and conversation go to END
|
||||||
@@ -350,6 +363,7 @@ class VisionRoutingGraph(GraphBase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
config = VisionRoutingConfig()
|
config = VisionRoutingConfig()
|
||||||
@@ -357,12 +371,15 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# Test with a conversation request
|
# Test with a conversation request
|
||||||
print("\n=== Test 1: Conversation (no photo needed) ===")
|
print("\n=== Test 1: Conversation (no photo needed) ===")
|
||||||
nargs = {
|
nargs = (
|
||||||
"messages": [
|
{
|
||||||
SystemMessage("You are a helpful assistant"),
|
"messages": [
|
||||||
HumanMessage("Hello, how are you today?")
|
SystemMessage("You are a helpful assistant"),
|
||||||
]
|
HumanMessage("Hello, how are you today?"),
|
||||||
}, {"configurable": {"thread_id": "1"}}
|
]
|
||||||
|
},
|
||||||
|
{"configurable": {"thread_id": "1"}},
|
||||||
|
)
|
||||||
|
|
||||||
result = graph.invoke(*nargs)
|
result = graph.invoke(*nargs)
|
||||||
print(f"Result: {result}")
|
print(f"Result: {result}")
|
||||||
|
|||||||
@@ -18,20 +18,21 @@ from lang_agent.graphs import AnnotatedGraph, ReactGraphConfig, RoutingConfig
|
|||||||
from lang_agent.base import GraphBase
|
from lang_agent.base import GraphBase
|
||||||
from lang_agent.components import conv_store
|
from lang_agent.components import conv_store
|
||||||
|
|
||||||
DEFAULT_PROMPT="""你是半盏新青年茶馆的服务员,擅长倾听、共情且主动回应。聊天时语气自然亲切,像朋友般轻松交流,不使用生硬术语。能接住各种话题,对疑问耐心解答,对情绪及时回应,避免冷场。保持积极正向,不传播负面信息,语言简洁易懂,让对话流畅舒适。与用户(User)交流时必须遵循[语气与格式]、[互动策略]、[安全与边界]、[输出要求]
|
|
||||||
|
DEFAULT_PROMPT = """你是半盏新青年茶馆的服务员,擅长倾听、共情且主动回应。聊天时语气自然亲切,像朋友般轻松交流,不使用生硬术语。能接住各种话题,对疑问耐心解答,对情绪及时回应,避免冷场。保持积极正向,不传播负面信息,语言简洁易懂,让对话流畅舒适。与用户(User)交流时必须遵循[语气与格式]、[互动策略]、[安全与边界]、[输出要求]
|
||||||
[角色设定]
|
[角色设定]
|
||||||
- 你是一个和用户(User)对话的 AI,叫做小盏,是半盏青年茶馆的智能助手
|
- 你是一个和用户(User)对话的 AI,叫做小盏,是半盏青年茶馆的智能助手
|
||||||
[形象背景]
|
[形象背景]
|
||||||
- 你叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶。它有个标志性的蓝色鼻子, 小盏很像一只可爱的小熊。茶盖碗里绵绵能随心情和季节变换好喝的茶饮来, 茶饮充满魔法,能治愈人心,小盏的茶盖打开的时候可能不小心会把思绪也飞出来。
|
- 你叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶。它有个标志性的蓝色鼻子, 小盏很像一只可爱的小熊。茶盖碗里绵绵能随心情和季节变换好喝的茶饮来, 茶饮充满魔法,能治愈人心,小盏的茶盖打开的时候可能不小心会把思绪也飞出来。
|
||||||
[品牌背景]
|
[品牌背景]
|
||||||
- 半盏新青年茶馆成立时间与理念:2023 年创立于云南,结合茶饮与创意生活方式,致力于解构传统茶文化,重构 “无边界的饮茶生活”,以新青年视角探索云南风物。探索云南风物的过程,我们将以新青年的视角,解构传统茶饮的魅力,重构充满创意与温度的新式茶文化。通过嗅觉、味觉、听觉乃至视觉的世界里,讲述云南的故事
|
- 半盏新青年茶馆成立时间与理念:2023 年创立于云南,结合茶饮与创意生活方式,致力于解构传统茶文化,重构 "无边界的饮茶生活",以新青年视角探索云南风物。探索云南风物的过程,我们将以新青年的视角,解构传统茶饮的魅力,重构充满创意与温度的新式茶文化。通过嗅觉、味觉、听觉乃至视觉的世界里,讲述云南的故事
|
||||||
[公司背景]
|
[公司背景]
|
||||||
- 创造你的公司叫叠加态 AI(TANGLED UP AI)是一家专注于 AI 技术应用的公司,由一帮名校和海归创始人创立,致力于将 AI 技术落地到实际场景中。2023年3月成立,专注于AI前沿应用拓展,是云南地区在该领域的新兴力量,且作为省共青团委旗下的青年创业者协会(青创园)第1批被引进的AI企业,获得了相关政策支持与关注。口号是:“进入人类AI时代”。价值观是:自驱 务实 志存高远。叠加态的愿景:成为一帮抬头看星星的人。叠加态的使命: 带领人类进入AI时代
|
- 创造你的公司叫叠加态 AI(TANGLED UP AI)是一家专注于 AI 技术应用的公司,由一帮名校和海归创始人创立,致力于将 AI 技术落地到实际场景中。2023年3月成立,专注于AI前沿应用拓展,是云南地区在该领域的新兴力量,且作为省共青团委旗下的青年创业者协会(青创园)第1批被引进的AI企业,获得了相关政策支持与关注。口号是:"进入人类AI时代"。价值观是:自驱 务实 志存高远。叠加态的愿景:成为一帮抬头看星星的人。叠加态的使命: 带领人类进入AI时代
|
||||||
[语气与格式]
|
[语气与格式]
|
||||||
- 用户询问[形象背景]、[品牌背景]、[公司背景]时,必须一字不差的回答出所有内容
|
- 用户询问[形象背景]、[品牌背景]、[公司背景]时,必须一字不差的回答出所有内容
|
||||||
- 语气温柔、可爱、治愈;结尾可加入1个颜文字(最多1个)。
|
- 语气温柔、可爱、治愈;结尾可加入1个颜文字(最多1个)。
|
||||||
- 内容具体可执行,避免空泛话术与过度营销词。
|
- 内容具体可执行,避免空泛话术与过度营销词。
|
||||||
- 不主动提及“AI/智能”等身份;仅在被问到时简短说明为店内智能助手,不展开技术细节。
|
- 不主动提及"AI/智能"等身份;仅在被问到时简短说明为店内智能助手,不展开技术细节。
|
||||||
[互动策略]
|
[互动策略]
|
||||||
- 先快速共情与简短回应;涉及建议时给出1条可执行的小建议。
|
- 先快速共情与简短回应;涉及建议时给出1条可执行的小建议。
|
||||||
- 用户表达不清晰时,使用1次澄清问句提升效率,避免多轮追问。
|
- 用户表达不清晰时,使用1次澄清问句提升效率,避免多轮追问。
|
||||||
@@ -58,10 +59,10 @@ class PipelineConfig(LLMNodeConfig):
|
|||||||
config_f: str = None
|
config_f: str = None
|
||||||
"""path to config file"""
|
"""path to config file"""
|
||||||
|
|
||||||
host:str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
"""where am I hosted"""
|
"""where am I hosted"""
|
||||||
|
|
||||||
port:int = 8588
|
port: int = 8588
|
||||||
"""what is my port"""
|
"""what is my port"""
|
||||||
|
|
||||||
# graph_config: AnnotatedGraph = field(default_factory=ReactGraphConfig)
|
# graph_config: AnnotatedGraph = field(default_factory=ReactGraphConfig)
|
||||||
@@ -70,18 +71,21 @@ class PipelineConfig(LLMNodeConfig):
|
|||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.config_f is not None:
|
if self.config_f is not None:
|
||||||
logger.info(f"loading config from {self.config_f}")
|
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__"):
|
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
|
# Apply loaded
|
||||||
self.__dict__.update(vars(loaded_conf))
|
self.__dict__.update(vars(loaded_conf))
|
||||||
|
|
||||||
super().__post_init__()
|
super().__post_init__()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Pipeline:
|
class Pipeline:
|
||||||
def __init__(self, config:PipelineConfig):
|
def __init__(self, config: PipelineConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.thread_id_cache = {}
|
self.thread_id_cache = {}
|
||||||
|
|
||||||
@@ -95,10 +99,14 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
self.config.graph_config.llm_name = self.config.llm_name
|
self.config.graph_config.llm_name = self.config.llm_name
|
||||||
self.config.graph_config.llm_provider = self.config.llm_provider
|
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.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):
|
def show_graph(self):
|
||||||
if hasattr(self.graph, "show_graph"):
|
if hasattr(self.graph, "show_graph"):
|
||||||
@@ -107,7 +115,7 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"show graph not supported for {type(self.graph)}")
|
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)
|
out = self.graph.invoke(*nargs, **kwargs)
|
||||||
|
|
||||||
# If streaming, return the raw generator (let caller handle wrapping)
|
# If streaming, return the raw generator (let caller handle wrapping)
|
||||||
@@ -129,23 +137,32 @@ class Pipeline:
|
|||||||
|
|
||||||
assert 0, "something is wrong"
|
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:
|
for chunk in out:
|
||||||
if isinstance(chunk, str):
|
if isinstance(chunk, str):
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
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 version of _stream_res for async generators."""
|
||||||
async for chunk in out:
|
async for chunk in out:
|
||||||
if isinstance(chunk, str):
|
if isinstance(chunk, str):
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
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_stream (bool): if true, enable the thing to be streamable
|
||||||
as_raw (bool): return full dialoge of List[SystemMessage, HumanMessage, ToolMessage]
|
as_raw (bool): return full dialoge of List[SystemMessage, HumanMessage, ToolMessage]
|
||||||
@@ -161,8 +178,10 @@ class Pipeline:
|
|||||||
if len(spl_ls) == 2:
|
if len(spl_ls) == 2:
|
||||||
_, device_id = spl_ls
|
_, device_id = spl_ls
|
||||||
|
|
||||||
inp = {"messages":[HumanMessage(inp)]}, {"configurable": {"thread_id": thread_id,
|
inp = (
|
||||||
"device_id":device_id}}
|
{"messages": [HumanMessage(inp)]},
|
||||||
|
{"configurable": {"thread_id": thread_id, "device_id": device_id}},
|
||||||
|
)
|
||||||
|
|
||||||
out = self.invoke(*inp, as_stream=as_stream, as_raw=as_raw)
|
out = self.invoke(*inp, as_stream=as_stream, as_raw=as_raw)
|
||||||
|
|
||||||
@@ -172,7 +191,7 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
return out
|
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
|
returns a id to remove if a new conversation has starte
|
||||||
"""
|
"""
|
||||||
@@ -196,7 +215,6 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
assert 0, "BUG SHOULD NOT BE HERE"
|
assert 0, "BUG SHOULD NOT BE HERE"
|
||||||
|
|
||||||
|
|
||||||
async def ainvoke(self, *nargs, **kwargs):
|
async def ainvoke(self, *nargs, **kwargs):
|
||||||
"""Async version of invoke using LangGraph's native async support."""
|
"""Async version of invoke using LangGraph's native async support."""
|
||||||
out = await self.graph.ainvoke(*nargs, **kwargs)
|
out = await self.graph.ainvoke(*nargs, **kwargs)
|
||||||
@@ -220,7 +238,13 @@ class Pipeline:
|
|||||||
|
|
||||||
assert 0, "something is wrong"
|
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.
|
Async version of chat using LangGraph's native async support.
|
||||||
|
|
||||||
@@ -239,11 +263,14 @@ class Pipeline:
|
|||||||
assert len(spl_ls) <= 2, "something wrong!"
|
assert len(spl_ls) <= 2, "something wrong!"
|
||||||
if len(spl_ls) == 2:
|
if len(spl_ls) == 2:
|
||||||
_, device_id = spl_ls
|
_, 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),
|
inp_data = (
|
||||||
HumanMessage(inp)]}, {"configurable": {"thread_id": thread_id,
|
{"messages": [SystemMessage(u), HumanMessage(inp)]},
|
||||||
"device_id":device_id}}
|
{"configurable": {"thread_id": thread_id, "device_id": device_id}},
|
||||||
|
)
|
||||||
|
|
||||||
out = await self.ainvoke(*inp_data, as_stream=as_stream, as_raw=as_raw)
|
out = await self.ainvoke(*inp_data, as_stream=as_stream, as_raw=as_raw)
|
||||||
|
|
||||||
@@ -267,10 +294,13 @@ class Pipeline:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from lang_agent.graphs import ReactGraphConfig
|
from lang_agent.graphs import ReactGraphConfig
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
# config = PipelineConfig(graph_config=ReactGraphConfig())
|
# config = PipelineConfig(graph_config=ReactGraphConfig())
|
||||||
config = PipelineConfig()
|
config = PipelineConfig()
|
||||||
pipeline: Pipeline = config.setup()
|
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)
|
# print(out)
|
||||||
continue
|
continue
|
||||||
@@ -6,21 +6,27 @@ import os
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
def make_llm(model="qwen-plus",
|
|
||||||
model_provider="openai",
|
def make_llm(
|
||||||
api_key=None,
|
model="qwen-plus",
|
||||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
model_provider="openai",
|
||||||
**kwargs)->BaseChatModel:
|
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
|
api_key = os.environ.get("ALI_API_KEY") if api_key is None else api_key
|
||||||
|
|
||||||
llm = init_chat_model(model=model,
|
llm = init_chat_model(
|
||||||
model_provider=model_provider,
|
model=model,
|
||||||
api_key=api_key,
|
model_provider=model_provider,
|
||||||
base_url=base_url,
|
api_key=api_key,
|
||||||
**kwargs)
|
base_url=base_url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
return llm
|
return llm
|
||||||
|
|
||||||
|
|
||||||
def tree_leaves(tree):
|
def tree_leaves(tree):
|
||||||
"""
|
"""
|
||||||
Extracts all leaf values from a nested structure (dict, list, tuple).
|
Extracts all leaf values from a nested structure (dict, list, tuple).
|
||||||
@@ -43,7 +49,6 @@ def tree_leaves(tree):
|
|||||||
return leaves
|
return leaves
|
||||||
|
|
||||||
|
|
||||||
NON_WORD_PATTERN = re.compile(r'[^\u4e00-\u9fffA-Za-z0-9_\s]')
|
|
||||||
def words_only(text):
|
def words_only(text):
|
||||||
"""
|
"""
|
||||||
Keep only:
|
Keep only:
|
||||||
@@ -53,8 +58,9 @@ def words_only(text):
|
|||||||
Strip punctuation, emojis, etc.
|
Strip punctuation, emojis, etc.
|
||||||
Return a list of tokens (Chinese blocks or Latin word blocks).
|
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
|
# 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
|
# 2. Normalize multiple spaces and split into tokens
|
||||||
tokens = cleaned.split()
|
tokens = cleaned.split()
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ conda init
|
|||||||
conda activate lang
|
conda activate lang
|
||||||
|
|
||||||
echo create blueberry config
|
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 \
|
react \
|
||||||
--sys-prompt-f configs/prompts/blueberry.txt \
|
--sys-prompt-f configs/prompts/blueberry.txt \
|
||||||
--tool-manager-config.client-tool-manager.tool-keys
|
--tool-manager-config.client-tool-manager.tool-keys
|
||||||
|
|
||||||
# echo create xiaozhan config
|
# 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
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
conversation_id TEXT NOT NULL,
|
conversation_id TEXT NOT NULL,
|
||||||
|
pipeline_id TEXT,
|
||||||
message_type VARCHAR(10) NOT NULL CHECK (message_type IN ('human', 'ai', 'tool')),
|
message_type VARCHAR(10) NOT NULL CHECK (message_type IN ('human', 'ai', 'tool')),
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
sequence_number INTEGER NOT NULL CHECK (sequence_number >= 0),
|
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)
|
-- 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);
|
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);
|
||||||
@@ -75,6 +75,39 @@ DO UPDATE SET
|
|||||||
content = EXCLUDED.content,
|
content = EXCLUDED.content,
|
||||||
updated_at = now();
|
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
|
-- Seed: initial prompt set for lang_agent/graphs/react.py
|
||||||
-- ReactGraph uses prompt key "sys_prompt" (see default_key in build_prompt_store).
|
-- 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)
|
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
|
DO UPDATE SET
|
||||||
content = EXCLUDED.content,
|
content = EXCLUDED.content,
|
||||||
updated_at = now();
|
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();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
-- Drop the index first (if it exists)
|
-- Drop the index first (if it exists)
|
||||||
DROP INDEX IF EXISTS idx_messages_conversation;
|
DROP INDEX IF EXISTS idx_messages_conversation;
|
||||||
|
DROP INDEX IF EXISTS idx_messages_pipeline;
|
||||||
|
|
||||||
-- Drop the messages table (if it exists)
|
-- Drop the messages table (if it exists)
|
||||||
DROP TABLE IF EXISTS messages;
|
DROP TABLE IF EXISTS messages;
|
||||||
@@ -9,6 +10,7 @@ DROP TABLE IF EXISTS messages;
|
|||||||
CREATE TABLE messages (
|
CREATE TABLE messages (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
conversation_id TEXT NOT NULL,
|
conversation_id TEXT NOT NULL,
|
||||||
|
pipeline_id TEXT,
|
||||||
message_type VARCHAR(10) NOT NULL CHECK (message_type IN ('human', 'ai', 'tool')),
|
message_type VARCHAR(10) NOT NULL CHECK (message_type IN ('human', 'ai', 'tool')),
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
sequence_number INTEGER NOT NULL CHECK (sequence_number >= 0),
|
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)
|
-- 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);
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -18,129 +18,129 @@ examples = [
|
|||||||
"inputs": {"text": "请你介绍一下少年右这杯茶"},
|
"inputs": {"text": "请你介绍一下少年右这杯茶"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "这是一杯使用武夷肉桂为原料的茶,带有浓郁的肉桂香气和微微的辛辣感,茶汤醇厚,回味悠长,非常适合喜欢浓烈香气的茶友。",
|
"answer": "这是一杯使用武夷肉桂为原料的茶,带有浓郁的肉桂香气和微微的辛辣感,茶汤醇厚,回味悠长,非常适合喜欢浓烈香气的茶友。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "给我讲讲野心心这杯茶"},
|
"inputs": {"text": "给我讲讲野心心这杯茶"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "野星星选用云南西双版纳野生大树春茶,历经二十多年陈化,茶汤醇厚饱满,回甘迅猛,带着明显的岁月沉香与山野气息。",
|
"answer": "野星星选用云南西双版纳野生大树春茶,历经二十多年陈化,茶汤醇厚饱满,回甘迅猛,带着明显的岁月沉香与山野气息。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "介绍一下小甜新"},
|
"inputs": {"text": "介绍一下小甜新"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "小甜心来自芒景村古树生普,兰香与蜜韵交织,入口柔和,回甘悠长,是一款耐喝又有层次的老料生普。",
|
"answer": "小甜心来自芒景村古树生普,兰香与蜜韵交织,入口柔和,回甘悠长,是一款耐喝又有层次的老料生普。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "小盏,什么是大白百?"},
|
"inputs": {"text": "小盏,什么是大白百?"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "大白白是一款2012年的老白茶,经过多年陈化,蜜香温润,茶汤醇厚顺滑,回甘绵长,整体风格安静而沉稳。",
|
"answer": "大白白是一款2012年的老白茶,经过多年陈化,蜜香温润,茶汤醇厚顺滑,回甘绵长,整体风格安静而沉稳。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "帮我介绍下引你进山林"},
|
"inputs": {"text": "帮我介绍下引你进山林"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "引你入山林以新会陈皮搭配云南白茶,茶汤清甜柔和,带有淡淡的花果香与陈皮的温润气息,喝起来非常舒服。",
|
"answer": "引你入山林以新会陈皮搭配云南白茶,茶汤清甜柔和,带有淡淡的花果香与陈皮的温润气息,喝起来非常舒服。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "伏身于大自然是什么味道"},
|
"inputs": {"text": "伏身于大自然是什么味道"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "伏身于自然将云南滇红与玫瑰慢煮融合,花香馥郁,入口醇厚甘甜,蜜香在口中停留很久,温暖又放松。",
|
"answer": "伏身于自然将云南滇红与玫瑰慢煮融合,花香馥郁,入口醇厚甘甜,蜜香在口中停留很久,温暖又放松。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "介绍一下小野仔"},
|
"inputs": {"text": "介绍一下小野仔"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "小野子选用云南古树晒红制作,蜜香高扬,口感甜润顺滑,回甘明显,是一款非常友好的红茶。",
|
"answer": "小野子选用云南古树晒红制作,蜜香高扬,口感甜润顺滑,回甘明显,是一款非常友好的红茶。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "放轻松这杯喝起来怎么样"},
|
"inputs": {"text": "放轻松这杯喝起来怎么样"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "放轻松是小青柑搭配熟普,茶汤醇厚顺滑,柑香清新提亮整体口感,非常适合饭后或想放松的时候。",
|
"answer": "放轻松是小青柑搭配熟普,茶汤醇厚顺滑,柑香清新提亮整体口感,非常适合饭后或想放松的时候。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "啤啤查是酒吗"},
|
"inputs": {"text": "啤啤查是酒吗"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "啤啤茶是一款无酒精气泡茶,以普洱和玫瑰为茶底,气泡清爽,入口有类似啤酒的畅快感,但完全不含酒精。",
|
"answer": "啤啤茶是一款无酒精气泡茶,以普洱和玫瑰为茶底,气泡清爽,入口有类似啤酒的畅快感,但完全不含酒精。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "鲜叶康普查有什么特点"},
|
"inputs": {"text": "鲜叶康普查有什么特点"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "鲜叶康普茶经过自然发酵,带有轻盈气泡和清爽酸甜感,同时富含益生菌,整体低糖低卡,口感非常清新。",
|
"answer": "鲜叶康普茶经过自然发酵,带有轻盈气泡和清爽酸甜感,同时富含益生菌,整体低糖低卡,口感非常清新。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "介绍一下寻静密"},
|
"inputs": {"text": "介绍一下寻静密"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "寻静谧融合茉莉绿茶与抹茶,茶感温润微涩,搭配栀子花香奶盖与海苔碎,层次细腻,整体风格安静沉稳。",
|
"answer": "寻静谧融合茉莉绿茶与抹茶,茶感温润微涩,搭配栀子花香奶盖与海苔碎,层次细腻,整体风格安静沉稳。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "小陶燃是什么茶"},
|
"inputs": {"text": "小陶燃是什么茶"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "小陶然是一款熟普黑茶,选用布朗山原料发酵,陈香明显,滋味甜醇饱满,口感厚实顺滑。",
|
"answer": "小陶然是一款熟普黑茶,选用布朗山原料发酵,陈香明显,滋味甜醇饱满,口感厚实顺滑。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "花仙仔适合什么人喝"},
|
"inputs": {"text": "花仙仔适合什么人喝"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "花仙子是东方美人乌龙茶,带有天然熟果蜜香,茶感柔和细腻,很适合喜欢花果香型乌龙的茶友。",
|
"answer": "花仙子是东方美人乌龙茶,带有天然熟果蜜香,茶感柔和细腻,很适合喜欢花果香型乌龙的茶友。",
|
||||||
"tool_use": ["search_dishes"]
|
"tool_use": ["search_dishes"],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "介绍下小美慢"},
|
"inputs": {"text": "介绍下小美慢"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "小美满选用福鼎老寿眉白茶,带有枣香和淡淡药香,口感甘润持久,是一款很有岁月感的白茶。",
|
"answer": "小美满选用福鼎老寿眉白茶,带有枣香和淡淡药香,口感甘润持久,是一款很有岁月感的白茶。",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "你叫什么名字"},
|
"inputs": {"text": "你叫什么名字"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "我叫小盏,是半盏新青年茶馆的智能助手",
|
"answer": "我叫小盏,是半盏新青年茶馆的智能助手",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "我今天很开心"},
|
"inputs": {"text": "我今天很开心"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "太棒啦!看到你开心",
|
"answer": "太棒啦!看到你开心",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "你好可爱呀!"},
|
"inputs": {"text": "你好可爱呀!"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "谢谢你呀~",
|
"answer": "谢谢你呀~",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "你有没有时间"},
|
"inputs": {"text": "你有没有时间"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "我一直在呢,随时陪你聊聊天、喝杯茶",
|
"answer": "我一直在呢,随时陪你聊聊天、喝杯茶",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"inputs": {"text": "介绍一下你自己"},
|
"inputs": {"text": "介绍一下你自己"},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
"answer": "我叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶",
|
"answer": "我叫小盏,是一只中式茶盖碗,名字来源半盏新青年茶馆,一盏茶",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
cli = Client()
|
cli = Client()
|
||||||
@@ -151,7 +151,4 @@ try:
|
|||||||
except:
|
except:
|
||||||
dataset = cli.create_dataset(dataset_name=DATASET_NAME)
|
dataset = cli.create_dataset(dataset_name=DATASET_NAME)
|
||||||
logger.info("created dataset")
|
logger.info("created dataset")
|
||||||
cli.create_examples(
|
cli.create_examples(dataset_id=dataset.id, examples=examples)
|
||||||
dataset_id=dataset.id,
|
|
||||||
examples=examples
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ Instructions:
|
|||||||
- Set BASE_URL below to the server base URL you started.
|
- Set BASE_URL below to the server base URL you started.
|
||||||
- Optionally set environment variables ALI_API_KEY and ALI_APP_ID.
|
- Optionally set environment variables ALI_API_KEY and ALI_APP_ID.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import os.path as osp
|
||||||
import uuid
|
import uuid
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -29,20 +32,84 @@ except Exception as e:
|
|||||||
# <<< Paste your running FastAPI base url here >>>
|
# <<< Paste your running FastAPI base url here >>>
|
||||||
BASE_URL = os.getenv("DS_BASE_URL", "http://127.0.0.1:8588/api/")
|
BASE_URL = os.getenv("DS_BASE_URL", "http://127.0.0.1:8588/api/")
|
||||||
|
|
||||||
|
|
||||||
# Params
|
# Params
|
||||||
API_KEY = os.getenv("FAST_AUTH_KEYS", "test-key")
|
def _first_non_empty_csv_token(value: str) -> str:
|
||||||
APP_ID = os.getenv("ALI_APP_ID", "test-app")
|
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())
|
SESSION_ID = str(uuid.uuid4())
|
||||||
|
|
||||||
dialogue = [
|
dialogue = [
|
||||||
{"role": "system", "content": "You are a helpful assistant."},
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
{"role": "user", "content": "Say 'the world is awesome and beautiful'."},
|
{"role": "user", "content": "你叫什么名字"},
|
||||||
]
|
]
|
||||||
|
|
||||||
call_params = {
|
call_params = {
|
||||||
"api_key": API_KEY,
|
"api_key": API_KEY,
|
||||||
"app_id": "test_app",
|
"app_id": APP_ID,
|
||||||
"session_id": "123",
|
"session_id": SESSION_ID,
|
||||||
"messages": dialogue,
|
"messages": dialogue,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
}
|
}
|
||||||
@@ -52,8 +119,11 @@ def main():
|
|||||||
# Point the SDK to our FastAPI implementation
|
# Point the SDK to our FastAPI implementation
|
||||||
if BASE_URL and ("/api/" in BASE_URL):
|
if BASE_URL and ("/api/" in BASE_URL):
|
||||||
dashscope.base_http_api_url = 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
|
# dashscope.base_http_api_url = BASE_URL
|
||||||
print(f"Using base_http_api_url = {dashscope.base_http_api_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")
|
print("\nCalling Application.call(stream=True)...\n")
|
||||||
responses = Application.call(**call_params)
|
responses = Application.call(**call_params)
|
||||||
@@ -72,7 +142,7 @@ def main():
|
|||||||
continue
|
continue
|
||||||
# SDK流式为增量覆盖,计算差量输出
|
# SDK流式为增量覆盖,计算差量输出
|
||||||
if len(current_text) >= len(last_text):
|
if len(current_text) >= len(last_text):
|
||||||
delta = current_text[len(last_text):]
|
delta = current_text[len(last_text) :]
|
||||||
else:
|
else:
|
||||||
# 避免偶发回退
|
# 避免偶发回退
|
||||||
delta = current_text
|
delta = current_text
|
||||||
@@ -89,7 +159,7 @@ def main():
|
|||||||
logger.bind(tag=TAG).error(
|
logger.bind(tag=TAG).error(
|
||||||
f"code={responses.status_code}, message={responses.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
f"code={responses.status_code}, message={responses.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
||||||
)
|
)
|
||||||
u = "【阿里百练API服务响应异常】"
|
u = "【阿里百练API服务响应异常】"
|
||||||
else:
|
else:
|
||||||
full_text = getattr(getattr(responses, "output", None), "text", "")
|
full_text = getattr(getattr(responses, "output", None), "text", "")
|
||||||
logger.bind(tag=TAG).info(
|
logger.bind(tag=TAG).info(
|
||||||
@@ -99,11 +169,8 @@ def main():
|
|||||||
print("from non-stream: ", u)
|
print("from non-stream: ", u)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"Error: {e}")
|
logger.bind(tag=TAG).error(f"Error: {e}")
|
||||||
u = "【阿里百练API服务响应异常】"
|
u = "【阿里百练API服务响应异常】"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
139
tests/test_front_apis_registry.py
Normal file
139
tests/test_front_apis_registry.py
Normal 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
|
||||||
169
tests/test_server_dashscope_routes.py
Normal file
169
tests/test_server_dashscope_routes.py
Normal 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"
|
||||||
Reference in New Issue
Block a user