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:
parent
0f0757d12b
commit
3ea03bedc4
@ -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」,但没有可用的该区域端点。按合规要求不跨端点降级——'
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user