feat(models): add capability table and ttl config
This commit is contained in:
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);
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "测试成功");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
|
||||
## 行为规范
|
||||
- 仅聚合启用状态的渠道
|
||||
- 优先使用渠道连通性测试返回的模型数据
|
||||
- 使用“模型广场测试结果”作为可用模型来源
|
||||
- 模型结果带 TTL(默认 2 小时,来自 settings),过期后不再返回
|
||||
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
|
||||
|
||||
## 依赖关系
|
||||
- `channels` 表
|
||||
- `channel_model_capabilities` 表
|
||||
@@ -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` 表
|
||||
@@ -4,6 +4,7 @@
|
||||
- 维护系统级配置(如日志保留天数)
|
||||
- 为使用日志与面板提供统一配置读取
|
||||
- 维护定时签到执行时间
|
||||
- 维护模型能力 TTL(模型广场与路由过滤窗口)
|
||||
|
||||
## 接口定义
|
||||
- `GET /api/settings` 读取设置
|
||||
@@ -15,6 +16,7 @@
|
||||
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
|
||||
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
|
||||
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
|
||||
- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期
|
||||
|
||||
## 依赖关系
|
||||
- `settings` 表
|
||||
Reference in new issue
Block a user