|
""" |
|
Walsin Gateway v2.3 — Self-review 後定版(P0 全清) |
|
======================================================== |
|
v1(80 行):用 /v1/chat/completions(OpenAI),CC 打 /v1/messages 會 404 |
|
v2.0(364 行):Anthropic 原生 endpoint + 完整翻譯層 |
|
v2.1(502 行):修 3 個 review#2 點出的 bug |
|
- Auth 邏輯反轉 → 無條件檢查 |
|
- Streaming tool use 不工作 → 加 tool_calls delta |
|
- stop_reason 寫死 → 真實 finish_reason 映射 |
|
v2.2(620 行):TODO 全部 close(B 級實作 + tiktoken + benchmark + demo) |
|
v2.3(本版,674 行):self-review 找出 7 個漏洞,全清: |
|
- 🔴 Auth substring match 漏洞 → secrets.compare_digest 精確比對 |
|
- 🔴 SSE 透傳格式錯 → aiter_lines 改 aiter_bytes 直通 |
|
- 🔴 Sanitize 漏 tool_use input / tool_result content → 改遞迴 |
|
- 🟡 MASTER_KEY hardcoded default → 環境變數沒設給 warning |
|
- 🟡 demo_record.sh 缺 pre-flight check |
|
- 🟡 /health 沒回報 ollama 狀態 |
|
- 🟡 B 級走 cloud 沒標 sanitized → 加 X-Gateway-Sanitized header |
|
|
|
設計原則: |
|
- A 級:強制地端,即使 ANTHROPIC_API_KEY 設了也不走 cloud |
|
- B 級:地端優先 → 地端死了 sanitize 後 cloud → sanitize 沒命中拒絕 |
|
- C 級:cloud 優先,沒 key 才 fallback 地端 |
|
|
|
需求: |
|
pip install fastapi uvicorn httpx tiktoken |
|
ollama pull qwen3:14b # A/B 級主處理(地端最強) |
|
export ANTHROPIC_API_KEY=sk-ant-... (optional,不設則純地端 + B 級 fallback 失效) |
|
|
|
啟動: |
|
python3 gateway_v2_cc.py |
|
|
|
接 CC: |
|
export ANTHROPIC_BASE_URL=http://localhost:4000 |
|
export ANTHROPIC_AUTH_TOKEN=sk-walsin-test |
|
claude |
|
""" |
|
import os |
|
import re |
|
import json |
|
import uuid |
|
import copy |
|
import secrets |
|
import logging |
|
from typing import Optional |
|
import httpx |
|
from fastapi import FastAPI, Request, HTTPException |
|
from fastapi.responses import JSONResponse, StreamingResponse |
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") |
|
log = logging.getLogger("gateway-v2") |
|
|
|
# ============================================================ |
|
# 配置 — A/B/C 三級分類字典 + B 級脫敏字典 |
|
# ============================================================ |
|
# 設計原則(雙維度:敏感度 × 可用性): |
|
# A 級:強制地端,即使 ANTHROPIC_API_KEY 設了也不走 cloud |
|
# 理由:資料外洩風險 > 模型能力差異 |
|
# B 級:地端優先,地端失敗 fallback 脫敏後 cloud |
|
# 理由:能脫敏 → 可降級用 cloud 求能力 |
|
# C 級:cloud 優先,沒 key 才 fallback 地端 |
|
# 理由:純技術問題,無敏感度 |
|
# |
|
# v2.2:B 級完整實作 — 試地端 → 失敗則 sanitize → fallback cloud |
|
|
|
A_KEYWORDS = [ |
|
"[client_alpha]", "[project_xxx]", "BOM_FORMULA", |
|
# A 級:絕對不能上 cloud(就算脫敏也不行,全程地端) |
|
] |
|
|
|
B_KEYWORDS = [ |
|
"[internal_process]", "[vendor_quote]", "[employee_name]", |
|
# B 級:能脫敏就上 cloud,脫敏失敗就退回地端 |
|
] |
|
|
|
# B 級脫敏映射(命中關鍵字 → 替換為 placeholder) |
|
SANITIZE_MAP = { |
|
r"\[internal_process\]": "[PROCESS]", |
|
r"\[vendor_quote\]": "[QUOTE]", |
|
r"\[employee_name\]": "[PERSON]", |
|
# 通用 PII patterns |
|
r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b": "[EMAIL]", |
|
r"\b(?:\d{1,3}\.){3}\d{1,3}\b": "[IP]", |
|
r"\b\d{4}-\d{4}-\d{4}-\d{4}\b": "[CARD]", |
|
} |
|
SANITIZE_REGEX = [(re.compile(p, re.IGNORECASE), repl) for p, repl in SANITIZE_MAP.items()] |
|
|
|
A_PATTERN = re.compile("|".join(re.escape(k) for k in A_KEYWORDS), re.IGNORECASE) if A_KEYWORDS else None |
|
B_PATTERN = re.compile("|".join(re.escape(k) for k in B_KEYWORDS), re.IGNORECASE) if B_KEYWORDS else None |
|
|
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") |
|
MODEL_A_LEVEL = os.environ.get("MODEL_A_LEVEL", "qwen3:14b") # A 級用強模型 |
|
MODEL_B_LEVEL = os.environ.get("MODEL_B_LEVEL", "qwen3:14b") # B 級先試地端 |
|
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY") # 沒設 → C 級也走地端 |
|
ANTHROPIC_FALLBACK_MODEL = os.environ.get("ANTHROPIC_FALLBACK_MODEL", "qwen3:14b") |
|
LOCAL_HEALTH_TIMEOUT = float(os.environ.get("LOCAL_HEALTH_TIMEOUT", "5.0")) # B 級 試地端 healthcheck timeout |
|
B_LEVEL_CLOUD_FALLBACK = os.environ.get("B_LEVEL_CLOUD_FALLBACK", "1") == "1" # 關掉 = B 級永遠地端 |
|
|
|
# v2.3 Master key — 強制環境變數,沒設給警告(原本 hardcoded default 是壞習慣) |
|
MASTER_KEY = os.environ.get("MASTER_KEY") |
|
if not MASTER_KEY: |
|
MASTER_KEY = "sk-walsin-test" |
|
log.warning( |
|
"⚠️ MASTER_KEY 環境變數沒設,用 insecure default 'sk-walsin-test'。" |
|
"上線前必設 MASTER_KEY=<random>。" |
|
) |
|
|
|
# Tiktoken approximation(對 Qwen 偏估,但比 chunk count 準很多) |
|
try: |
|
import tiktoken |
|
_TIKTOKEN_ENC = tiktoken.encoding_for_model("gpt-4") |
|
def count_tokens(text: str) -> int: |
|
return len(_TIKTOKEN_ENC.encode(text)) if text else 0 |
|
except ImportError: |
|
log.warning("tiktoken 未安裝,token usage 會用粗估(每 4 chars ≈ 1 token)") |
|
def count_tokens(text: str) -> int: |
|
return max(1, len(text) // 4) if text else 0 |
|
|
|
app = FastAPI() |
|
|
|
# ============================================================ |
|
# Classifier |
|
# ============================================================ |
|
def extract_text(messages, system=None): |
|
"""抽出所有文字內容(用於分類)。""" |
|
parts = [] |
|
if isinstance(system, str): |
|
parts.append(system) |
|
elif isinstance(system, list): |
|
for s in system: |
|
if isinstance(s, dict) and s.get("type") == "text": |
|
parts.append(s.get("text", "")) |
|
for msg in messages: |
|
c = msg.get("content", "") |
|
if isinstance(c, str): |
|
parts.append(c) |
|
elif isinstance(c, list): |
|
for x in c: |
|
if isinstance(x, dict) and x.get("type") == "text": |
|
parts.append(x.get("text", "")) |
|
return "\n".join(parts) |
|
|
|
|
|
def classify(messages, system=None): |
|
"""三級分類:A → 強制地端,B → 地端優先,C → cloud 優先。""" |
|
text = extract_text(messages, system) |
|
if A_PATTERN: |
|
m = A_PATTERN.search(text) |
|
if m: |
|
return ("A", m.group()) |
|
if B_PATTERN: |
|
m = B_PATTERN.search(text) |
|
if m: |
|
return ("B", m.group()) |
|
return ("C", None) |
|
|
|
|
|
# ============================================================ |
|
# Sanitization (B 級 cloud fallback 前用) |
|
# ============================================================ |
|
def sanitize_string(text: str) -> tuple[str, bool]: |
|
"""脫敏文字。回傳 (脫敏後文字, 是否有命中替換)。""" |
|
if not text: |
|
return text, False |
|
hit = False |
|
out = text |
|
# 先擋 A 級字典(B 級 fallback 時不能讓 A 級 keyword 漏出去) |
|
if A_PATTERN: |
|
new = A_PATTERN.sub("[A_REDACTED]", out) |
|
if new != out: |
|
hit = True |
|
out = new |
|
# 再脫敏 B 級對應 placeholder |
|
for pat, repl in SANITIZE_REGEX: |
|
new = pat.sub(repl, out) |
|
if new != out: |
|
hit = True |
|
out = new |
|
return out, hit |
|
|
|
|
|
def _sanitize_value(v): |
|
"""v2.3 遞迴 sanitize — 處理 str / dict / list,涵蓋 tool_use input、tool_result content 等任意巢狀。 |
|
回 (sanitized_value, hit)。""" |
|
if isinstance(v, str): |
|
return sanitize_string(v) |
|
if isinstance(v, dict): |
|
out = {} |
|
any_hit = False |
|
for k, vv in v.items(): |
|
nv, h = _sanitize_value(vv) |
|
out[k] = nv |
|
any_hit = any_hit or h |
|
return out, any_hit |
|
if isinstance(v, list): |
|
out = [] |
|
any_hit = False |
|
for item in v: |
|
ni, h = _sanitize_value(item) |
|
out.append(ni) |
|
any_hit = any_hit or h |
|
return out, any_hit |
|
# int / float / bool / None — 不動 |
|
return v, False |
|
|
|
|
|
def sanitize_anthropic_body(body: dict) -> tuple[dict, bool]: |
|
"""對 Anthropic Messages body 脫敏。 |
|
v2.3:遞迴處理 system / messages 全部,涵蓋: |
|
- text content blocks |
|
- tool_use input(JSON dict) |
|
- tool_result content(string or list of blocks) |
|
- 任意巢狀 dict / list |
|
""" |
|
out = copy.deepcopy(body) |
|
any_hit = False |
|
|
|
if "system" in out: |
|
new_sys, h = _sanitize_value(out["system"]) |
|
out["system"] = new_sys |
|
any_hit = any_hit or h |
|
|
|
if "messages" in out: |
|
new_msgs, h = _sanitize_value(out["messages"]) |
|
out["messages"] = new_msgs |
|
any_hit = any_hit or h |
|
|
|
return out, any_hit |
|
|
|
|
|
def _check_master_key(auth_header: str) -> bool: |
|
"""v2.3 安全:精確比對 master_key,避免 substring match 漏洞。 |
|
支援 'Bearer XXX' / 'XXX' / 純 x-api-key。timing-safe 比對。""" |
|
if not auth_header: |
|
return False |
|
parts = auth_header.split(None, 1) |
|
if len(parts) == 2 and parts[0].lower() == "bearer": |
|
provided = parts[1] |
|
else: |
|
provided = parts[0] if len(parts) == 1 else auth_header |
|
return secrets.compare_digest(provided.encode(), MASTER_KEY.encode()) |
|
|
|
|
|
async def ollama_alive() -> bool: |
|
"""檢查地端 Ollama 是否健在(B 級 fallback 判斷)。""" |
|
try: |
|
async with httpx.AsyncClient(timeout=LOCAL_HEALTH_TIMEOUT) as client: |
|
r = await client.get(f"{OLLAMA_URL}/api/tags") |
|
return r.status_code == 200 |
|
except Exception: |
|
return False |
|
|
|
|
|
# ============================================================ |
|
# Anthropic ↔ OpenAI Translation |
|
# ============================================================ |
|
def anthropic_to_openai_request(body: dict, target_model: str) -> dict: |
|
"""Anthropic Messages → OpenAI Chat Completions。""" |
|
openai_messages = [] |
|
|
|
# Anthropic system 欄位 → OpenAI system message |
|
sys = body.get("system") |
|
if isinstance(sys, str) and sys: |
|
openai_messages.append({"role": "system", "content": sys}) |
|
elif isinstance(sys, list): |
|
sys_text = "\n".join(s.get("text", "") for s in sys if s.get("type") == "text") |
|
if sys_text: |
|
openai_messages.append({"role": "system", "content": sys_text}) |
|
|
|
# Anthropic messages → OpenAI messages |
|
for msg in body.get("messages", []): |
|
role = msg.get("role", "user") |
|
content = msg.get("content", "") |
|
|
|
# 字串 content 直接搬 |
|
if isinstance(content, str): |
|
openai_messages.append({"role": role, "content": content}) |
|
continue |
|
|
|
# list content(text + tool_use + tool_result) |
|
if isinstance(content, list): |
|
text_parts = [] |
|
tool_calls = [] |
|
tool_results = [] |
|
for block in content: |
|
btype = block.get("type") |
|
if btype == "text": |
|
text_parts.append(block.get("text", "")) |
|
elif btype == "tool_use": |
|
tool_calls.append({ |
|
"id": block.get("id", ""), |
|
"type": "function", |
|
"function": { |
|
"name": block.get("name", ""), |
|
"arguments": json.dumps(block.get("input", {})), |
|
}, |
|
}) |
|
elif btype == "tool_result": |
|
# OpenAI 要 tool result 變成獨立 message |
|
result_content = block.get("content", "") |
|
if isinstance(result_content, list): |
|
result_content = "\n".join( |
|
x.get("text", "") for x in result_content if x.get("type") == "text" |
|
) |
|
tool_results.append({ |
|
"role": "tool", |
|
"tool_call_id": block.get("tool_use_id", ""), |
|
"content": str(result_content), |
|
}) |
|
|
|
# assemble |
|
msg_out = {"role": role} |
|
if text_parts: |
|
msg_out["content"] = "\n".join(text_parts) |
|
if tool_calls: |
|
msg_out["tool_calls"] = tool_calls |
|
msg_out.setdefault("content", None) |
|
if msg_out.get("content") is not None or tool_calls: |
|
openai_messages.append(msg_out) |
|
for tr in tool_results: |
|
openai_messages.append(tr) |
|
|
|
out = { |
|
"model": target_model, |
|
"messages": openai_messages, |
|
"max_tokens": body.get("max_tokens", 4096), |
|
"stream": body.get("stream", False), |
|
} |
|
if "temperature" in body: |
|
out["temperature"] = body["temperature"] |
|
|
|
# Anthropic tools → OpenAI tools |
|
if body.get("tools"): |
|
out["tools"] = [ |
|
{ |
|
"type": "function", |
|
"function": { |
|
"name": t["name"], |
|
"description": t.get("description", ""), |
|
"parameters": t.get("input_schema", {}), |
|
}, |
|
} |
|
for t in body["tools"] |
|
] |
|
return out |
|
|
|
|
|
def openai_to_anthropic_response(openai_resp: dict, original_model: str) -> dict: |
|
"""OpenAI Chat 回應 → Anthropic Messages 回應格式。""" |
|
choice = openai_resp.get("choices", [{}])[0] |
|
msg = choice.get("message", {}) |
|
|
|
content_blocks = [] |
|
|
|
# text content |
|
if msg.get("content"): |
|
content_blocks.append({"type": "text", "text": msg["content"]}) |
|
|
|
# tool_calls → tool_use blocks |
|
for tc in msg.get("tool_calls", []) or []: |
|
fn = tc.get("function", {}) |
|
try: |
|
tool_input = json.loads(fn.get("arguments", "{}")) |
|
except json.JSONDecodeError: |
|
tool_input = {} |
|
content_blocks.append({ |
|
"type": "tool_use", |
|
"id": tc.get("id", f"toolu_{uuid.uuid4().hex[:16]}"), |
|
"name": fn.get("name", ""), |
|
"input": tool_input, |
|
}) |
|
|
|
finish = choice.get("finish_reason", "stop") |
|
stop_reason = { |
|
"stop": "end_turn", |
|
"length": "max_tokens", |
|
"tool_calls": "tool_use", |
|
}.get(finish, "end_turn") |
|
|
|
usage = openai_resp.get("usage", {}) |
|
return { |
|
"id": openai_resp.get("id", f"msg_{uuid.uuid4().hex[:16]}"), |
|
"type": "message", |
|
"role": "assistant", |
|
"model": original_model, |
|
"content": content_blocks or [{"type": "text", "text": ""}], |
|
"stop_reason": stop_reason, |
|
"stop_sequence": None, |
|
"usage": { |
|
"input_tokens": usage.get("prompt_tokens", 0), |
|
"output_tokens": usage.get("completion_tokens", 0), |
|
}, |
|
} |
|
|
|
|
|
# ============================================================ |
|
# SSE Streaming Translation (OpenAI → Anthropic) |
|
# v2.1 修正: |
|
# - tool_calls delta 累積 + 對映 Anthropic content_block_start (tool_use) |
|
# + input_json_delta(原本只翻譯 text,tool 會卡死 CC) |
|
# - stop_reason 用真實 finish_reason 映射(原本寫死 end_turn) |
|
# - content blocks 改 lazy open(只在真有內容時才送 start) |
|
# ============================================================ |
|
def _sse(event: str, payload: dict) -> str: |
|
return f"event: {event}\ndata: {json.dumps(payload)}\n\n" |
|
|
|
|
|
async def stream_anthropic_from_openai(openai_stream, msg_id: str, model: str): |
|
"""OpenAI SSE → Anthropic SSE。處理 text + tool_use 兩種 content block。""" |
|
# 開頭固定送 message_start |
|
yield _sse("message_start", { |
|
"type": "message_start", |
|
"message": { |
|
"id": msg_id, "type": "message", "role": "assistant", "model": model, |
|
"content": [], "stop_reason": None, |
|
"usage": {"input_tokens": 0, "output_tokens": 0}, |
|
}, |
|
}) |
|
|
|
# block 狀態管理(lazy open) |
|
next_block_index = 0 |
|
text_block_index: Optional[int] = None # text block 的 index(若已開) |
|
tool_blocks: dict = {} # OpenAI tool_call.index -> {our_index, id, name, args_buffer} |
|
finish_reason = "stop" |
|
accumulated_text = "" # 累積 text 用於 token 計算 |
|
|
|
async for line in openai_stream: |
|
line = line.strip() |
|
if not line or not line.startswith("data:"): |
|
continue |
|
data_str = line[5:].strip() |
|
if data_str == "[DONE]": |
|
break |
|
try: |
|
data = json.loads(data_str) |
|
except json.JSONDecodeError: |
|
continue |
|
|
|
choice = data.get("choices", [{}])[0] |
|
delta = choice.get("delta", {}) or {} |
|
if choice.get("finish_reason"): |
|
finish_reason = choice["finish_reason"] |
|
|
|
# text content delta |
|
text = delta.get("content") |
|
if text: |
|
if text_block_index is None: |
|
# lazy open text block |
|
text_block_index = next_block_index |
|
next_block_index += 1 |
|
yield _sse("content_block_start", { |
|
"type": "content_block_start", |
|
"index": text_block_index, |
|
"content_block": {"type": "text", "text": ""}, |
|
}) |
|
# v2.2:用 tiktoken 估算真實 token,而不是 chunk count |
|
accumulated_text += text |
|
yield _sse("content_block_delta", { |
|
"type": "content_block_delta", |
|
"index": text_block_index, |
|
"delta": {"type": "text_delta", "text": text}, |
|
}) |
|
|
|
# tool_calls delta(關鍵修正:CC 的 Read/Edit/Bash 都需要這個) |
|
for tc in delta.get("tool_calls") or []: |
|
tc_idx = tc.get("index", 0) |
|
fn = tc.get("function", {}) or {} |
|
|
|
if tc_idx not in tool_blocks: |
|
# 新 tool_call:close 之前的 text block(if open) |
|
if text_block_index is not None: |
|
yield _sse("content_block_stop", { |
|
"type": "content_block_stop", "index": text_block_index, |
|
}) |
|
text_block_index = None |
|
|
|
# open 新 tool_use block |
|
our_idx = next_block_index |
|
next_block_index += 1 |
|
tool_blocks[tc_idx] = { |
|
"our_index": our_idx, |
|
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}", |
|
"name": fn.get("name", ""), |
|
"args_buffer": "", |
|
} |
|
yield _sse("content_block_start", { |
|
"type": "content_block_start", |
|
"index": our_idx, |
|
"content_block": { |
|
"type": "tool_use", |
|
"id": tool_blocks[tc_idx]["id"], |
|
"name": tool_blocks[tc_idx]["name"], |
|
"input": {}, |
|
}, |
|
}) |
|
|
|
# arguments delta(partial JSON) |
|
args_delta = fn.get("arguments") |
|
if args_delta: |
|
tool_blocks[tc_idx]["args_buffer"] += args_delta |
|
yield _sse("content_block_delta", { |
|
"type": "content_block_delta", |
|
"index": tool_blocks[tc_idx]["our_index"], |
|
"delta": {"type": "input_json_delta", "partial_json": args_delta}, |
|
}) |
|
|
|
# close 所有開著的 blocks |
|
if text_block_index is not None: |
|
yield _sse("content_block_stop", { |
|
"type": "content_block_stop", "index": text_block_index, |
|
}) |
|
for tc_data in tool_blocks.values(): |
|
yield _sse("content_block_stop", { |
|
"type": "content_block_stop", "index": tc_data["our_index"], |
|
}) |
|
|
|
# 真實 stop_reason(不再寫死 end_turn) |
|
stop_reason = { |
|
"stop": "end_turn", |
|
"length": "max_tokens", |
|
"tool_calls": "tool_use", |
|
"function_call": "tool_use", |
|
"content_filter": "end_turn", |
|
}.get(finish_reason, "end_turn") |
|
|
|
# v2.2:tiktoken 估算 output_tokens(text + tool args 都算) |
|
output_tokens = count_tokens(accumulated_text) |
|
for tc_data in tool_blocks.values(): |
|
output_tokens += count_tokens(tc_data["args_buffer"]) |
|
|
|
yield _sse("message_delta", { |
|
"type": "message_delta", |
|
"delta": {"stop_reason": stop_reason, "stop_sequence": None}, |
|
"usage": {"output_tokens": output_tokens}, |
|
}) |
|
yield _sse("message_stop", {"type": "message_stop"}) |
|
|
|
|
|
# ============================================================ |
|
# Backend Forwarders |
|
# ============================================================ |
|
async def forward_to_ollama(anthropic_body: dict, target_model: str, original_model: str): |
|
"""A 級 → 翻譯成 OpenAI format,forward to Ollama。""" |
|
openai_body = anthropic_to_openai_request(anthropic_body, target_model) |
|
is_stream = openai_body.get("stream", False) |
|
|
|
if is_stream: |
|
async def gen(): |
|
async with httpx.AsyncClient(timeout=600) as client: |
|
async with client.stream( |
|
"POST", |
|
f"{OLLAMA_URL}/v1/chat/completions", |
|
json=openai_body, |
|
) as r: |
|
msg_id = f"msg_{uuid.uuid4().hex[:16]}" |
|
async for chunk in stream_anthropic_from_openai(r.aiter_lines(), msg_id, original_model): |
|
yield chunk |
|
return StreamingResponse(gen(), media_type="text/event-stream", |
|
headers={"X-Gateway-Decision": "A_LOCAL", "X-Gateway-Model": target_model}) |
|
else: |
|
async with httpx.AsyncClient(timeout=600) as client: |
|
r = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json=openai_body) |
|
data = r.json() |
|
anthropic_resp = openai_to_anthropic_response(data, original_model) |
|
return JSONResponse(anthropic_resp, |
|
headers={"X-Gateway-Decision": "A_LOCAL", "X-Gateway-Model": target_model}) |
|
|
|
|
|
async def forward_to_anthropic(anthropic_body: dict, request: Request, original_model: str): |
|
"""C 級 → 直接 proxy 到 api.anthropic.com(streaming + non-streaming)。""" |
|
if not ANTHROPIC_API_KEY: |
|
# 沒 key → fallback 走地端 14b |
|
log.warning("[C-LEVEL] ANTHROPIC_API_KEY 沒設 → fallback to local %s", ANTHROPIC_FALLBACK_MODEL) |
|
return await forward_to_ollama(anthropic_body, ANTHROPIC_FALLBACK_MODEL, original_model) |
|
|
|
headers = { |
|
"x-api-key": ANTHROPIC_API_KEY, |
|
"anthropic-version": request.headers.get("anthropic-version", "2023-06-01"), |
|
"content-type": "application/json", |
|
} |
|
is_stream = anthropic_body.get("stream", False) |
|
|
|
if is_stream: |
|
# v2.3:用 aiter_bytes 直通 — aiter_lines 會剝掉 \n\n event 結尾,SSE 格式會壞 |
|
async def gen(): |
|
async with httpx.AsyncClient(timeout=600) as client: |
|
async with client.stream( |
|
"POST", "https://api.anthropic.com/v1/messages", |
|
json=anthropic_body, headers=headers, |
|
) as r: |
|
async for chunk in r.aiter_bytes(): |
|
yield chunk |
|
return StreamingResponse(gen(), media_type="text/event-stream", |
|
headers={"X-Gateway-Decision": "C_CLOUD", "X-Gateway-Model": original_model}) |
|
else: |
|
async with httpx.AsyncClient(timeout=600) as client: |
|
r = await client.post("https://api.anthropic.com/v1/messages", |
|
json=anthropic_body, headers=headers) |
|
return JSONResponse(r.json(), status_code=r.status_code, |
|
headers={"X-Gateway-Decision": "C_CLOUD", "X-Gateway-Model": original_model}) |
|
|
|
|
|
# ============================================================ |
|
# Main Endpoint |
|
# ============================================================ |
|
@app.post("/v1/messages") |
|
async def messages(request: Request): |
|
# v2.3 安全:精確 token 比對(避免 substring match 漏洞,例如 'sk-test' 通過 'sk-test-extra') |
|
# 兼容兩種 header:Authorization: Bearer XXX / x-api-key: XXX |
|
auth = ( |
|
request.headers.get("authorization", "") |
|
or "Bearer " + (request.headers.get("x-api-key") or "") |
|
) |
|
if not _check_master_key(auth): |
|
raise HTTPException(401, "bad master key") |
|
|
|
body = await request.json() |
|
original_model = body.get("model", "claude-opus-4-7") |
|
decision, keyword = classify(body.get("messages", []), body.get("system")) |
|
|
|
# 雙維度決策:A 級永遠地端(即使有 cloud key);B 級地端優先 → fallback 脫敏 cloud;C 級 cloud 優先 |
|
if decision == "A": |
|
log.warning(f"[A-LEVEL] 命中 '{keyword}' → 強制地端 {MODEL_A_LEVEL}(即使有 cloud key 也不走)") |
|
return await forward_to_ollama(body, MODEL_A_LEVEL, original_model) |
|
|
|
elif decision == "B": |
|
# v2.2 完整 B 級實作:試地端 → 失敗 sanitize → cloud(若 cloud key 在且 fallback 啟用) |
|
if await ollama_alive(): |
|
log.warning(f"[B-LEVEL] 命中 '{keyword}' → 地端 {MODEL_B_LEVEL}(地端健在,優先地端)") |
|
return await forward_to_ollama(body, MODEL_B_LEVEL, original_model) |
|
|
|
# 地端死了,看能不能 fallback cloud |
|
if not (ANTHROPIC_API_KEY and B_LEVEL_CLOUD_FALLBACK): |
|
log.error(f"[B-LEVEL] 地端不可用 + 無 cloud fallback,拒絕請求") |
|
raise HTTPException(503, "B-level: local unavailable, cloud fallback disabled") |
|
|
|
sanitized_body, hit = sanitize_anthropic_body(body) |
|
if not hit: |
|
# 地端死了 + 沒有任何 sanitize 命中(意外狀況,B 級命中字典但脫敏沒抓到) |
|
log.error(f"[B-LEVEL] 地端不可用 + 脫敏未命中(B 字典 vs 脫敏字典不一致?)拒絕") |
|
raise HTTPException(500, "B-level: local down + sanitization mismatch") |
|
|
|
log.warning(f"[B-LEVEL] 命中 '{keyword}' → 地端不可用 → 脫敏後 fallback cloud(已 sanitize)") |
|
# v2.3:讓 client 知道資料被脫敏(透明度) |
|
resp = await forward_to_anthropic(sanitized_body, request, original_model) |
|
try: |
|
resp.headers["X-Gateway-Sanitized"] = "1" |
|
except Exception: |
|
pass # streaming response header 已固定 |
|
return resp |
|
|
|
else: |
|
log.info(f"[C-LEVEL] → cloud {original_model}" if ANTHROPIC_API_KEY else f"[C-LEVEL] no key → local {ANTHROPIC_FALLBACK_MODEL}") |
|
return await forward_to_anthropic(body, request, original_model) |
|
|
|
|
|
@app.get("/health") |
|
async def health(): |
|
# v2.3:回報 ollama 狀態給 monitoring |
|
ollama_ok = await ollama_alive() |
|
return { |
|
"status": "ok", |
|
"ollama": "alive" if ollama_ok else "down", |
|
"a_level_model": MODEL_A_LEVEL, |
|
"b_level_model": MODEL_B_LEVEL, |
|
"b_cloud_fallback": B_LEVEL_CLOUD_FALLBACK, |
|
"c_level": "cloud" if ANTHROPIC_API_KEY else f"local fallback ({ANTHROPIC_FALLBACK_MODEL})", |
|
"keywords_count": len(A_KEYWORDS), |
|
} |
|
|
|
|
|
if __name__ == "__main__": |
|
import uvicorn |
|
log.info(f"Walsin Gateway v2 starting...") |
|
log.info(f" A-LEVEL model: {MODEL_A_LEVEL}") |
|
log.info(f" C-LEVEL: {'cloud Claude' if ANTHROPIC_API_KEY else f'local fallback {ANTHROPIC_FALLBACK_MODEL}'}") |
|
log.info(f" A_KEYWORDS: {len(A_KEYWORDS)} entries") |
|
uvicorn.run(app, host="0.0.0.0", port=4000, log_level="info") |