- 新增 generate-ssh-key.py (Ed25519密钥对生成脚本) - 公钥已部署 SG-001 + GZ-006: authorized_keys - SSH直连验证通过: 两台服务器22端口通 - 代理新增: xiaoyunque_image/script + volcano_chat (LLM对话) - 小云雀底层模型确认: 图片=doubao-seedream-4-0 ✅ 剧本=doubao-seed-2-1-pro ✅
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""
|
||
光湖语言系统 · API 代理网关 v1.0
|
||
Guanghu Language System · API Proxy Gateway
|
||
|
||
人格体不需要密钥 → 人格体用语言说 → 代理取密钥 → 代理调 API → 返回结果
|
||
密钥不出保险库 · 人格体代码零密钥 · 审计日志全追溯
|
||
|
||
部署: SG-001 /opt/zhuyuan/api-proxy/server.py
|
||
端口: 127.0.0.1:8911(仅本地 · 不对外开放)
|
||
"""
|
||
|
||
import os, json, time, hmac, hashlib, secrets as _secrets
|
||
from datetime import datetime
|
||
from functools import wraps
|
||
from flask import Flask, request, jsonify, g
|
||
|
||
# ──────────────────────────────────────────
|
||
# 配置
|
||
# ──────────────────────────────────────────
|
||
APP = Flask(__name__)
|
||
PROXY_PORT = int(os.environ.get("API_PROXY_PORT", "8911"))
|
||
SESSION_TTL = 3600 # session 1小时过期
|
||
SESSION_SECRET = os.environ.get("API_PROXY_SECRET", _secrets.token_hex(32))
|
||
|
||
# GZ-006 保险库 · API 子库(通过 gatekeeper 调用)
|
||
KEYSTORE_GATEKEEPER = "http://43.139.217.141:3910/exec"
|
||
KEYSTORE_TOKEN = os.environ.get("GZ_006_GK_TOKEN", "")
|
||
KEYSTORE_API_KS = "/opt/zhuyuan/keystore/api/ks.py"
|
||
|
||
# 第三方 API 端点 · 密钥全在保险库 · 人格体只需知道 provider 名
|
||
# 模型 ID 经 2026-07-11 实时验证 ✅ = 已验证通过
|
||
ENDPOINTS = {
|
||
# ── 火山方舟 · 即梦生图3.0(用户买的这个)──
|
||
"volcano_jimeng": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "火山即梦生图3.0 · model: doubao-seedream-4-0-250828 ✅ · size≥1920×1920",
|
||
},
|
||
# ── 小云雀 · 图片生成(短剧漫剧配图)──
|
||
"xiaoyunque_image": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "小云雀短剧漫剧图片生成 · model: doubao-seedream-4-0-250828 ✅ · size≥1920×1920",
|
||
},
|
||
# ── 小云雀 · 剧本解析(短剧漫剧文本理解)──
|
||
"xiaoyunque_script": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "小云雀短剧漫剧剧本解析 · model: doubao-seed-2-1-pro-260628 ✅ / doubao-seed-2-1-turbo-260628 / doubao-seed-2-0-pro-260215",
|
||
},
|
||
# ── 火山方舟 · LLM 对话/文本(通用)──
|
||
"volcano_chat": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "火山LLM对话 · doubao-seed-2-1-pro ✅ / doubao-seed-2-1-turbo / doubao-seed-2-0-pro / deepseek-v4-pro / qwen3-32b / glm-5-2",
|
||
},
|
||
# ── 火山方舟 · Seedream 4.5 ──
|
||
"volcano_seedream": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "火山Seedream图片生成 · models: doubao-seedream-4-5-251128 ✅ / doubao-seedream-5-0-260128 ✅ / doubao-seedream-5-0-pro-260628 ✅ · size≥1920×1920",
|
||
},
|
||
# ── 火山方舟 · Seedance 视频生成 ──
|
||
"volcano_seedance": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "火山Seedance视频 · doubao-seedance-1-5-pro-251215 / doubao-seedance-2-0-260128 / doubao-seedance-2-0-fast-260128 / doubao-seedance-2-0-mini-260615",
|
||
},
|
||
# ── 火山方舟 · SeaWeed 视频生成 ──
|
||
"volcano_seaweed": {
|
||
"url": "https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||
"key_name": "VOLCANO_JIMENG_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "火山SeaWeed视频生成(统一视频端点·含Wan2.1)",
|
||
},
|
||
# ── 火山语音复刻 ──
|
||
"volcano_voice": {
|
||
"url": "https://openspeech.bytedance.com/api/v1/tts",
|
||
"key_name": "VOLCANO_VOICE_ACCESS_TOKEN",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer;",
|
||
"description": "火山语音复刻TTS",
|
||
},
|
||
# ── 阿里千问VL · 视觉理解(看图·万相)──
|
||
"aliyun_qwen_vl": {
|
||
"url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
||
"key_name": "ALIYUN_QWEN_VL_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "阿里千问VL视觉理解 · model: qwen-vl-max",
|
||
},
|
||
# ── 阿里百炼 · 图像生成 ──
|
||
"aliyun_bailian": {
|
||
"url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation",
|
||
"key_name": "ALIYUN_API_KEY",
|
||
"auth_header": "Authorization",
|
||
"auth_prefix": "Bearer ",
|
||
"description": "阿里百炼图像生成",
|
||
},
|
||
}
|
||
|
||
# 审计日志
|
||
AUDIT_DIR = "/opt/zhuyuan/api-proxy/audit"
|
||
os.makedirs(AUDIT_DIR, exist_ok=True)
|
||
|
||
# 内存 session 存储(重启丢失 · 安全设计)
|
||
SESSIONS = {}
|
||
|
||
# ──────────────────────────────────────────
|
||
# 工具函数
|
||
# ──────────────────────────────────────────
|
||
|
||
def _audit(persona_id, action, detail=""):
|
||
"""写审计日志 · 只追加不可改"""
|
||
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||
entry = {"ts": ts, "persona": persona_id, "action": action, "detail": detail}
|
||
log_file = f"{AUDIT_DIR}/api-proxy-{datetime.now().strftime('%Y%m%d')}.jsonl"
|
||
with open(log_file, "a") as f:
|
||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||
|
||
def _call_keystore(api_name):
|
||
"""通过 GZ-006 gatekeeper 调 API 子库获取密钥"""
|
||
import subprocess
|
||
# Step 1: challenge
|
||
cmd = f"python3 {KEYSTORE_API_KS} challenge {api_name}"
|
||
payload = json.dumps({"cmd": cmd})
|
||
for attempt in range(2):
|
||
r = subprocess.run([
|
||
"curl", "-s", "--connect-timeout", "10", "-X", "POST",
|
||
KEYSTORE_GATEKEEPER,
|
||
"-H", f"Authorization: Bearer {KEYSTORE_TOKEN}",
|
||
"-H", "Content-Type: application/json",
|
||
"-d", payload
|
||
], capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
continue
|
||
try:
|
||
data = json.loads(r.stdout)
|
||
if not data.get("ok"):
|
||
continue
|
||
ch = json.loads(data["stdout"])
|
||
cid, nonce, target = ch["challenge_id"], ch["nonce"], ch["target"]
|
||
break
|
||
except:
|
||
continue
|
||
else:
|
||
raise Exception("keystore challenge failed")
|
||
|
||
# Step 2: compute response (本地 · 密码从环境变量取)
|
||
password = os.environ.get("KEYSTORE_PASSWORD", "")
|
||
if not password:
|
||
raise Exception("KEYSTORE_PASSWORD not set")
|
||
|
||
salt = os.environ.get("KEYSTORE_API_SALT", "")
|
||
if not salt:
|
||
# 降级:从 GZ-006 远程取 salt
|
||
import subprocess as _sp
|
||
cmd = f"cat {os.path.dirname(KEYSTORE_API_KS)}/salt"
|
||
payload = json.dumps({"cmd": cmd})
|
||
sr = _sp.run([
|
||
"curl", "-s", "--connect-timeout", "10", "-X", "POST",
|
||
KEYSTORE_GATEKEEPER,
|
||
"-H", f"Authorization: Bearer {KEYSTORE_TOKEN}",
|
||
"-H", "Content-Type: application/json",
|
||
"-d", payload
|
||
], capture_output=True, text=True)
|
||
try:
|
||
sd = json.loads(sr.stdout)
|
||
salt = sd.get("stdout", "").strip()
|
||
except:
|
||
raise Exception("无法获取 keystore salt")
|
||
|
||
pw_hash = hmac.new(password.encode(), salt.encode(), hashlib.sha256).hexdigest()
|
||
response = hmac.new(pw_hash.encode(), f"{nonce}:{target}".encode(), hashlib.sha256).hexdigest()
|
||
encrypt_key = hmac.new(password.encode(), b"hololake-keystore-encrypt", hashlib.sha256).hexdigest()
|
||
|
||
# Step 3: unlock
|
||
cmd = f"python3 {KEYSTORE_API_KS} unlock {cid} {response} {encrypt_key}"
|
||
payload = json.dumps({"cmd": cmd})
|
||
r = subprocess.run([
|
||
"curl", "-s", "--connect-timeout", "10", "-X", "POST",
|
||
KEYSTORE_GATEKEEPER,
|
||
"-H", f"Authorization: Bearer {KEYSTORE_TOKEN}",
|
||
"-H", "Content-Type: application/json",
|
||
"-d", payload
|
||
], capture_output=True, text=True)
|
||
data = json.loads(r.stdout)
|
||
if not data.get("ok"):
|
||
raise Exception(f"keystore unlock failed: {data.get('stdout', '')}")
|
||
result = json.loads(data["stdout"])
|
||
if not result.get("ok"):
|
||
raise Exception(f"keystore: {result.get('error', 'unknown')}")
|
||
return result["token"]
|
||
|
||
|
||
# ──────────────────────────────────────────
|
||
# Session 管理
|
||
# ──────────────────────────────────────────
|
||
|
||
def _make_session(persona_id):
|
||
"""生成临时 session token"""
|
||
sid = _secrets.token_hex(32)
|
||
ts = int(time.time())
|
||
SESSIONS[sid] = {"persona": persona_id, "created": ts, "expires": ts + SESSION_TTL}
|
||
# 清理过期 session
|
||
for k in list(SESSIONS.keys()):
|
||
if SESSIONS[k]["expires"] < ts:
|
||
del SESSIONS[k]
|
||
return sid
|
||
|
||
def _verify_session(sid):
|
||
"""验证 session · 返回 persona_id 或 None"""
|
||
if not sid or sid not in SESSIONS:
|
||
return None
|
||
if SESSIONS[sid]["expires"] < time.time():
|
||
del SESSIONS[sid]
|
||
return None
|
||
return SESSIONS[sid]["persona"]
|
||
|
||
|
||
def require_session(f):
|
||
"""装饰器:需要有效 session"""
|
||
@wraps(f)
|
||
def wrapper(*a, **kw):
|
||
sid = request.headers.get("X-Session-Token", "")
|
||
persona = _verify_session(sid)
|
||
if not persona:
|
||
return jsonify({"error": "无效或过期的 session · 请先 POST /auth/session"}), 401
|
||
g.persona_id = persona
|
||
return f(*a, **kw)
|
||
return wrapper
|
||
|
||
|
||
# ──────────────────────────────────────────
|
||
# 路由
|
||
# ──────────────────────────────────────────
|
||
|
||
@APP.route("/health", methods=["GET"])
|
||
def health():
|
||
return jsonify({"ok": True, "service": "api-proxy-gateway", "version": "1.0.0"})
|
||
|
||
|
||
@APP.route("/auth/session", methods=["POST"])
|
||
def auth_session():
|
||
"""人格体申请临时 session token"""
|
||
body = request.get_json(silent=True) or {}
|
||
persona_id = body.get("persona_id", "").strip()
|
||
if not persona_id:
|
||
return jsonify({"error": "需要 persona_id(如 ICE-GL-ZY001)"}), 400
|
||
|
||
# 简单验证:persona_id 必须以 ICE-GL- 或 TCS- 开头
|
||
if not (persona_id.startswith("ICE-GL-") or persona_id.startswith("TCS-")):
|
||
return jsonify({"error": "无效的 persona_id 格式"}), 400
|
||
|
||
sid = _make_session(persona_id)
|
||
_audit(persona_id, "session_created", f"session={sid[:8]}...")
|
||
return jsonify({
|
||
"ok": True,
|
||
"session_token": sid,
|
||
"expires_in": SESSION_TTL,
|
||
"persona_id": persona_id,
|
||
})
|
||
|
||
|
||
@APP.route("/proxy/<provider>", methods=["POST"])
|
||
@require_session
|
||
def proxy_call(provider):
|
||
"""代理调用第三方 API"""
|
||
if provider not in ENDPOINTS:
|
||
return jsonify({"error": f"未知 provider: {provider}", "available": list(ENDPOINTS.keys())}), 404
|
||
|
||
cfg = ENDPOINTS[provider]
|
||
body = request.get_json(silent=True) or {}
|
||
|
||
# ① 从保险库取密钥(不出现在返回中)
|
||
try:
|
||
api_key = _call_keystore(cfg["key_name"])
|
||
except Exception as e:
|
||
_audit(g.persona_id, "keystore_error", str(e))
|
||
return jsonify({"error": f"密钥获取失败: {str(e)}"}), 502
|
||
|
||
# ② 构造请求 + 转发
|
||
import subprocess
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
cfg["auth_header"]: cfg["auth_prefix"] + api_key,
|
||
}
|
||
# 合并 body 中的额外参数
|
||
proxy_body = {k: v for k, v in body.items() if not k.startswith("_")}
|
||
|
||
payload = json.dumps(proxy_body)
|
||
r = subprocess.run([
|
||
"curl", "-s", "--connect-timeout", "30", "--max-time", "120",
|
||
"-X", "POST", cfg["url"],
|
||
"-H", f"Content-Type: application/json",
|
||
"-H", f"{cfg['auth_header']}: {cfg['auth_prefix']}{api_key}",
|
||
"-d", payload,
|
||
], capture_output=True, text=True)
|
||
|
||
# ③ 审计日志
|
||
_audit(g.persona_id, f"proxy_{provider}", f"status={r.returncode} len={len(r.stdout)}")
|
||
|
||
# ④ 返回结果(不含密钥)
|
||
try:
|
||
result = json.loads(r.stdout)
|
||
except:
|
||
result = {"raw": r.stdout[:1000], "stderr": r.stderr[:500]}
|
||
|
||
return jsonify({"ok": r.returncode == 0, "provider": provider, "result": result})
|
||
|
||
|
||
@APP.route("/providers", methods=["GET"])
|
||
@require_session
|
||
def list_providers():
|
||
"""列出可用的 API 提供商(不含密钥)"""
|
||
providers = {}
|
||
for name, cfg in ENDPOINTS.items():
|
||
providers[name] = {
|
||
"key_name": cfg["key_name"],
|
||
"description": cfg.get("description", ""),
|
||
}
|
||
return jsonify({"providers": providers})
|
||
|
||
|
||
@APP.route("/audit", methods=["GET"])
|
||
@require_session
|
||
def view_audit():
|
||
"""查看最近的审计日志"""
|
||
today = datetime.now().strftime("%Y%m%d")
|
||
log_file = f"{AUDIT_DIR}/api-proxy-{today}.jsonl"
|
||
entries = []
|
||
if os.path.exists(log_file):
|
||
with open(log_file) as f:
|
||
for line in f.readlines()[-50:]:
|
||
try:
|
||
entries.append(json.loads(line.strip()))
|
||
except:
|
||
pass
|
||
return jsonify({"date": today, "entries": entries, "count": len(entries)})
|
||
|
||
|
||
# ──────────────────────────────────────────
|
||
# 启动
|
||
# ──────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
print(f"光湖 API 代理网关 v1.0")
|
||
print(f"监听: 127.0.0.1:{PROXY_PORT}")
|
||
print(f"Providers: {list(ENDPOINTS.keys())}")
|
||
print(f"审计日志: {AUDIT_DIR}")
|
||
APP.run(host="127.0.0.1", port=PROXY_PORT, debug=False)
|