Metadata-Version: 2.4
Name: ai-runner-hjy
Version: 0.0.2
Summary: DB-config driven OpenAI-compatible caller with auditing (RDS logs), CLI + import API
Author: hjy
License: MIT
Project-URL: Homepage, https://example.com
Keywords: openai,chat,audit,mysql,langgraph
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: httpx<1,>=0.27
Requires-Dist: loguru<1,>=0.7
Requires-Dist: python-dotenv<2,>=1
Requires-Dist: mysql-connector-python<9,>=8.3
Requires-Dist: cryptography<43,>=41
Requires-Dist: oss2<3,>=2.18
Requires-Dist: fastapi>=0.111
Requires-Dist: uvicorn>=0.30

# ai_runner_hjy（AI 调用装配与审计模块）

> 注意（必须阅读）：本模块“只会”从【项目根目录】加载 env 文件（`basic.env`、`mysql.env`、`oss.env`）。子目录中的任何 `*.env` 仅作为模板示例，运行期不会读取。
> 运行前会强校验以下必填项，缺失将直接报错并终止：
> - `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`
> - 二选一：`MYSQL_AI_DATABASE` 或 `MYSQL_DATABASE`
> - `AI_PEPPER`
> 若缺失，会得到类似错误：
> `RuntimeError: Missing required env variables: MYSQL_HOST, AI_PEPPER. Ensure you have created root-level basic.env/mysql.env and filled values.`

一个可复用的小模块：以“数据库配置驱动”的方式装配并调用 OpenAI Chat Completions 兼容接口（如 qdd/openrouter），并将调用指标与请求/响应 JSON 统一写入 RDS 以便审计与回放。

---

## 轻内核使用（不依赖 Web/DB）

当你只需要“稳定的 AI 调用函数”，而不希望引入 Web/DB 等工程依赖时，可直接使用轻内核 `runner.dry_run()/runner.run()` 并通过可插拔接口提供路由与日志能力。

最小示例（自定义 `RouteProvider` 构造一次标准请求，实际项目可换成你的路由/凭证来源）：

```python
from typing import Any, Dict, Optional, Tuple
from backend.ai_runner_hjy import runner
from backend.ai_runner_hjy.providers import RouteProvider


class InMemoryRouteProvider(RouteProvider):
    def resolve(self, project: str, route_key: str, *, runtime_variables: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, str], Dict[str, Any], Dict[str, Any]]:
        vars = runtime_variables or {}
        body = {"model": "demo-model", "messages": [
            {"role": "system", "content": "You are helpful."},
            {"role": "user", "content": f"route={route_key}; vars={vars}"},
        ]}
        url = "https://api.example.com/v1/chat/completions"
        headers = {"Authorization": "Bearer DEMO_KEY", "Content-Type": "application/json"}
        meta = {"config_key": f"demo::{project}::{route_key}"}
        return url, headers, body, meta


provider = InMemoryRouteProvider()
preview = runner.dry_run("demo", "hello", {"A": 1}, route_provider=provider)
print("preview=", preview)

result = runner.run("demo", "hello", {"A": 1}, route_provider=provider)
print("result=", result)
```

返回的结构稳定，便于业务拼装：

```python
{
  "status": 200,
  "duration_ms": 123,
  "trace_id": "...",
  "response": {...},        # 下游响应（JSON）或 None
  "error": {"code": "...", "message": "..."} 或 None,
  "meta": {"config_key": "...", "failover": false},
  "usage": {"total_tokens": ...}   # 视下游返回而定
}
```

如需“主备切换/权重”之类路由，可在你自己的服务中实现：使用多个 `RouteProvider` 顺序尝试，首个成功即返回；若非第一个，给 `meta.failover=true`（示例见 `examples/gateway/failover.py`）。

---

## 适用场景
- 需要“配置切换、代码零改动”的 AI 调用范式（模型/参数/Prompt 全在库里配置）。
- 需要强约束的日志与审计（耗时、状态、token 用量、请求 JSON、响应 JSON）。
- 多项目共用同一套 AI 连接/模型配置，或需要运维同学集中管理配置。

---

## 核心能力（0.0.2 新增：项目/接口→路由/配置 + HMAC + OSS 溢出 + Dev 沙盒）
- 按 `config_key` 从 RDS 读取：`ai_connection` → `ai_model` → `ai_param_profile` → `ai_prompt` → `ai_config`。
- 通过“项目名 + 接口昵称（route_key）”调用：路由到一组候选 `config_key`（三要素），支持 `primary_backup` 与 `round_robin`，默认主备优先。
- 运行时使用 `AI_PEPPER` 在内存里解密 API Key（RDS 仅存 AES‑GCM 密文 JSON）。
- 组装 OpenAI Chat Completions 请求体，参数白名单映射：
  - `TEMPERATURE→temperature`，`TOP_P→top_p`，`MAX_TOKENS→max_tokens`，`STREAM→stream`，`STOP→stop`，`N→n`，`FREQUENCY_PENALTY→frequency_penalty`，`PRESENCE_PENALTY→presence_penalty`
  - 若 `JSON_SCHEMA_ENFORCE=true` 且未在 Prompt 指定 `response_format`，兜底为 `{type:json_object}`
- 统一入库 `ai_call_logs`：
  - 最小：`http_status`、`duration_ms`、token 用量、`response_id`、错误码/信息
  - 扩展：`request_body_json`、`response_body_json`（可选大 JSON；若库未加列则自动回退，只写最小指标）
- 大 JSON 外溢到 OSS（公开链接），日志列写入 `{"oss": "https://..."}`，开启条件：存在 `oss.env`；开关 `ENABLE_OSS_SPILLOVER=true`（默认即 true）。
- Dev 沙盒（本机）`ai-runner-hjy dev`：内置最小前端，支持 `Run/Dry Run`、展示耗时与响应；可开启 HMAC 鉴权。

---

## 目录结构
- `runner.py`：装配 → 调用 → 入库（主要入口）
- `crypto_utils.py`：API Key 解密（AES‑256‑GCM + PBKDF2）
- `core/crud.py`：最小 CRUD（示例实现 `ai_param_profile` 的新增/查询，带幂等校验+统一错误返回）
- `__init__.py`：导出 `run_once / load_envs / get_db_connection`

---

## 环境准备
- 依赖安装（清华源）：
```bash
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r backend/requirements.txt
```
- 环境变量（模块会从项目根目录按需加载 `basic.env`、`mysql.env`，不覆盖已有环境变量）：
  - 必填：`MYSQL_HOST` `MYSQL_PORT` `MYSQL_USER` `MYSQL_PASSWORD` `MYSQL_AI_DATABASE`(或 `MYSQL_DATABASE`)
  - 必填：`AI_PEPPER`（Pepper，用于从密文 JSON 解密 API Key；严禁入库/泄露）

---

## 文档导航（V0.0.2 聚焦四大场景）
- 快速开始：`docs/GETTING_STARTED.md`
- 路由策略：`docs/ROUTING_POLICY.md`
- 表结构与种子：`docs/SCHEMA_AND_SEED.md`
- 配置与开关：`docs/SETTINGS_AND_SWITCHES.md`

### 最小体检/初始化命令
- 只查环境与数据库连通，不触表、不改库：
  ```bash
  # 1) 环境就绪 + 端口探测（若 8899 占用，自动递增寻找并写入 basic.env 的 DEV_SERVER_PORT）
  ai-runner-hjy init --env-only --dev-port 8899

  # 2) 仅查数据库连通（SELECT 1）
  ai-runner-hjy init --db-only
  ```
  输出包含：env/Python依赖/tmp写权限/可用端口 或 DB 连接结果；零副作用。

> 注：鉴权（HMAC）与其他非核心能力已在 V0.0.2 隐藏为废置功能，不再出现在主流程。

## v1 最小数据库结构（兼容运行）
> 为了可审计与回放，推荐包含请求/响应两列。若暂不需要，可先省略两列，模块会自动回退到最小日志模式。

```sql
-- 1) 连接表：只存密文 JSON
CREATE TABLE IF NOT EXISTS ai_connection (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100) NOT NULL,
  provider ENUM('openai','openrouter','qdd','google','custom') NOT NULL,
  base_url VARCHAR(500) NOT NULL,
  api_key_encrypted JSON NOT NULL,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_conn_name (name)
);

-- 2) 模型：指向连接
CREATE TABLE IF NOT EXISTS ai_model (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(120) NOT NULL,
  connection_id BIGINT NOT NULL,
  defaults_json JSON NULL,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_model_name (name),
  KEY idx_model_connection (connection_id),
  CONSTRAINT fk_model_connection FOREIGN KEY (connection_id) REFERENCES ai_connection(id)
);

-- 3) 参数白名单（映射到 OpenAI body）
CREATE TABLE IF NOT EXISTS ai_param_profile (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(120) NOT NULL,
  params_json JSON NOT NULL,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_param_name (name)
);

-- 4) Prompt（可含 variables_json 占位符）
CREATE TABLE IF NOT EXISTS ai_prompt (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(120) NOT NULL,
  version VARCHAR(20) NOT NULL,
  description TEXT NULL,
  messages_json JSON NOT NULL,
  response_format_json JSON NULL,
  variables_json JSON NULL,
  status ENUM('active','archived') NOT NULL DEFAULT 'active',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_prompt_name_version (name, version)
);

-- 5) 配置入口：以 config_key 作为对外接口
CREATE TABLE IF NOT EXISTS ai_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  config_key VARCHAR(120) NOT NULL,
  model_id BIGINT NOT NULL,
  param_profile_id BIGINT NULL,
  prompt_id BIGINT NULL,
  description TEXT NULL,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_config_key (config_key),
  KEY idx_config_model (model_id),
  KEY idx_config_param (param_profile_id),
  KEY idx_config_prompt (prompt_id),
  CONSTRAINT fk_cfg_model FOREIGN KEY (model_id) REFERENCES ai_model(id),
  CONSTRAINT fk_cfg_param FOREIGN KEY (param_profile_id) REFERENCES ai_param_profile(id),
  CONSTRAINT fk_cfg_prompt FOREIGN KEY (prompt_id) REFERENCES ai_prompt(id)
);

-- 6) 调用日志：最小指标 + 可选大 JSON
CREATE TABLE IF NOT EXISTS ai_call_logs (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  config_key VARCHAR(120) NOT NULL,
  http_status INT NOT NULL,
  duration_ms INT NOT NULL,
  prompt_tokens INT NULL,
  completion_tokens INT NULL,
  total_tokens INT NULL,
  response_id VARCHAR(128) NULL,
  error_code VARCHAR(64) NULL,
  error_message VARCHAR(512) NULL,
  request_body_json JSON NULL,
  response_body_json JSON NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_logs_cfg (config_key),
  KEY idx_logs_time (created_at)
);
```

> 迁移提示（已有库需要补齐唯一约束时）：
> 如已存在 `ai_config` 表但尚未对 `config_key` 加唯一索引，可执行：
>
> ```sql
> ALTER TABLE ai_config.ai_config ADD UNIQUE KEY uk_ai_config_config_key (config_key);
> ```
> 执行后用 `SHOW INDEX FROM ai_config.ai_config` 验证 `Non_unique=0`、`Key_name=uk_ai_config_config_key`、`Column_name=config_key`。

> API Key 加密请使用与本模块一致的 AES‑GCM 方案（见 `crypto_utils.py`），`ai_connection.api_key_encrypted` 列保存密文 JSON：`{"v","alg","iter","salt","nonce","ct"}`（可带 `tag` 兼容旧格式）。

---

## 种子数据（示例）
以 openrouter 为例（仅示意，实际请根据你的提供商修改）。
```sql
-- 连接（先插空密文，随后再 UPDATE 为真实密文）
INSERT INTO ai_connection (name, provider, base_url, api_key_encrypted, is_active)
VALUES ('openrouter_default', 'openrouter', 'https://openrouter.ai/api/v1/chat/completions', JSON_OBJECT(), 1);

-- 模型
ingert INTO ai_model (name, connection_id, is_active)
SELECT 'gemini-2.5-flash', id, 1 FROM ai_connection WHERE name='openrouter_default';

-- 参数白名单
ingert INTO ai_param_profile (name, params_json, is_active)
VALUES ('default_json', JSON_OBJECT('TEMPERATURE',0.2,'MAX_TOKENS',256,'JSON_SCHEMA_ENFORCE', true), 1);

-- Prompt（简单单轮）
INSERT INTO ai_prompt (name, version, description, messages_json, response_format_json, variables_json, status)
VALUES (
  'single_chat_zh','v1','单轮对话 JSON 输出',
  JSON_ARRAY(
    JSON_OBJECT('role','system','content','只输出 JSON，不要多余文本'),
    JSON_OBJECT('role','user','content', JSON_OBJECT('question','{{QUESTION}}'))
  ),
  JSON_OBJECT('type','json_object'),
  JSON_OBJECT('QUESTION','给我一句鼓励的话'),
  'active'
);

-- 配置入口（对外只暴露这个 key）
INSERT INTO ai_config (config_key, model_id, param_profile_id, prompt_id, description, is_active)
SELECT 'gemini25_single_chat', m.id, p.id, pr.id, 'gemini-2.5-flash 单轮对话', 1
FROM ai_model m, ai_param_profile p, ai_prompt pr
WHERE m.name='gemini-2.5-flash' AND p.name='default_json' AND pr.name='single_chat_zh' AND pr.version='v1';
```
> 记得用 `crypto_utils.py` 相同算法生成密文 JSON，并 `UPDATE ai_connection.api_key_encrypted`。

---

## v2 路由与凭证（0.0.2 新增）
> 为“一个 prompt 对多个模型”与主备/轮询提供结构化支持；不影响旧表。

```sql
SOURCE backend/ai_runner_hjy/core/schema_v2.sql;
```

### “人话”解释
- 你只要告诉我“项目名 + 接口昵称（route_key）”，我会从路由表里选一个候选三要素去调用；
- 接口昵称上写你公共的 prompt/参数；后续换/加模型，只需把新的 `config_key` 挂上去，不改代码。

### 最小样例（把 `your_cfg` 改为你的有效 config_key）
```sql
INSERT INTO ai_project_v2(name,status) VALUES('dogvoice','active')
ON DUPLICATE KEY UPDATE status='active';

INSERT INTO ai_route_v2(project_id,route_key,policy,is_active)
SELECT p.id,'sentiment','primary_backup',1 FROM ai_project_v2 p WHERE p.name='dogvoice'
ON DUPLICATE KEY UPDATE policy='primary_backup', is_active=1;

INSERT INTO ai_route_member_v2(route_id,config_id,priority,is_active)
SELECT r.id, cfg.id, 1, 1
FROM ai_route_v2 r
JOIN ai_project_v2 p ON p.id=r.project_id AND p.name='dogvoice'
JOIN ai_config cfg ON cfg.config_key='your_cfg'
WHERE r.route_key='sentiment';
```

### 鉴权（可选，默认关闭）
- 客户端带 3 个头：`X-AI-AccessKeyId`、`X-AI-Timestamp`(秒)、`X-AI-Signature`(HMAC-SHA256(base64))。
- 签名规则（人话）：把“方法 + 路径 + 时间戳 + 请求体sha256”拼成一行，用私钥算 HMAC；服务端用同样方式校验。

```python
from ai_runner_hjy import hmac_sign
sig = hmac_sign(secret, 'POST', '/invoke', '1710000000', b'{"project":"dogvoice"}')
```

---

## 变量替换（占位符）
- 在 `ai_prompt.variables_json` 与/或 `ai_param_profile.params_json` 中提供变量，模块会按 `{{VARIABLE}}` 替换到 `messages_json`。
- 多模态占位示意：`{{IMAGE_URL}}` / `{{AUDIO_URL}}` / `{{VIDEO_URL}}`（取决于你的消息结构）。

---

## 快速开始（v1：config_key 调用）
```python
from backend.ai_runner_hjy import run_once
import os

# 方式一：通过环境变量指定（脚本/任务方便）
os.environ['AI_TEST_CONFIG_KEY'] = 'gemini25_single_chat'
run_once()

# 方式二：在代码里显式传入（服务内调用）
run_once('gemini25_single_chat')
```

### 命令行（CLI）用法
无需改代码，直接运行一次：
```bash
python -m backend.ai_runner_hjy --config-key gemini25_single_chat
# 可选参数
python -m backend.ai_runner_hjy --config-key gemini25_single_chat --timeout 30 --max-retries 2
```

### CRUD 最小用法（示例）
```python
from backend.ai_runner_hjy.core.crud import add_param_profile_if_absent, get_param_profile_by_name

# 幂等新增（存在则返回 existed=True）
res, err = add_param_profile_if_absent("default_json", {"TEMPERATURE": 0.2, "MAX_TOKENS": 256})
if err:
    # err 为 (code, message)，例如 ("DB_ERROR", "...") / ("VALIDATION_ERROR", "...")
    raise RuntimeError(err)
print(res)  # {"id": 123, "existed": False}

# 查询
row, err = get_param_profile_by_name("default_json")
if err:
    raise RuntimeError(err)
print(row["params_json"])  # JSON 字符串
```

## 快速开始（v2：project+route 调用）
```bash
ai-runner-hjy route -p dogvoice -r sentiment -d '{"TEXT":"你好"}'
```

### Dev 沙盒（本机）
```bash
ai-runner-hjy dev -H 127.0.0.1 -P 5173        # 默认不鉴权
# 浏览器打开 http://127.0.0.1:5173 进行 Run / Dry Run

# 开启 HMAC
ai-runner-hjy dev -H 127.0.0.1 -P 5173 --enable-auth
```

---

## 日志与错误处理
- 每次调用必写一行 `ai_call_logs`：
  - 成功：`http_status=200/201`、`duration_ms`、`usage.*`、`response_id`，以及两列 JSON（若存在）
  - 失败：`error_code` 为 `HTTP_STATUS_ERROR`（非 2xx）或 `REQUEST_ERROR`（网络/序列化），同时尽量保留响应 JSON 便于排障
- 建议定期审计：慢请求、非 2xx 比例、token 消耗

### 错误码（轻内核稳定输出）

核心常见错误码说明：

- `MISSING_MEDIA_URL`：构建期严格校验失败（例如多模态 url 为空）；不会发起下游调用；`status=400`。
- `BUILD_ERROR`：构建期其它错误（含解密失败等）；`status=500`。
- `ROUTE_RESOLUTION_ERROR`：无法解析路由或无有效成员；`status=500`。
- `HTTP_429_RATE_LIMIT`：下游返回 429；`status=429`。
- `HTTP_5XX_SERVER_ERROR`：下游 5xx；`status` 为对应 5xx。
- `HTTP_STATUS_ERROR`：下游非 2xx 且非 429/5xx。
- `NETWORK_TIMEOUT` / `NETWORK_ERROR` / `REQUEST_ERROR`：网络/请求级错误；`status` 为最近一次尝试的状态或 0。
- `SCHEMA_INVALID`：在 200 响应时，启用 JSON enforce 后发现不可解析或不符合约束。
- `LETTER_DETECTED`：在 200 响应时，启用语言约束后发现 JSON 值包含英文字符。

> 约定：JSON enforce/语言约束仅在 `status=200` 时评估，不会覆盖 429/5xx 等更关键的下游错误语义。

---

## 常见问题（FAQ）
- Q：为什么没有写入 `request_body_json/response_body_json`？
  - A：请确认你已在 `ai_call_logs` 新增两列；否则模块会回退到最小日志模式（不报错）。
- Q：如何强制只输出 JSON？
  - A：在 Prompt 的 `response_format_json` 指定 `{type:"json_object"}`，或在参数白名单设置 `JSON_SCHEMA_ENFORCE=true`（兜底）。
- Q：API Key 放哪里？
  - A：只以密文 JSON 存在 `ai_connection.api_key_encrypted`。运行时用环境变量 `AI_PEPPER` 解密，明文不会落库/日志。

---

## 与服务集成（示例）
```python
from backend.ai_runner_hjy import run_once

def handle_request(config_key: str):
    # … 业务逻辑
    run_once(config_key)
```
> 若你以前通过 `subprocess` 调脚本，现在可以直接导入函数调用，便于单测与复用。

---

## 许可证与复用
- 内部脚手架，依赖最小、接口清晰，便于在不同项目迁移复用。欢迎在此基础上扩展：
  - 增加缓存/重试/降级策略参数
  - 将请求/响应落 OSS（大体量）并在日志里保存链接
  - 接入更完整的观测与指标
