From 93c66bfdfb642d467443f1638e3c74b4f830f82c Mon Sep 17 00:00:00 2001 From: karuo Date: Tue, 24 Feb 2026 20:10:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20=E5=8D=A1=E8=8B=A5AI=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=202026-02-24=2020:10=20|=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=9A=E8=BF=90=E8=90=A5=E4=B8=AD=E6=9E=A2=E3=80=81=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E4=B8=AD=E6=9E=A2=E5=B7=A5=E4=BD=9C=E5=8F=B0=20|=20?= =?UTF-8?q?=E6=8E=92=E9=99=A4=20>20MB:=2012=20=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 运营中枢/scripts/karuo_ai_gateway/README.md | 10 ++ 运营中枢/scripts/karuo_ai_gateway/main.py | 152 +++++++++++++++++++- 运营中枢/工作台/gitea_push_log.md | 1 + 运营中枢/工作台/代码管理.md | 1 + 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/运营中枢/scripts/karuo_ai_gateway/README.md b/运营中枢/scripts/karuo_ai_gateway/README.md index b8332a32..80c972d2 100644 --- a/运营中枢/scripts/karuo_ai_gateway/README.md +++ b/运营中枢/scripts/karuo_ai_gateway/README.md @@ -72,6 +72,16 @@ curl -s "http://127.0.0.1:8000/v1/skills" \ curl -s "http://127.0.0.1:8000/v1/health" ``` +## Cursor 配置(OpenAI 兼容) + +如果你希望在 Cursor 的「API Keys」里把卡若AI网关当成一个 OpenAI 兼容后端: + +1. 打开 Cursor → Settings → API Keys +2. `OpenAI API Key`:填你的 **dept_key**(例如“卡若公司”的 key) +3. 打开 `Override OpenAI Base URL`:填 `http://127.0.0.1:8000` + - 不要填 `/v1/chat` + - Cursor 会调用:`POST /v1/chat/completions` + ## 外网暴露 - **本机 + ngrok**:`ngrok http 8000`,用给出的 https 地址作为 YOUR_DOMAIN。 diff --git a/运营中枢/scripts/karuo_ai_gateway/main.py b/运营中枢/scripts/karuo_ai_gateway/main.py index b4c8ecad..b9ea5913 100644 --- a/运营中枢/scripts/karuo_ai_gateway/main.py +++ b/运营中枢/scripts/karuo_ai_gateway/main.py @@ -90,6 +90,22 @@ def _tenant_by_key(cfg: Dict[str, Any], api_key_plain: str) -> Optional[Dict[str return None +def _get_api_key_from_request(request: Request, cfg: Dict[str, Any]) -> str: + """ + 兼容两种鉴权头: + - X-Karuo-Api-Key: (原生网关方式) + - Authorization: Bearer (OpenAI 兼容客户端常用) + """ + header_name = _auth_header_name(cfg) + api_key = request.headers.get(header_name, "").strip() + if api_key: + return api_key + auth = request.headers.get("authorization", "").strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return "" + + def _rpm_allow(tenant_id: str, rpm: int) -> bool: """ 极简内存限流(单进程);够用就行。 @@ -225,6 +241,39 @@ def build_reply_with_llm(prompt: str, cfg: Dict[str, Any], matched_skill: str, s return _template_reply(prompt, matched_skill, skill_path) +class OpenAIChatCompletionsRequest(BaseModel): + """ + OpenAI 兼容:只实现 Cursor 常用字段。 + """ + + model: str = "karuo-ai" + messages: List[Dict[str, Any]] + max_tokens: Optional[int] = None + temperature: Optional[float] = None + stream: Optional[bool] = None + + +def _messages_to_prompt(messages: List[Dict[str, Any]]) -> str: + """ + 优先取最后一条 user 消息;否则拼接全部文本。 + """ + last_user = "" + chunks: List[str] = [] + for m in messages or []: + role = str(m.get("role", "")).strip() + content = m.get("content", "") + if isinstance(content, list): + content = "\n".join( + str(x.get("text", "")) for x in content if isinstance(x, dict) and x.get("type") == "text" + ) + content = str(content) + if role and content: + chunks.append(f"{role}: {content}") + if role == "user" and content: + last_user = content + return (last_user or ("\n".join(chunks))).strip() + + def _template_reply(prompt: str, matched_skill: str, skill_path: str, error: str = "") -> str: """未配置 LLM 或调用失败时返回模板回复(仍含复盘格式)。""" err = f"\n(当前未配置 OPENAI_API_KEY 或调用失败:{error})" if error else "" @@ -274,8 +323,7 @@ async def chat(req: ChatRequest, request: Request): # 1) 鉴权(如果有配置文件就强制开启) tenant: Optional[Dict[str, Any]] = None if cfg: - header_name = _auth_header_name(cfg) - api_key = request.headers.get(header_name, "") + api_key = _get_api_key_from_request(request, cfg) tenant = _tenant_by_key(cfg, api_key) if not tenant: raise HTTPException(status_code=401, detail="invalid api key") @@ -334,6 +382,104 @@ async def chat(req: ChatRequest, request: Request): ) +@app.get("/v1/models") +def openai_models(): + """ + OpenAI 兼容:给 Cursor/其他客户端一个可选模型列表。 + """ + now = int(time.time()) + return { + "object": "list", + "data": [ + {"id": "karuo-ai", "object": "model", "created": now, "owned_by": "karuo-ai-gateway"}, + ], + } + + +@app.post("/v1/chat/completions") +async def openai_chat_completions(req: OpenAIChatCompletionsRequest, request: Request): + """ + OpenAI 兼容入口:Cursor 的 “Override OpenAI Base URL” 会请求这个接口。 + 鉴权:Authorization: Bearer (或 X-Karuo-Api-Key) + """ + cfg = load_config() + + tenant: Optional[Dict[str, Any]] = None + if cfg: + api_key = _get_api_key_from_request(request, cfg) + tenant = _tenant_by_key(cfg, api_key) + if not tenant: + raise HTTPException(status_code=401, detail="invalid api key") + + tenant_id = str((tenant or {}).get("id", "")).strip() + tenant_name = str((tenant or {}).get("name", "")).strip() + + prompt = _messages_to_prompt(req.messages) + if not prompt: + raise HTTPException(status_code=400, detail="empty messages") + + limits = (tenant or {}).get("limits") or {} + max_prompt_chars = int(limits.get("max_prompt_chars", 0) or 0) + if max_prompt_chars and len(prompt) > max_prompt_chars: + raise HTTPException(status_code=413, detail="prompt too large") + + rpm = int(limits.get("rpm", 0) or 0) + if tenant_id and rpm and not _rpm_allow(tenant_id, rpm): + raise HTTPException(status_code=429, detail="rate limit exceeded") + + skill_id, matched_skill, skill_path = match_skill(prompt, cfg=cfg) + if cfg and not (skill_id and matched_skill and skill_path): + raise HTTPException(status_code=404, detail="no skill matched") + + if tenant: + allowed = (tenant.get("allowed_skills") or []) if isinstance(tenant, dict) else [] + allowed = [str(x).strip() for x in allowed if str(x).strip()] + if allowed: + if (skill_id not in allowed) and (skill_path not in allowed): + raise HTTPException(status_code=403, detail="skill not allowed for tenant") + + # OpenAI 客户端的 max_tokens:临时覆盖配置 + if req.max_tokens is not None: + llm_cfg = dict(_llm_settings(cfg)) + llm_cfg["max_tokens"] = int(req.max_tokens) + cfg = dict(cfg or {}) + cfg["llm"] = llm_cfg + + reply = build_reply_with_llm(prompt, cfg, matched_skill, skill_path) + + logging_cfg = (cfg or {}).get("logging") or {} + record: Dict[str, Any] = { + "ts": int(time.time()), + "tenant_id": tenant_id, + "tenant_name": tenant_name, + "skill_id": skill_id, + "matched_skill": matched_skill, + "skill_path": skill_path, + "client": request.client.host if request.client else "", + "ua": request.headers.get("user-agent", ""), + "openai_compatible": True, + "requested_model": req.model, + } + if bool(logging_cfg.get("log_request_body", False)): + record["prompt"] = prompt + _log_access(cfg, record) + + now = int(time.time()) + return { + "id": f"chatcmpl-{now}", + "object": "chat.completion", + "created": now, + "model": req.model or "karuo-ai", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": reply}, + "finish_reason": "stop", + } + ], + } + + @app.get("/v1/health") def health(): return {"ok": True} @@ -349,7 +495,7 @@ def allowed_skills(request: Request): if not cfg: return {"tenants_enabled": False, "allowed_skills": []} header_name = _auth_header_name(cfg) - api_key = request.headers.get(header_name, "") + api_key = _get_api_key_from_request(request, cfg) tenant = _tenant_by_key(cfg, api_key) if not tenant: raise HTTPException(status_code=401, detail="invalid api key") diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md index 15a54225..52fde338 100644 --- a/运营中枢/工作台/gitea_push_log.md +++ b/运营中枢/工作台/gitea_push_log.md @@ -126,3 +126,4 @@ | 2026-02-24 11:42:10 | 🔄 卡若AI 同步 2026-02-24 11:42 | 更新:金仓、水桥平台对接、运营中枢工作台 | 排除 >20MB: 10 个 | | 2026-02-24 16:28:06 | 🔄 卡若AI 同步 2026-02-24 16:28 | 更新:水桥平台对接、卡木、卡土、运营中枢工作台 | 排除 >20MB: 10 个 | | 2026-02-24 16:49:15 | 🔄 卡若AI 同步 2026-02-24 16:49 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 10 个 | +| 2026-02-24 19:59:17 | 🔄 卡若AI 同步 2026-02-24 19:59 | 更新:总索引与入口、水溪整理归档、卡木、运营中枢、运营中枢参考资料、运营中枢工作台 | 排除 >20MB: 12 个 | diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md index 99c6d4b3..a9ce76cb 100644 --- a/运营中枢/工作台/代码管理.md +++ b/运营中枢/工作台/代码管理.md @@ -129,3 +129,4 @@ | 2026-02-24 11:42:10 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 11:42 | 更新:金仓、水桥平台对接、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-02-24 16:28:06 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 16:28 | 更新:水桥平台对接、卡木、卡土、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-02-24 16:49:15 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 16:49 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | +| 2026-02-24 19:59:17 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 19:59 | 更新:总索引与入口、水溪整理归档、卡木、运营中枢、运营中枢参考资料、运营中枢工作台 | 排除 >20MB: 12 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |