refactor(gateway): 端点与模型解绑——删_pick_candidate协议过滤(端点=纯连接点对全模型可互换),endpoint_ids空=全部端点;inference删3处path全URL覆盖分支(模型profile禁把端点写进path);init.normalize_endpoints端点归一主机根+同主机去重+timeout取max。根治t2t间歇404(无profile被轮询到api/v1原生端点拼chat/completions)。2026-09-12用户定夺:模型和端点不能强绑定

This commit is contained in:
yumoqing 2026-09-12 12:35:04 +08:00
parent 0f0757d12b
commit 3ea03bedc4
3 changed files with 50 additions and 58 deletions

View File

@ -317,17 +317,6 @@ def _filter_by_pref(candidates_with_ep, pref: str):
return matched + rest
async def _model_protocol(sor, model: dict) -> str:
"""模型适配模板的协议(2026-09-05 端点协议感知选择用)。查不到返回空串。"""
pid = (model.get('profile_id') or '').strip()
if not pid:
return ''
recs = await sor.sqlExe(
"SELECT protocol FROM llm_api_profile WHERE id=${i}$ LIMIT 1", {"i": pid})
await sor.sqlExe("COMMIT", {})
return (getattr(recs[0], 'protocol', '') or '').strip() if recs else ''
async def _pick_candidate(sor, model: dict, pref: str, session_id: str = ''):
"""构建候选并选一个:协议过滤 → 区域偏好过滤 → 钱包余额门禁 →
冷却避让 → 会话粘性(同会话固定账号省上游缓存钱)→ 跨会话轮询。
@ -354,49 +343,30 @@ async def _pick_candidate(sor, model: dict, pref: str, session_id: str = ''):
accounts = await _account_candidates(sor, model)
if not accounts:
return False, '模型「%s」的供应商下无启用账号(或账号余额状态异常)' % model.get('name', '')
# 协议感知(2026-09-05 404 教训):模型模板协议与端点标签匹配才可用。
# 端点无标签(历史数据)视为通用——同供应商混布 compatible-mode/api/v1
# 两类端点时,无标签过滤会把原生异步模型拼到 openai_compat 前缀下 404。
mproto = await _model_protocol(sor, model)
if mproto:
tagged = [(i, e) for i, e in enumerate(endpoints) if (e.get('protocol') or '').strip()]
if tagged:
matched = {i for i, e in tagged if (e.get('protocol') or '').strip() == mproto}
untagged = {i for i, e in enumerate(endpoints)
if not (e.get('protocol') or '').strip()}
# 有协议匹配端点 → 只选匹配的(同账号多端点余额同分,按序会选到
# 第一个即 compatible-mode → 404,必须排除不匹配项而非并集);
# 无匹配但有未标注(历史)端点 → 兜底用未标注;全无 → 报错
if matched:
usable = matched
elif untagged:
usable = untagged
else:
return False, ('供应商端点均为其他协议(模型模板协议 %s,端点协议 %s)——'
'原生异步(openai_compat 之外)与兼容模式端点 base_url 不同,'
'混用必 404。请在供应商端点目录添加协议 %s 的端点'
% (mproto, sorted({(e.get('protocol') or '') for _, e in tagged}),
mproto))
else:
usable = set(range(len(endpoints)))
else:
usable = set(range(len(endpoints)))
# 展开 (账号, 端点) 对:账号选用的端点下标
# 端点 = 纯连接点(主机根+区域+超时),所有端点对所有模型可互换(2026-09-12
# 用户定夺:模型和端点不能强绑定,多端点=冗余/区域/多key,每个端点都支持
# 全部模型)。请求形态(协议/版本段/接口路径)完全由模型适配模板 profile
# 的完整相对 path 承载:t2t → /compatible-mode/v1/chat/completions,
# i2v → /api/v1/services/aigc/...(主机根 + 完整相对 path = 任意端点可拼)。
# 旧协议过滤已删(它正是间歇 404 根因:模型无 profile → 过滤跳过 → t2t 被
# 轮询到 api/v1 原生端点拼出 /chat/completions 404)。
# 展开 (账号, 端点) 对:账号选用的端点下标。
# endpoint_ids 空 = 选用全部端点(2026-09-12:端点可互换后,空不应再是
# 「跳过该账号」——历史数据里单端点供应商的账号常留空,语义即「用唯一端点」)。
pairs = []
for acc in accounts:
idxs = _parse_json(acc.get('endpoint_ids'), [])
if not idxs:
continue
idxs = list(range(len(endpoints)))
for i in idxs:
try:
i = int(i)
except (TypeError, ValueError):
continue
if i in usable:
if 0 <= i < len(endpoints):
pairs.append((acc, endpoints[i], i))
if not pairs:
return False, ('所有账号都未选用可用端点(模型治理→供应商账号→选用端点)。'
'模型模板协议 %s——请把该协议端点加入账号的选用端点' % (mproto or '未知'))
return False, '供应商端点目录与账号选用端点无交集——请检查模型治理→供应商端点目录与账号选用端点配置'
pairs = _filter_by_pref(pairs, pref)
if not pairs:
return False, ('端点偏好为「仅 %s」,但没有可用的该区域端点。按合规要求不跨端点降级——'

View File

@ -417,10 +417,10 @@ async def _post_upstream(ctx, payload, require_choices=True):
profile = ctx.get('profile') or {}
ns = {'api_key': ctx.get('api_key') or '', 'org_id': ctx.get('org_id') or ''}
path = await _render_tmpl((profile.get('path') or '').strip() or _DEFAULT_CHAT_PATH, ns)
if path.startswith('http://') or path.startswith('https://'):
url = path
else:
url = _join_url(ctx.get('api_base'), path)
# 2026-09-12 用户定夺:模型 profile 禁止把端点写进 path(path 必须是完整相对
# 路径,主机根由所选端点提供)——原「path 为全 URL 则无视端点」的覆盖分支已删,
# 端点对所有模型可互换。
url = _join_url(ctx.get('api_base'), path)
headers = await _render_headers(profile.get('headers', ''), ns)
body = await _build_upstream_body(ctx, payload)
kind = 'chat' if require_choices else 'gen'
@ -690,10 +690,7 @@ async def _async_query_once(ctx, qprofile, task_id, ns, timeout, seq=0):
"""按查询步骤模板请求一次任务状态。path 支持 {task_id} 占位符。"""
path = await _render_tmpl((qprofile.get('path') or '').strip(), ns)
path = path.replace('{task_id}', task_id)
if path.startswith('http://') or path.startswith('https://'):
url = path
else:
url = _join_url(ctx.get('api_base'), path)
url = _join_url(ctx.get('api_base'), path)
headers = await _render_headers(qprofile.get('headers', ''), ns)
method = (qprofile.get('method') or 'GET').upper()
_t0 = time.time()
@ -740,10 +737,7 @@ async def _async_inference(ctx, payload, req_timeout):
# ── 1. 提交任务 ──
body = await _build_async_body(ctx, payload)
path = await _render_tmpl((profile.get('path') or '').strip(), ns)
if path.startswith('http://') or path.startswith('https://'):
url = path
else:
url = _join_url(ctx.get('api_base'), path)
url = _join_url(ctx.get('api_base'), path)
headers = await _render_headers(profile.get('headers', ''), ns)
submit_method = (profile.get('method') or 'POST').upper()
_t0 = time.time()

View File

@ -54,12 +54,18 @@ def normalize_endpoints(text):
"""端点目录输入归一化:支持「一行一个 URL」或 JSON 数组两种写法。
行格式:URL [region=domestic|international] [timeout=秒],例如:
https://dashscope.aliyuncs.com/compatible-mode/v1
https://api.example.com/v1 region=international timeout=30
https://dashscope.aliyuncs.com
https://api.example.com region=international timeout=30
缺省 region=domestic、timeout=60。存储格式恒为 JSON 数组
[{base_url, region, timeout}](治理链按此读取)。
非法输入抛 ValueError(中文提示,直接展示给用户)。
2026-09-12 端点可互换约定:base_url 一律归一为主机根(scheme+域名)——
输入带路径段(旧数据/旧习惯的 /api/v1、/compatible-mode/v1 等)时确定性
剥掉;接口路径归 profile.path 承载(完整相对路径)。同 (主机根, region)
去重合并,timeout 取最大值(单端点须覆盖该主机全部接口形态的超时需求)。
"""
from urllib.parse import urlparse
s = (text or '').strip()
if not s:
return ''
@ -90,10 +96,32 @@ def normalize_endpoints(text):
else:
raise ValueError('第 %d 行未知选项 %s(支持 region= / timeout=)' % (ln_no, k))
eps.append(ep)
merged = []
for i, ep in enumerate(eps):
if not isinstance(ep, dict) or not (ep.get('base_url') or '').strip():
raise ValueError('端点 #%d 缺少 base_url' % (i + 1))
return json.dumps(eps, ensure_ascii=False)
pu = urlparse((ep.get('base_url') or '').rstrip('/'))
if not pu.netloc:
raise ValueError('端点 #%d base_url 非法(无主机名):%s' % (i + 1, ep.get('base_url')))
root = pu.scheme + '://' + pu.netloc
region = ep.get('region') or 'domestic'
try:
to = int(ep.get('timeout') or 60)
except (TypeError, ValueError):
to = 60
hit = None
for m in merged:
if m['base_url'] == root and m['region'] == region:
hit = m
break
if hit is None:
m = {'base_url': root, 'region': region, 'timeout': to}
if ep.get('protocol'):
m['protocol'] = ep['protocol']
merged.append(m)
else:
hit['timeout'] = max(hit['timeout'], to)
return json.dumps(merged, ensure_ascii=False)
def _norm_endpoint_ids(raw):