365 lines
14 KiB
Python
Raw Normal View History

"""
光湖语言系统 · 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 ✅ / TOKEN_STRING_REDACTED ✅ · 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视频 · TOKEN_STRING_REDACTED / doubao-seedance-2-0-260128 / TOKEN_STRING_REDACTED / TOKEN_STRING_REDACTED",
},
# ── 火山方舟 · 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)