feat(models): add capability table and ttl config

This commit is contained in:
lsy committed 2026-03-12 10:22:24 +08:00
1 parent e1c7394bf0
commit 4115891bab
14 files changed
+223 -15

No files matched your search

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS channel_model_capabilities (
channel_id TEXT NOT NULL,
model TEXT NOT NULL,
last_ok_at INTEGER NOT NULL,
last_err_at INTEGER,
last_err_code TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (channel_id, model)
);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_model
ON channel_model_capabilities (model);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_channel
ON channel_model_capabilities (channel_id);
+1
View File
@@ -201,6 +201,7 @@ channels.post("/:id/test", async (c) => {
await updateChannelTestResult(c.env.DB, id, {
ok: true,
elapsed: summary.elapsed,
models: summary.models,
modelsJson: modelsToJson(summary.models),
});
+9 -2
View File
@@ -1,7 +1,8 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { extractModels } from "../services/channel-models";
import { listVerifiedModelEntries } from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getModelCapabilityTtlHours } from "../services/settings";
const models = new Hono<AppEnv>();
@@ -10,7 +11,13 @@ const models = new Hono<AppEnv>();
*/
models.get("/", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const entries = channels.flatMap((channel) => extractModels(channel));
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const entries = await listVerifiedModelEntries(
c.env.DB,
channels.map((channel) => ({ id: channel.id, name: channel.name })),
ttlSeconds,
);
const map = new Map<
string,
+2
View File
@@ -383,6 +383,7 @@ newapi.get("/test/:id", async (c) => {
await updateChannelTestResult(c.env.DB, id, {
ok: true,
elapsed: result.elapsed,
models: result.models,
});
return newApiSuccess(c, undefined, "测试成功");
@@ -412,6 +413,7 @@ newapi.post("/test", async (c) => {
await updateChannelTestResult(c.env.DB, String(id), {
ok: true,
elapsed: result.elapsed,
models: result.models,
});
return newApiSuccess(c, undefined, "测试成功");
});
+18 -2
View File
@@ -1,8 +1,11 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { newApiAuth } from "../middleware/newApiAuth";
import { collectUniqueModelIds } from "../services/channel-models";
import {
listVerifiedModelsByChannel,
} from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getModelCapabilityTtlHours } from "../services/settings";
import { newApiSuccess } from "../utils/newapi-response";
const users = new Hono<AppEnv>({ strict: false });
@@ -10,7 +13,20 @@ users.use("*", newApiAuth);
users.get("/models", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const data = collectUniqueModelIds(channels).map((id) => ({
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const map = await listVerifiedModelsByChannel(
c.env.DB,
channels.map((channel) => channel.id),
ttlSeconds,
);
const modelSet = new Set<string>();
for (const models of map.values()) {
for (const id of models) {
modelSet.add(id);
}
}
const data = Array.from(modelSet).map((id) => ({
id,
name: id,
}));
+38 -10
View File
@@ -17,6 +17,8 @@ import {
createWeightedOrder,
extractModels,
} from "../services/channels";
import { listVerifiedModelsByChannel } from "../services/channel-model-capabilities";
import { getModelCapabilityTtlHours } from "../services/settings";
import {
applyGeminiModelToPath,
buildUpstreamChatRequest,
@@ -55,25 +57,35 @@ type ExecutionContextLike = {
function channelSupportsModel(
channel: ChannelRecord,
model?: string | null,
model: string | null,
verifiedModelsByChannel: Map<string, Set<string>>,
): boolean {
if (!model) {
return true;
}
const models = extractModels(channel);
if (models.some((entry) => entry.id === model)) {
const verified = verifiedModelsByChannel.get(channel.id);
const metadata = parseChannelMetadata(channel.metadata_json);
const mapped = resolveMappedModel(metadata.model_mapping, model);
if (verified && verified.size > 0) {
if (mapped && verified.has(mapped)) {
return true;
}
return verified.has(model);
}
const declaredModels = extractModels(channel).map((entry) => entry.id);
if (mapped && declaredModels.includes(mapped)) {
return true;
}
const metadata = parseChannelMetadata(channel.metadata_json);
return Boolean(metadata.model_mapping[model] ?? metadata.model_mapping["*"]);
return declaredModels.includes(model);
}
export function selectCandidateChannels(
allowedChannels: ChannelRecord[],
downstreamModel: string | null,
verifiedModelsByChannel: Map<string, Set<string>>,
): ChannelRecord[] {
const modelChannels = allowedChannels.filter((channel) =>
channelSupportsModel(channel, downstreamModel),
channelSupportsModel(channel, downstreamModel, verifiedModelsByChannel),
);
if (modelChannels.length > 0) {
return modelChannels;
@@ -100,13 +112,17 @@ export function resolveUpstreamModelForChannel(
channel: ChannelRecord,
metadata: ChannelMetadata,
downstreamModel: string | null,
verifiedModelsByChannel: Map<string, Set<string>>,
): { model: string | null; autoMapped: boolean } {
const mapped = resolveMappedModel(metadata.model_mapping, downstreamModel);
if (!downstreamModel || hasExplicitModelMapping(metadata, downstreamModel)) {
return { model: mapped, autoMapped: false };
}
const declaredModels = extractModels(channel).map((entry) => entry.id);
const verified = verifiedModelsByChannel.get(channel.id);
const declaredModels = verified
? Array.from(verified)
: extractModels(channel).map((entry) => entry.id);
if (declaredModels.length === 0) {
return { model: mapped, autoMapped: false };
}
@@ -295,7 +311,18 @@ proxy.all("/*", tokenAuth, async (c) => {
callTokenMap.set(row.channel_id, list);
}
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
const candidates = selectCandidateChannels(allowedChannels, downstreamModel);
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const verifiedModelsByChannel = await listVerifiedModelsByChannel(
c.env.DB,
allowedChannels.map((channel) => channel.id),
ttlSeconds,
);
const candidates = selectCandidateChannels(
allowedChannels,
downstreamModel,
verifiedModelsByChannel,
);
if (candidates.length === 0 && allowedChannels.length > 0) {
console.warn("[proxy:no_compatible_channels]", {
@@ -308,12 +335,12 @@ proxy.all("/*", tokenAuth, async (c) => {
if (candidates.length === 0) {
return jsonError(c, 503, "no_available_channels", "no_available_channels");
}
const ordered = createWeightedOrder(candidates);
const targetPath = c.req.path;
const querySuffix = c.req.url.includes("?")
? `?${c.req.url.split("?")[1]}`
: "";
const ordered = createWeightedOrder(candidates);
const upstreamTimeoutMs = Math.max(
1000,
Number(c.env.PROXY_UPSTREAM_TIMEOUT_MS ?? "30000"),
@@ -333,6 +360,7 @@ proxy.all("/*", tokenAuth, async (c) => {
channel,
metadata,
downstreamModel,
verifiedModelsByChannel,
);
const upstreamModel = resolvedModel.model;
if (
+18
View File
@@ -8,11 +8,13 @@ import {
getCheckinScheduleTime,
getRetentionDays,
getSessionTtlHours,
getModelCapabilityTtlHours,
isAdminPasswordSet,
setAdminPasswordHash,
setCheckinScheduleTime,
setRetentionDays,
setSessionTtlHours,
setModelCapabilityTtlHours,
} from "../services/settings";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
@@ -27,11 +29,13 @@ settings.get("/", async (c) => {
const sessionTtlHours = await getSessionTtlHours(c.env.DB);
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
const checkinScheduleTime = await getCheckinScheduleTime(c.env.DB);
const modelCapabilityTtlHours = await getModelCapabilityTtlHours(c.env.DB);
return c.json({
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
admin_password_set: adminPasswordSet,
checkin_schedule_time: checkinScheduleTime,
model_capability_ttl_hours: modelCapabilityTtlHours,
});
});
@@ -76,6 +80,20 @@ settings.put("/", async (c) => {
touched = true;
}
if (body.model_capability_ttl_hours !== undefined) {
const hours = Number(body.model_capability_ttl_hours);
if (Number.isNaN(hours) || hours < 1) {
return jsonError(
c,
400,
"invalid_model_capability_ttl_hours",
"invalid_model_capability_ttl_hours",
);
}
await setModelCapabilityTtlHours(c.env.DB, hours);
touched = true;
}
if (typeof body.admin_password === "string" && body.admin_password.trim()) {
const hash = await sha256Hex(body.admin_password.trim());
await setAdminPasswordHash(c.env.DB, hash);
@@ -0,0 +1,87 @@
import type { D1Database } from "@cloudflare/workers-types";
import { nowIso } from "../utils/time";
import type { ModelEntry } from "./channel-models";
export const DEFAULT_MODEL_CAPABILITY_TTL_SEC = 2 * 60 * 60;
export type CapabilityRow = {
channel_id: string;
model: string;
last_ok_at: number | null;
};
export function buildCapabilityMap(
rows: CapabilityRow[],
cutoff: number,
): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const row of rows) {
if (!row.channel_id || !row.model) {
continue;
}
const lastOk = Number(row.last_ok_at ?? 0);
if (!lastOk || lastOk < cutoff) {
continue;
}
const set = map.get(row.channel_id) ?? new Set<string>();
set.add(row.model);
map.set(row.channel_id, set);
}
return map;
}
export async function listVerifiedModelsByChannel(
db: D1Database,
channelIds: string[],
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<Map<string, Set<string>>> {
if (channelIds.length === 0) {
return new Map();
}
const cutoff = Math.floor(Date.now() / 1000) - ttlSeconds;
const placeholders = channelIds.map(() => "?").join(", ");
const rows = await db
.prepare(
`SELECT channel_id, model, last_ok_at FROM channel_model_capabilities WHERE channel_id IN (${placeholders}) AND last_ok_at >= ?`,
)
.bind(...channelIds, cutoff)
.all<CapabilityRow>();
return buildCapabilityMap(rows.results ?? [], cutoff);
}
export async function listVerifiedModelEntries(
db: D1Database,
channels: Array<{ id: string; name: string }>,
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<ModelEntry[]> {
const ids = channels.map((channel) => channel.id);
const nameMap = new Map(channels.map((channel) => [channel.id, channel.name]));
const map = await listVerifiedModelsByChannel(db, ids, ttlSeconds);
const entries: ModelEntry[] = [];
for (const [channelId, models] of map.entries()) {
const channelName = nameMap.get(channelId) ?? channelId;
for (const id of models) {
entries.push({ id, label: id, channelId, channelName });
}
}
return entries;
}
export async function upsertChannelModelCapabilities(
db: D1Database,
channelId: string,
models: string[],
nowSeconds: number = Math.floor(Date.now() / 1000),
): Promise<void> {
if (models.length === 0) {
return;
}
const timestamp = nowIso();
const stmt = db.prepare(
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_ok_at = excluded.last_ok_at, last_err_at = NULL, last_err_code = NULL, updated_at = excluded.updated_at",
);
const statements = models.map((model) =>
stmt.bind(channelId, model, nowSeconds, timestamp, timestamp),
);
await db.batch(statements);
}
@@ -2,6 +2,7 @@ import type { D1Database } from "@cloudflare/workers-types";
import { nowIso } from "../utils/time";
import { normalizeBaseUrl } from "../utils/url";
import { modelsToJson, normalizeModelsInput } from "./channel-models";
import { upsertChannelModelCapabilities } from "./channel-model-capabilities";
export type ChannelTestResult = {
ok: boolean;
@@ -159,4 +160,7 @@ export async function updateChannelTestResult(
} else {
await stmt.bind(status, now, result.elapsed, nowIso(), id).run();
}
if (result.ok && result.models && result.models.length > 0) {
await upsertChannelModelCapabilities(db, id, result.models, now);
}
}
+17
View File
@@ -4,10 +4,12 @@ import { nowIso } from "../utils/time";
const DEFAULT_LOG_RETENTION_DAYS = 30;
const DEFAULT_SESSION_TTL_HOURS = 12;
const DEFAULT_CHECKIN_SCHEDULE_TIME = "00:10";
const DEFAULT_MODEL_CAPABILITY_TTL_HOURS = 2;
const RETENTION_KEY = "log_retention_days";
const SESSION_TTL_KEY = "session_ttl_hours";
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
const CHECKIN_SCHEDULE_TIME_KEY = "checkin_schedule_time";
const MODEL_CAPABILITY_TTL_KEY = "model_capability_ttl_hours";
async function readSetting(
db: D1Database,
@@ -126,6 +128,21 @@ export async function setCheckinScheduleTime(
await upsertSetting(db, CHECKIN_SCHEDULE_TIME_KEY, time);
}
export async function getModelCapabilityTtlHours(
db: D1Database,
): Promise<number> {
const value = await readSetting(db, MODEL_CAPABILITY_TTL_KEY);
return parsePositiveNumber(value, DEFAULT_MODEL_CAPABILITY_TTL_HOURS);
}
export async function setModelCapabilityTtlHours(
db: D1Database,
hours: number,
): Promise<void> {
const value = Math.max(1, Math.floor(hours)).toString();
await upsertSetting(db, MODEL_CAPABILITY_TTL_KEY, value);
}
/**
* Loads generic settings as a key/value map.
*/
+6
View File
@@ -17,6 +17,12 @@
### 微调
- **[worker/models]**: 引入通道模型能力表并基于“模型广场测试结果+可配置TTL(默认2小时)”进行模型发布与路由匹配
- 类型: 微调(无方案包)
- 文件: apps/worker/migrations/0004_add_channel_model_capabilities.sql, apps/worker/src/services/channel-model-capabilities.ts, apps/worker/src/services/channel-testing.ts, apps/worker/src/services/settings.ts, apps/worker/src/routes/settings.ts, apps/worker/src/routes/models.ts, apps/worker/src/routes/newapiUsers.ts, apps/worker/src/routes/proxy.ts, tests/worker/channel-model-capabilities.test.ts, tests/worker/newapi.test.ts, helloagents/modules/settings.md, helloagents/modules/models.md, helloagents/modules/proxy.md
- **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总)
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
- **[worker/proxy]**: 请求日志改为安全采样并补充上游失败异常日志,便于定位 Claude 请求故障
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
+3 -1
View File
@@ -10,8 +10,10 @@
## 行为规范
- 仅聚合启用状态的渠道
- 优先使用渠道连通性测试返回的模型数据
- 使用“模型广场测试结果”作为可用模型来源
- 模型结果带 TTL(默认 2 小时,来自 settings),过期后不再返回
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
## 依赖关系
- `channels` 表
- `channel_model_capabilities` 表
+2
View File
@@ -26,7 +26,9 @@
- 允许 `metadata_json.endpoint_overrides` 覆盖 chat/embedding/image 上游地址(可用 `{model}` 占位)
- 允许 `metadata_json.header_override` 与 `metadata_json.query_override` 注入额外上游请求头/查询参数
- Anthropic 默认注入 `anthropic-version=2023-06-01`,可通过 `header_override` 覆盖
- 路由时优先使用模型能力表(TTL 内测试成功的模型),无能力记录时回退渠道声明模型列表
## 依赖关系
- `channels` / `tokens` / `usage_logs` 表
- `tokenAuth` 中间件
- `channel_model_capabilities` 表
+2
View File
@@ -4,6 +4,7 @@
- 维护系统级配置(如日志保留天数)
- 为使用日志与面板提供统一配置读取
- 维护定时签到执行时间
- 维护模型能力 TTL(模型广场与路由过滤窗口)
## 接口定义
- `GET /api/settings` 读取设置
@@ -15,6 +16,7 @@
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期
## 依赖关系
- `settings` 表