From 4115891bab6c4c5c04366e633cb274b5d493f61b Mon Sep 17 00:00:00 2001 From: lsy Date: Thu, 12 Mar 2026 10:22:24 +0800 Subject: [PATCH] feat(models): add capability table and ttl config --- .../0004_add_channel_model_capabilities.sql | 16 ++++ apps/worker/src/routes/channels.ts | 1 + apps/worker/src/routes/models.ts | 11 ++- apps/worker/src/routes/newapiChannels.ts | 2 + apps/worker/src/routes/newapiUsers.ts | 20 ++++- apps/worker/src/routes/proxy.ts | 48 +++++++--- apps/worker/src/routes/settings.ts | 18 ++++ .../services/channel-model-capabilities.ts | 87 +++++++++++++++++++ apps/worker/src/services/channel-testing.ts | 4 + apps/worker/src/services/settings.ts | 17 ++++ helloagents/CHANGELOG.md | 6 ++ helloagents/modules/models.md | 4 +- helloagents/modules/proxy.md | 2 + helloagents/modules/settings.md | 2 + 14 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 apps/worker/migrations/0004_add_channel_model_capabilities.sql create mode 100644 apps/worker/src/services/channel-model-capabilities.ts diff --git a/apps/worker/migrations/0004_add_channel_model_capabilities.sql b/apps/worker/migrations/0004_add_channel_model_capabilities.sql new file mode 100644 index 0000000..e218e29 --- /dev/null +++ b/apps/worker/migrations/0004_add_channel_model_capabilities.sql @@ -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); diff --git a/apps/worker/src/routes/channels.ts b/apps/worker/src/routes/channels.ts index b72a024..67e54e7 100644 --- a/apps/worker/src/routes/channels.ts +++ b/apps/worker/src/routes/channels.ts @@ -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), }); diff --git a/apps/worker/src/routes/models.ts b/apps/worker/src/routes/models.ts index 6b2a8d5..eff8de1 100644 --- a/apps/worker/src/routes/models.ts +++ b/apps/worker/src/routes/models.ts @@ -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(); @@ -10,7 +11,13 @@ const models = new Hono(); */ 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, diff --git a/apps/worker/src/routes/newapiChannels.ts b/apps/worker/src/routes/newapiChannels.ts index 6c4297c..c7c06b0 100644 --- a/apps/worker/src/routes/newapiChannels.ts +++ b/apps/worker/src/routes/newapiChannels.ts @@ -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, "测试成功"); }); diff --git a/apps/worker/src/routes/newapiUsers.ts b/apps/worker/src/routes/newapiUsers.ts index 1a7730f..0fa80d0 100644 --- a/apps/worker/src/routes/newapiUsers.ts +++ b/apps/worker/src/routes/newapiUsers.ts @@ -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({ 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(); + for (const models of map.values()) { + for (const id of models) { + modelSet.add(id); + } + } + const data = Array.from(modelSet).map((id) => ({ id, name: id, })); diff --git a/apps/worker/src/routes/proxy.ts b/apps/worker/src/routes/proxy.ts index b230d32..3dad0f6 100644 --- a/apps/worker/src/routes/proxy.ts +++ b/apps/worker/src/routes/proxy.ts @@ -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>, ): 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>, ): 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>, ): { 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 ( diff --git a/apps/worker/src/routes/settings.ts b/apps/worker/src/routes/settings.ts index 80e66b7..1445d18 100644 --- a/apps/worker/src/routes/settings.ts +++ b/apps/worker/src/routes/settings.ts @@ -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); diff --git a/apps/worker/src/services/channel-model-capabilities.ts b/apps/worker/src/services/channel-model-capabilities.ts new file mode 100644 index 0000000..389d08d --- /dev/null +++ b/apps/worker/src/services/channel-model-capabilities.ts @@ -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> { + const map = new Map>(); + 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(); + 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>> { + 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(); + 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 { + 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 { + 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); +} diff --git a/apps/worker/src/services/channel-testing.ts b/apps/worker/src/services/channel-testing.ts index b811243..7e33ea0 100644 --- a/apps/worker/src/services/channel-testing.ts +++ b/apps/worker/src/services/channel-testing.ts @@ -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); + } } diff --git a/apps/worker/src/services/settings.ts b/apps/worker/src/services/settings.ts index 6742e44..8d62e61 100644 --- a/apps/worker/src/services/settings.ts +++ b/apps/worker/src/services/settings.ts @@ -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 { + 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 { + 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. */ diff --git a/helloagents/CHANGELOG.md b/helloagents/CHANGELOG.md index a60bf50..ee6edd4 100644 --- a/helloagents/CHANGELOG.md +++ b/helloagents/CHANGELOG.md @@ -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 diff --git a/helloagents/modules/models.md b/helloagents/modules/models.md index b3a1c3a..dd8ab05 100644 --- a/helloagents/modules/models.md +++ b/helloagents/modules/models.md @@ -10,8 +10,10 @@ ## 行为规范 - 仅聚合启用状态的渠道 -- 优先使用渠道连通性测试返回的模型数据 +- 使用“模型广场测试结果”作为可用模型来源 +- 模型结果带 TTL(默认 2 小时,来自 settings),过期后不再返回 - 模型归一化支持字符串数组与对象数组(对象取 `id` 字段) ## 依赖关系 - `channels` 表 +- `channel_model_capabilities` 表 diff --git a/helloagents/modules/proxy.md b/helloagents/modules/proxy.md index 5be4389..c84bebd 100644 --- a/helloagents/modules/proxy.md +++ b/helloagents/modules/proxy.md @@ -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` 表 diff --git a/helloagents/modules/settings.md b/helloagents/modules/settings.md index 716df6a..c915542 100644 --- a/helloagents/modules/settings.md +++ b/helloagents/modules/settings.md @@ -4,6 +4,7 @@ - 维护系统级配置(如日志保留天数) - 为使用日志与面板提供统一配置读取 - 维护定时签到执行时间 +- 维护模型能力 TTL(模型广场与路由过滤窗口) ## 接口定义 - `GET /api/settings` 读取设置 @@ -15,6 +16,7 @@ - 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间) - 更新签到时间后会触发 `CheckinScheduler` 重新调度 - 若更新后当前时间已超过新签到时间,当日可再次触发自动签到 +- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期 ## 依赖关系 - `settings` 表