feat: chat + optmz control panel
This commit is contained in:
@@ -284,17 +284,17 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id}
|
||||
|
||||
pclass = provider.get("class", "local")
|
||||
if pclass == "cloud" and provider.get("requires_key"):
|
||||
if provider.get("requires_key"):
|
||||
key_env = provider.get("key_env", "")
|
||||
if key_env and not os.environ.get(key_env):
|
||||
# Honest: do not silently downgrade a cloud request to a fake answer.
|
||||
return deterministic, {"mode": "deterministic", "reason": "cloud_key_unset", "provider": provider_id}
|
||||
return deterministic, {"mode": "deterministic", "reason": "provider_key_unset", "provider": provider_id}
|
||||
|
||||
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
|
||||
env = os.environ.copy()
|
||||
if pclass == "cloud":
|
||||
# Data policy 18.M.2: PII/secret must not reach a cloud model without the
|
||||
# C3 guard. Force the model-router preflight for any cloud-class provider.
|
||||
if pclass == "cloud" or provider.get("requires_preflight"):
|
||||
# Data policy 18.M.2: PII/secret must not reach a cloud or gateway model
|
||||
# without the C3 guard. Force the model-router preflight for either route.
|
||||
env["CASAN_PREFLIGHT"] = "1"
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
@@ -306,6 +306,22 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env,
|
||||
)
|
||||
# A locally available model is the safe operational fallback when a
|
||||
# configured cloud/gateway route is unavailable. The input is already
|
||||
# H4-scanned and the output still passes H4 below; we never fall back to
|
||||
# another network provider or bypass the router.
|
||||
fallback_from = ""
|
||||
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
|
||||
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
|
||||
fallback_model = fallback.get("model")
|
||||
if fallback_model and fallback.get("class", "local") == "local":
|
||||
fallback_from = provider_id
|
||||
r = subprocess.run(
|
||||
["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(),
|
||||
)
|
||||
if r.returncode == 0 and os.path.isfile(oj):
|
||||
provider_id, provider, model_spec, pclass = "local", fallback, fallback_model, "local"
|
||||
if r.returncode != 0 or not os.path.isfile(oj):
|
||||
return deterministic, {
|
||||
"mode": "deterministic",
|
||||
@@ -328,6 +344,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
"ollama": "ollama_local_real_tokens",
|
||||
"openai": "openai_api_real_tokens",
|
||||
"anthropic": "anthropic_api_real_tokens",
|
||||
"openai-compatible": "openai_compatible_api_real_tokens",
|
||||
}.get(_backend_of(model_spec), "model_real_tokens")
|
||||
return answer, {
|
||||
"mode": "model",
|
||||
@@ -338,6 +355,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
"input_tokens": int(out.get("input_tokens") or 0),
|
||||
"output_tokens": int(out.get("output_tokens") or 0),
|
||||
"cost_source": cost_source,
|
||||
"fallback_from": fallback_from or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -585,6 +603,76 @@ def verify_audit() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def history(args) -> int:
|
||||
"""Return a privacy-minimised, integrity-checked view of one actor's chats.
|
||||
|
||||
The Control Panel never reads the audit file itself. This harness command
|
||||
keeps tenant-path resolution, chain verification and field minimisation in
|
||||
the same trust boundary as chat writes. It exposes H4-scanned prompt
|
||||
previews and bounded governed-output previews only, never a raw user
|
||||
message or full audit record.
|
||||
"""
|
||||
if args.tenant and args.tenant != "default":
|
||||
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||
tenant_id = args.tenant or "default"
|
||||
records = []
|
||||
prev = GENESIS_HASH
|
||||
try:
|
||||
with open(audit_path(), encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
rest = {k: v for k, v in rec.items() if k != "record_hash"}
|
||||
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != rec.get("record_hash"):
|
||||
print(json.dumps({"ok": False, "reason": "chat_chain_broken"}, ensure_ascii=False))
|
||||
return 3
|
||||
prev = rec["record_hash"]
|
||||
if rec.get("tenant_id", "default") == tenant_id and rec.get("actor") == args.actor:
|
||||
records.append(rec)
|
||||
except OSError:
|
||||
records = []
|
||||
|
||||
conversations = {}
|
||||
for rec in records:
|
||||
chat_id = rec.get("chat_id") or "chat-default"
|
||||
current = conversations.get(chat_id)
|
||||
item = {
|
||||
"chat_id": chat_id,
|
||||
"title": (rec.get("safe_preview") or rec.get("answer_preview") or "Governed chat")[:80],
|
||||
"updated_at": rec.get("timestamp") or "",
|
||||
"turns": 1,
|
||||
"last_decision": rec.get("decision") or "UNKNOWN",
|
||||
"last_mode": rec.get("mode") or "READ_ONLY",
|
||||
}
|
||||
if current:
|
||||
item["turns"] = current["turns"] + 1
|
||||
if current.get("updated_at", "") > item["updated_at"]:
|
||||
item = current
|
||||
conversations[chat_id] = item
|
||||
|
||||
selected = records if not args.chat_id else [r for r in records if r.get("chat_id") == args.chat_id]
|
||||
selected = selected[-max(1, min(args.limit, 100)):]
|
||||
turns = [{
|
||||
"chat_id": rec.get("chat_id") or "chat-default",
|
||||
"turn_id": rec.get("turn_id") or "",
|
||||
"timestamp": rec.get("timestamp") or "",
|
||||
"mode": rec.get("mode") or "READ_ONLY",
|
||||
"risk": rec.get("risk") or "low",
|
||||
"decision": rec.get("decision") or "UNKNOWN",
|
||||
"prompt_preview": rec.get("safe_preview") or "",
|
||||
"answer_preview": (rec.get("answer_preview") or rec.get("answer") or "")[:180],
|
||||
"certified": rec.get("decision") == "ANSWERED",
|
||||
"audit_hash": rec.get("record_hash") or "",
|
||||
} for rec in selected]
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"conversations": sorted(conversations.values(), key=lambda item: item.get("updated_at", ""), reverse=True),
|
||||
"turns": turns,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -596,11 +684,18 @@ def main() -> int:
|
||||
askp.add_argument("--tenant", default="default")
|
||||
askp.add_argument("--stream", action="store_true")
|
||||
sub.add_parser("verify-audit")
|
||||
hp = sub.add_parser("history")
|
||||
hp.add_argument("--actor", required=True)
|
||||
hp.add_argument("--chat-id", default="")
|
||||
hp.add_argument("--tenant", default="default")
|
||||
hp.add_argument("--limit", type=int, default=50)
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "ask":
|
||||
return ask(args)
|
||||
if args.cmd == "verify-audit":
|
||||
return verify_audit()
|
||||
if args.cmd == "history":
|
||||
return history(args)
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
@@ -398,13 +398,13 @@ def _model_codegen_body(args):
|
||||
if not model_spec:
|
||||
return None, {"mode": "deterministic", "reason": "provider_unresolved"}
|
||||
pclass = provider.get("class", "local")
|
||||
if pclass == "cloud" and provider.get("requires_key"):
|
||||
if provider.get("requires_key"):
|
||||
key_env = provider.get("key_env", "")
|
||||
if key_env and not os.environ.get(key_env):
|
||||
return None, {"mode": "deterministic", "reason": "cloud_key_unset"}
|
||||
return None, {"mode": "deterministic", "reason": "provider_key_unset"}
|
||||
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
|
||||
env = os.environ.copy()
|
||||
if pclass == "cloud":
|
||||
if pclass == "cloud" or provider.get("requires_preflight"):
|
||||
env["CASAN_PREFLIGHT"] = "1"
|
||||
prompt = "\n".join([
|
||||
"You are CASAN's governed codegen assistant. Produce a SMALL Python draft",
|
||||
@@ -417,6 +417,16 @@ def _model_codegen_body(args):
|
||||
write_text(pf, prompt)
|
||||
r = subprocess.run(["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env)
|
||||
fallback_from = ""
|
||||
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
|
||||
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
|
||||
fallback_model = fallback.get("model")
|
||||
if fallback_model and fallback.get("class", "local") == "local":
|
||||
fallback_from = provider_id
|
||||
r = subprocess.run(["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
|
||||
if r.returncode == 0 and os.path.isfile(oj):
|
||||
provider_id, model_spec = "local", fallback_model
|
||||
if r.returncode != 0 or not os.path.isfile(oj):
|
||||
return None, {"mode": "deterministic", "reason": "model_unavailable"}
|
||||
try:
|
||||
@@ -432,6 +442,7 @@ def _model_codegen_body(args):
|
||||
"model": model_spec,
|
||||
"input_tokens": int(out.get("input_tokens") or 0),
|
||||
"output_tokens": int(out.get("output_tokens") or 0),
|
||||
"fallback_from": fallback_from or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -709,6 +720,13 @@ def verify_audit() -> int:
|
||||
return run_and_passthrough(["python3", READONLY, "verify-audit"])
|
||||
|
||||
|
||||
def history(args) -> int:
|
||||
command = ["python3", READONLY, "history", "--actor", args.actor, "--tenant", args.tenant, "--limit", str(args.limit)]
|
||||
if args.chat_id:
|
||||
command += ["--chat-id", args.chat_id]
|
||||
return run_and_passthrough(command)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -726,6 +744,12 @@ def main() -> int:
|
||||
askp.add_argument("--stream", action="store_true")
|
||||
askp.set_defaults(func=ask)
|
||||
sub.add_parser("verify-audit").set_defaults(func=lambda _args: verify_audit())
|
||||
hp = sub.add_parser("history")
|
||||
hp.add_argument("--actor", required=True)
|
||||
hp.add_argument("--chat-id", default="")
|
||||
hp.add_argument("--tenant", default="default")
|
||||
hp.add_argument("--limit", type=int, default=50)
|
||||
hp.set_defaults(func=history)
|
||||
args = ap.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# One entry point for the complete local CASAN lab. It keeps the infrastructure
|
||||
# lab and the authenticated Control Panel as separate Compose projects so their
|
||||
# lifecycle can be managed without port/network collisions.
|
||||
#
|
||||
# Usage: local-full.sh start|stop|status|verify|smoke|env
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/casan-paths.sh"
|
||||
ROOT="$CASAN_APP_ROOT"
|
||||
INFRA="$SCRIPT_DIR/infra-lab.sh"
|
||||
CP_COMPOSE="$ROOT/docker-compose.control-panel.local.yml"
|
||||
LOCAL_ENV="$ROOT/infra/local-prod/casan.local.env"
|
||||
TLS_DIR="$ROOT/tmp/control-panel-local/tls"
|
||||
CMD="${1:-status}"
|
||||
|
||||
cp_compose() {
|
||||
if [[ -f "$LOCAL_ENV" ]]; then
|
||||
docker compose --env-file "$LOCAL_ENV" -f "$CP_COMPOSE" "$@"
|
||||
else
|
||||
docker compose -f "$CP_COMPOSE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
need_docker() {
|
||||
command -v docker >/dev/null 2>&1 || { echo "CASAN_LOCAL_DOCKER_MISSING" >&2; exit 1; }
|
||||
docker compose version >/dev/null 2>&1 || { echo "CASAN_LOCAL_COMPOSE_MISSING" >&2; exit 1; }
|
||||
}
|
||||
|
||||
ensure_tls() {
|
||||
mkdir -p "$TLS_DIR"
|
||||
if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$TLS_DIR/tls.key" -out "$TLS_DIR/tls.crt" \
|
||||
-subj "/CN=localhost" -days 30 >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_url() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -k -fsS -m 3 "$url" >/dev/null 2>&1 && return 0
|
||||
sleep 1
|
||||
done
|
||||
echo "CASAN_LOCAL_WAIT_TIMEOUT url=$url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
start)
|
||||
need_docker
|
||||
bash "$INFRA" start
|
||||
ensure_tls
|
||||
cp_compose up -d --build
|
||||
wait_url "http://127.0.0.1:18082/healthz"
|
||||
# The unauthenticated console intentionally redirects, so test the OIDC IdP
|
||||
# here; `smoke` performs the complete logged-in browser flow.
|
||||
echo "CASAN_LOCAL_FULL_STARTED"
|
||||
echo "dashboard=http://127.0.0.1:18080 (basic auth: casan / casan)"
|
||||
echo "minio_console=http://127.0.0.1:19091 (casanadmin / casanadmin123)"
|
||||
echo "control_panel=https://localhost:18443 (self-signed TLS; mock OIDC login)"
|
||||
;;
|
||||
stop)
|
||||
need_docker
|
||||
cp_compose down --remove-orphans
|
||||
bash "$INFRA" stop
|
||||
echo "CASAN_LOCAL_FULL_STOPPED"
|
||||
;;
|
||||
status)
|
||||
need_docker
|
||||
echo "=== infrastructure ==="
|
||||
bash "$INFRA" status
|
||||
echo "=== control panel ==="
|
||||
cp_compose ps
|
||||
;;
|
||||
verify)
|
||||
need_docker
|
||||
bash "$INFRA" verify
|
||||
cp_compose ps
|
||||
wait_url "http://127.0.0.1:18082/healthz"
|
||||
echo "CASAN_LOCAL_FULL_VERIFY_PASS"
|
||||
;;
|
||||
smoke)
|
||||
need_docker
|
||||
bash "$ROOT/packages/casan-control-panel/scripts/local-prod-smoke.sh"
|
||||
;;
|
||||
env)
|
||||
bash "$INFRA" env
|
||||
printf '%s\n' \
|
||||
'# OmniRoute is opt-in. Replace <model-id> with an ID returned by /v1/models.' \
|
||||
'export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://127.0.0.1:20128/v1' \
|
||||
'export CASAN_OPENAI_COMPATIBLE_API_KEY="$OPENAI_API_KEY"' \
|
||||
'export CASAN_MODEL_PRIMARY=openai-compatible:<model-id>' \
|
||||
'export CASAN_CHAT_MODEL_MODE=model' \
|
||||
'export CASAN_CHAT_MODEL_PROVIDER=omniroute' \
|
||||
'export CASAN_PREFLIGHT=1'
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 start|stop|status|verify|smoke|env" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -23,6 +23,8 @@ Usage:
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _casan_app_root():
|
||||
@@ -43,6 +45,7 @@ import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
OLLAMA_HOST = "127.0.0.1:11434" # the only allowed ollama endpoint
|
||||
DOCKER_OLLAMA_HOST = "host.docker.internal:11434"
|
||||
ALLOWED_CLOUD = {"api.anthropic.com", "api.openai.com"}
|
||||
REPO_ROOT = _casan_app_root()
|
||||
PROVIDER_LOG = os.path.join(REPO_ROOT, ".specify/logs/level5/provider-usage.jsonl")
|
||||
@@ -131,11 +134,20 @@ def extract_verdict(role, text):
|
||||
return None, False
|
||||
|
||||
|
||||
def ollama_host_allowed(host: str) -> bool:
|
||||
return host == OLLAMA_HOST or (
|
||||
os.environ.get("CASAN_ALLOW_DOCKER_HOST_OLLAMA") == "1"
|
||||
and host == DOCKER_OLLAMA_HOST
|
||||
)
|
||||
|
||||
|
||||
def call_ollama(model_name, prompt, role):
|
||||
# SSRF guard: hard-pinned loopback endpoint, no env override of host.
|
||||
# SSRF guard: hard-pinned loopback endpoint by default. Docker Desktop has
|
||||
# one explicit, opt-in bridge to the Mac host's Ollama daemon; arbitrary
|
||||
# LAN, metadata and user-supplied hosts remain blocked.
|
||||
host = os.environ.get("CASAN_OLLAMA_HOST", OLLAMA_HOST)
|
||||
if host != OLLAMA_HOST:
|
||||
fail(f"endpoint_not_allowed ollama host={host} (only {OLLAMA_HOST})")
|
||||
if not ollama_host_allowed(host):
|
||||
fail(f"endpoint_not_allowed ollama host={host} (only {OLLAMA_HOST}; Docker bridge requires explicit opt-in)")
|
||||
digest_gate = os.path.join(os.path.dirname(__file__), "model-digest-check.sh")
|
||||
if os.path.isfile(digest_gate):
|
||||
check = subprocess.run(
|
||||
@@ -211,6 +223,73 @@ def call_openai(model_name, prompt, role):
|
||||
}
|
||||
|
||||
|
||||
def openai_compatible_url():
|
||||
"""Return a vetted OpenAI-compatible chat-completions endpoint.
|
||||
|
||||
This is opt-in, preserving the existing hard-pinned public OpenAI route.
|
||||
The default allowlist permits only local development hosts; another host
|
||||
must be explicitly approved by the operator.
|
||||
"""
|
||||
raw = os.environ.get("CASAN_OPENAI_COMPATIBLE_BASE_URL", "").strip()
|
||||
if not raw:
|
||||
fail("openai_compatible_base_url_unset")
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname \
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
fail("endpoint_not_allowed openai-compatible invalid_base_url")
|
||||
configured = os.environ.get("CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS", "")
|
||||
allowed_hosts = {host.strip().lower() for host in configured.split(",") if host.strip()} \
|
||||
or {"127.0.0.1", "localhost", "host.docker.internal"}
|
||||
host = parsed.hostname.lower()
|
||||
if host not in allowed_hosts:
|
||||
fail(f"endpoint_not_allowed openai-compatible host={host}")
|
||||
# HTTP is acceptable only for a loopback gateway or an explicitly named
|
||||
# private-LAN IP. Public HTTP and arbitrary SSRF targets remain blocked.
|
||||
if parsed.scheme == "http" and host not in {"127.0.0.1", "localhost", "host.docker.internal"}:
|
||||
try:
|
||||
is_private = ipaddress.ip_address(host).is_private
|
||||
except ValueError:
|
||||
is_private = False
|
||||
if not is_private:
|
||||
fail(f"endpoint_not_allowed openai-compatible insecure_host={host}")
|
||||
base_path = parsed.path.rstrip("/")
|
||||
if base_path not in {"", "/v1"}:
|
||||
fail("endpoint_not_allowed openai-compatible base_path_must_be_v1")
|
||||
return f"{parsed.scheme}://{parsed.netloc}{base_path}/chat/completions"
|
||||
|
||||
|
||||
def call_openai_compatible(model_name, prompt, role):
|
||||
"""Call an explicitly allowlisted OpenAI-compatible gateway (e.g. OmniRoute)."""
|
||||
key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||
if not key:
|
||||
fail("openai-compatible_backend_unavailable (API key unset)")
|
||||
body = {
|
||||
"model": model_name,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0 if role in ("classify", "judge") else 0.2,
|
||||
"max_tokens": 16 if role in ("classify", "judge") else 512,
|
||||
}
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
openai_compatible_url(), data=data,
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
|
||||
latency_ms = int((time.time() - t0) * 1000)
|
||||
text, input_tokens, output_tokens = parse_openai_payload(payload)
|
||||
return {
|
||||
"text": text,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
|
||||
def parse_openai_payload(payload):
|
||||
try:
|
||||
text = (payload["choices"][0]["message"]["content"] or "").strip()
|
||||
@@ -297,11 +376,18 @@ def main():
|
||||
model_spec = args.model
|
||||
if model_spec.startswith("ollama:"):
|
||||
backend, model_name = "ollama", model_spec[len("ollama:"):]
|
||||
elif model_spec.startswith(("anthropic:", "openai:")):
|
||||
elif model_spec.startswith(("anthropic:", "openai:", "openai-compatible:")):
|
||||
backend, model_name = model_spec.split(":", 1)
|
||||
key = os.environ.get("ANTHROPIC_API_KEY" if backend == "anthropic" else "OPENAI_API_KEY", "")
|
||||
if backend == "anthropic":
|
||||
key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
elif backend == "openai-compatible":
|
||||
key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
|
||||
else:
|
||||
key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if not key:
|
||||
# honest: cloud backend unavailable while key unset (do NOT fake)
|
||||
if backend == "openai-compatible":
|
||||
fail("openai-compatible_backend_unavailable (API key unset)")
|
||||
# Preserve the existing public-cloud unavailable contract.
|
||||
fail(f"cloud_backend_unavailable {backend} (API key unset)")
|
||||
else:
|
||||
fail(f"unknown_model_spec {model_spec}")
|
||||
@@ -311,6 +397,8 @@ def main():
|
||||
result = call_ollama(model_name, prompt, args.role)
|
||||
elif backend == "openai":
|
||||
result = call_openai(model_name, prompt, args.role)
|
||||
elif backend == "openai-compatible":
|
||||
result = call_openai_compatible(model_name, prompt, args.role)
|
||||
else: # anthropic
|
||||
result = call_anthropic(model_name, prompt, args.role)
|
||||
|
||||
@@ -343,6 +431,7 @@ def main():
|
||||
"ollama": "ollama_local_real_tokens",
|
||||
"openai": "openai_api_real_tokens",
|
||||
"anthropic": "anthropic_api_real_tokens",
|
||||
"openai-compatible": "openai_compatible_api_real_tokens",
|
||||
}.get(backend, f"{backend}_real_tokens")
|
||||
os.makedirs(os.path.dirname(PROVIDER_LOG), exist_ok=True)
|
||||
usage = {
|
||||
|
||||
Reference in New Issue
Block a user