mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」
## 一、删除 priority(它从未参与跨源调度)
`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。
- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority
## 二、一通道 = 一个适配器 = 一个源(channels 插件)
旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效
新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
(内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
时给当前账号打 termErr,下次选号跳过;成功则清标记。
⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发
## 三、discover 支持用已存凭据(真实缺口)
刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。
- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
做探测,那就成了 SSRF/密钥外带);响应不回显 key
## 四、image_models 显式声明(补元数据缺失)
`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。
不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。
## 五、UI:对话与生图合并为一个导航项
两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。
## 测试
- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
(TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
(验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"
gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。
BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。
498 lines
16 KiB
Go
498 lines
16 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"llmsproxy/internal/config"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// HTTP surface for model discovery.
|
||
//
|
||
// Two endpoints, deliberately separate:
|
||
//
|
||
// POST /api/sources/discover — probe a URL+key, return the catalog.
|
||
// Nothing is written.
|
||
// POST /api/sources/discover/apply — probe, then write the source.
|
||
//
|
||
// The split exists because discovery mutates nothing and is therefore safe to
|
||
// call repeatedly while the operator is still typing a key, whereas apply
|
||
// rewrites credentials and the AUTO chain's inputs. Making them one endpoint
|
||
// with a flag would mean a "just check it" click can silently create a source.
|
||
//
|
||
// Both are admin-only, matching /api/sources: the request body carries an API
|
||
// key, and a model catalog reveals which upstreams an operator uses.
|
||
|
||
// discoverRequest is the body of both endpoints.
|
||
type discoverRequest struct {
|
||
// BaseURL is what the operator pasted. It may or may not include a
|
||
// version segment; discovery works that out rather than making the
|
||
// caller know this gateway's path-composition rules.
|
||
BaseURL string `json:"base_url"`
|
||
APIKey string `json:"api_key"`
|
||
|
||
// Name is required for apply, ignored for discover. It defaults from the
|
||
// base URL's host when omitted, because "https://api.vendor.com/v1" is a
|
||
// better starting name than "new-source".
|
||
Name string `json:"name"`
|
||
|
||
// Adapter is optional. Discovery assumes "openai" because that is what a
|
||
// /v1/models catalog implies; a non-OpenAI upstream (Anthropic, Gemini)
|
||
// has no such endpoint, so discovery will legitimately fail for those and
|
||
// the operator configures them by hand.
|
||
Adapter string `json:"adapter"`
|
||
|
||
// Temperature and MaxConcurrent are carried into the written source.
|
||
Temperature float64 `json:"temperature"`
|
||
MaxConcurrent int `json:"max_concurrent"`
|
||
|
||
// ReplaceModels drops the source's existing model list before applying the
|
||
// discovered one. It defaults to true for a NEW source and is required to
|
||
// be spelled out for an existing one, so a re-discovery cannot silently
|
||
// delete hand-tuned priorities.
|
||
ReplaceModels *bool `json:"replace_models,omitempty"`
|
||
|
||
// Select narrows what gets written. Empty means everything discovered.
|
||
// Ids that are not in the catalog are an error rather than a silent skip:
|
||
// a typo here would produce a source that 404s on every request.
|
||
Include []string `json:"include,omitempty"`
|
||
Exclude []string `json:"exclude,omitempty"`
|
||
|
||
// ImageModels names ids that are **kind=image** but whose upstream catalog
|
||
// does not say so.
|
||
//
|
||
// 为什么需要(真实缺口):discoveredModelKind 只看上游的 modality 字段,
|
||
// 并且刻意**不从模型名推断**(那会把 "gpt-image-1" 这类同名聊天模型
|
||
// 错归成生图,而错误的 kind 会毒死 AUTO 链)。
|
||
// 但有些网关的 `/v1/models` 只返回 `{id,object,created,owned_by}` ——
|
||
// 零 modality(实测:某聚合网关的 3 个 gpt-image-* 全部落成 chat,
|
||
// 于是生图端点永远报 `no image source configured`)。
|
||
//
|
||
// 两条原则都不破:不猜(默认仍完全按元数据),但允许**显式声明**。
|
||
// 只影响写入的 kind,不影响目录里有没有这个模型。
|
||
ImageModels []string `json:"image_models,omitempty"`
|
||
}
|
||
|
||
// discoverResponse is the JSON the WebUI binds to.
|
||
type discoverResponse struct {
|
||
OK bool `json:"ok"`
|
||
|
||
// Normalized source fields, ready to be echoed back to /api/sources.
|
||
BaseURL string `json:"base_url"`
|
||
Adapter string `json:"adapter"`
|
||
Models []string `json:"models"`
|
||
ImageModels []string `json:"image_models"`
|
||
|
||
// Endpoint is set only when discovery had to move a version segment.
|
||
Endpoint string `json:"endpoint"`
|
||
// ImageEndpoint is required whenever base_url carries a version segment:
|
||
// the gateway's default image path is the ABSOLUTE "/v1/images/generations"
|
||
// while chat's default is the relative "/chat/completions", so the same
|
||
// base_url that yields a working chat URL yields a 404 image URL.
|
||
ImageEndpoint string `json:"image_endpoint"`
|
||
|
||
// Diagnostics for the operator.
|
||
ModelsPath string `json:"models_path"`
|
||
Tried []string `json:"tried"`
|
||
LatencyMS int64 `json:"latency_ms"`
|
||
Note string `json:"note,omitempty"`
|
||
Warnings []string `json:"warnings,omitempty"`
|
||
|
||
// Set by apply only.
|
||
SourceName string `json:"source_name,omitempty"`
|
||
Applied bool `json:"applied,omitempty"`
|
||
}
|
||
|
||
func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request) {
|
||
if reqRole(r.Context()) != "admin" {
|
||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||
return
|
||
}
|
||
apply := strings.HasSuffix(r.URL.Path, "/apply")
|
||
if r.Method != http.MethodPost {
|
||
w.Header().Set("Allow", "POST")
|
||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||
"use POST with {\"base_url\":…,\"api_key\":…}")
|
||
return
|
||
}
|
||
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid_request", "cannot read body")
|
||
return
|
||
}
|
||
var req discoverRequest
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||
return
|
||
}
|
||
req.BaseURL = strings.TrimSpace(req.BaseURL)
|
||
req.Name = strings.TrimSpace(req.Name)
|
||
|
||
// ★ 只给 `name`(或两者都给但 key 为空)⇒ **从已存源取 base_url 与凭据**。
|
||
//
|
||
// 为什么必须有这条路径(真实痛点):刷新一个**已存在**源的模型目录,此前
|
||
// 必须让运维把明文 key 再粘一次 —— 而库里的 key 是 `enc:v1:` 密文,
|
||
// 运维手里未必还有原文;WebUI 也不回显明文(这是正确的安全设计)。
|
||
// 结果就是「想刷新目录 → 必须重新申请/粘贴一次 key」,很多人干脆不刷新,
|
||
// 于是源的模型目录越放越旧(实例:某源的 3 个生图模型从未被登记)。
|
||
//
|
||
// 安全边界:① 已要求 admin 角色;② 只能探测**本网关已配置的**源,
|
||
// 不能借此拿任意 URL + 任意 key 做探测(那样就成了 SSRF/密钥外带渠道);
|
||
// ③ 响应不回显 key(只回目录)。
|
||
if req.Name != "" {
|
||
if old := g.sourceByName(req.Name); old != nil {
|
||
if req.BaseURL == "" {
|
||
req.BaseURL = old.BaseURL
|
||
}
|
||
if req.APIKey == "" {
|
||
req.APIKey = old.APIKey
|
||
}
|
||
if req.Adapter == "" {
|
||
req.Adapter = old.Adapter
|
||
}
|
||
}
|
||
}
|
||
|
||
if req.BaseURL == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||
"base_url required (or pass name of an existing source to reuse its base_url and credential)")
|
||
return
|
||
}
|
||
if req.Adapter == "" {
|
||
req.Adapter = "openai"
|
||
}
|
||
|
||
// Bounded so a hung upstream cannot pin the handler; discovery has its own
|
||
// per-request timeout on top.
|
||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||
defer cancel()
|
||
|
||
res, err := DiscoverModels(ctx, req.BaseURL, req.APIKey)
|
||
if err != nil {
|
||
// A rejected key must not be reported as an upstream outage: the two
|
||
// need opposite actions from the operator, and 502 sends them to the
|
||
// wrong one.
|
||
status := http.StatusBadGateway
|
||
var f *discoveryFailure
|
||
if errors.As(err, &f) {
|
||
status = f.StatusOrDefault()
|
||
}
|
||
writeError(w, status, "discover_failed", err.Error())
|
||
return
|
||
}
|
||
|
||
// ★ 显式生图声明:把 operator 点名的 id 从 chat 挪到 image。
|
||
//
|
||
// 放在 selectModels **之前**:后者按 include/exclude 裁剪两个列表,
|
||
// 挪类必须在裁剪前完成,否则一个"只声明成生图、又被 include 选中"的
|
||
// 模型会先在 chat 里被保留、再被这里挪走 —— 顺序反了会得到两份。
|
||
//
|
||
// 只认**目录里确实存在的** id:写错一个字不该静默造出一个不存在的模型
|
||
// (那会让源在每次照片请求时 404),而是明确报错。
|
||
if len(req.ImageModels) > 0 {
|
||
have := map[string]bool{}
|
||
for _, m := range res.Models {
|
||
have[m] = true
|
||
}
|
||
for _, m := range res.ImageModels {
|
||
have[m] = true
|
||
}
|
||
var missing []string
|
||
force := map[string]bool{}
|
||
for _, id := range req.ImageModels {
|
||
if !have[id] {
|
||
missing = append(missing, id)
|
||
continue
|
||
}
|
||
force[id] = true
|
||
}
|
||
if len(missing) > 0 {
|
||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||
"image_models contains ids that are not in the upstream catalog: "+
|
||
strings.Join(missing, ", "))
|
||
return
|
||
}
|
||
var keepChat []string
|
||
for _, m := range res.Models {
|
||
if !force[m] {
|
||
keepChat = append(keepChat, m)
|
||
}
|
||
}
|
||
seenImg := map[string]bool{}
|
||
var newImage []string
|
||
for _, m := range res.ImageModels {
|
||
seenImg[m] = true
|
||
newImage = append(newImage, m)
|
||
}
|
||
for id := range force {
|
||
if !seenImg[id] {
|
||
newImage = append(newImage, id)
|
||
}
|
||
}
|
||
res.Models = keepChat
|
||
res.ImageModels = newImage
|
||
}
|
||
|
||
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
|
||
if err != nil {
|
||
// 400: the operator asked for a model the catalog does not have.
|
||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||
return
|
||
}
|
||
|
||
out := &discoverResponse{
|
||
OK: true,
|
||
BaseURL: res.BaseURL,
|
||
Adapter: req.Adapter,
|
||
Models: chat,
|
||
ImageModels: image,
|
||
Endpoint: res.Endpoint,
|
||
ImageEndpoint: res.ImageEndpoint,
|
||
ModelsPath: res.ModelsPath,
|
||
Tried: res.Tried,
|
||
LatencyMS: res.LatencyMS,
|
||
Note: res.Note,
|
||
Warnings: warns,
|
||
}
|
||
if len(chat) == 0 && len(image) == 0 {
|
||
out.Warnings = append(out.Warnings,
|
||
"every discovered model was excluded; applying this would create a source with no models")
|
||
}
|
||
|
||
if !apply {
|
||
writeJSON(w, http.StatusOK, out)
|
||
return
|
||
}
|
||
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
name = defaultSourceName(req.BaseURL)
|
||
}
|
||
if name == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||
"name required: cannot derive one from base_url")
|
||
return
|
||
}
|
||
if strings.ContainsAny(name, "/\\ \t") || name == "." || name == ".." {
|
||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||
"source name must not contain slashes or whitespace")
|
||
return
|
||
}
|
||
|
||
exists := g.sourceExists(name)
|
||
if exists && req.ReplaceModels == nil {
|
||
// Refusing here is deliberate. Re-running discovery on an existing
|
||
// source and having it silently replace a hand-tuned model list —
|
||
// including priorities chosen for the AUTO chain — is the kind of
|
||
// "helpful" behaviour that loses an afternoon.
|
||
writeError(w, http.StatusConflict, "conflict",
|
||
"source "+name+" already exists; set replace_models=true to overwrite its model list, "+
|
||
"or choose a different name")
|
||
return
|
||
}
|
||
|
||
// Build the source directly rather than round-tripping through the
|
||
// /api/sources payload: resolveAPIKey carries the "an empty api_key means
|
||
// keep the stored one" rule, and going through a second decoder here would
|
||
// duplicate (and eventually diverge from) that logic.
|
||
src := config.Source{
|
||
Name: name,
|
||
BaseURL: res.BaseURL,
|
||
APIKey: g.resolveAPIKey(name, req.APIKey, exists),
|
||
Adapter: req.Adapter,
|
||
Endpoint: res.Endpoint,
|
||
ImageEndpoint: res.ImageEndpoint,
|
||
Models: modelsToConfig(chat, image),
|
||
Temperature: req.Temperature,
|
||
}
|
||
if req.MaxConcurrent > 0 {
|
||
src.MaxConcurrent = req.MaxConcurrent
|
||
}
|
||
|
||
// An existing source may carry fields discovery does not know about
|
||
// (proxy_url, headers, api_key_env, timeout). Rebuilding from scratch would
|
||
// drop them, so they are inherited unless the request overrides them.
|
||
if exists {
|
||
old := g.sourceByName(name)
|
||
if old != nil {
|
||
src.ProxyURL = old.ProxyURL
|
||
src.APIKeyEnv = old.APIKeyEnv
|
||
src.Timeout = old.Timeout
|
||
src.QueueTimeout = old.QueueTimeout
|
||
src.Headers = old.Headers
|
||
src.Meta = old.Meta
|
||
src.MaxTokens = old.MaxTokens
|
||
src.RPM = old.RPM
|
||
// image_endpoint is NOT inherited when discovery supplied one:
|
||
// the stored value was correct for the OLD base_url, and the base
|
||
// is exactly what may have changed. Inheriting it would keep a
|
||
// stale value that 404s against the new base.
|
||
if res.ImageEndpoint == "" {
|
||
src.ImageEndpoint = old.ImageEndpoint
|
||
}
|
||
if src.Adapter == "" {
|
||
src.Adapter = old.Adapter
|
||
}
|
||
if req.MaxConcurrent <= 0 {
|
||
src.MaxConcurrent = old.MaxConcurrent
|
||
}
|
||
// replace_models=false keeps the curated list. Discovery still
|
||
// refreshed base_url/endpoint/credential, which is the point of
|
||
// re-running it.
|
||
if req.ReplaceModels != nil && !*req.ReplaceModels {
|
||
src.Models = old.Models
|
||
out.Warnings = append(out.Warnings,
|
||
"model list left unchanged (replace_models=false)")
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := g.core.AddSource(src); err != nil {
|
||
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
||
return
|
||
}
|
||
|
||
out.SourceName = name
|
||
out.Applied = true
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// selectModels applies the include/exclude filters.
|
||
//
|
||
// An include entry the catalog does not have is an error: silently dropping it
|
||
// produces a source that looks configured and 404s at request time, which is
|
||
// far harder to diagnose than a rejected apply. An exclude entry that is not
|
||
// present is fine — the operator is allowed to be defensive.
|
||
func selectModels(chat, image, include, exclude []string) ([]string, []string, []string, error) {
|
||
have := map[string]bool{}
|
||
for _, m := range chat {
|
||
have[m] = true
|
||
}
|
||
for _, m := range image {
|
||
have[m] = true
|
||
}
|
||
|
||
if len(include) > 0 {
|
||
var missing []string
|
||
for _, want := range include {
|
||
if !have[want] {
|
||
missing = append(missing, want)
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
return nil, nil, nil, &discoverError{
|
||
msg: "these models are not in the upstream catalog: " + strings.Join(missing, ", "),
|
||
}
|
||
}
|
||
keep := map[string]bool{}
|
||
for _, want := range include {
|
||
keep[want] = true
|
||
}
|
||
var c, i []string
|
||
for _, m := range chat {
|
||
if keep[m] {
|
||
c = append(c, m)
|
||
}
|
||
}
|
||
for _, m := range image {
|
||
if keep[m] {
|
||
i = append(i, m)
|
||
}
|
||
}
|
||
chat, image = c, i
|
||
}
|
||
|
||
if len(exclude) > 0 {
|
||
drop := map[string]bool{}
|
||
for _, m := range exclude {
|
||
drop[m] = true
|
||
}
|
||
var c, i []string
|
||
for _, m := range chat {
|
||
if !drop[m] {
|
||
c = append(c, m)
|
||
}
|
||
}
|
||
for _, m := range image {
|
||
if !drop[m] {
|
||
i = append(i, m)
|
||
}
|
||
}
|
||
chat, image = c, i
|
||
}
|
||
|
||
// chat and image must stay non-nil: the JSON contract forbids null here.
|
||
if chat == nil {
|
||
chat = []string{}
|
||
}
|
||
if image == nil {
|
||
image = []string{}
|
||
}
|
||
|
||
var warns []string
|
||
if len(chat) == 0 && len(image) == 0 {
|
||
// Caller can only get here by excluding everything, or from a catalog
|
||
// that was already empty. Either way "silence" would read as success.
|
||
warns = append(warns,
|
||
"no models selected; applying this would create a source that cannot serve any request")
|
||
return chat, image, warns, nil
|
||
}
|
||
if len(image) > 0 && len(chat) == 0 {
|
||
warns = append(warns,
|
||
"every model is an image model; AUTO chat requests have nothing to select")
|
||
}
|
||
if len(chat) > 0 && len(image) > 0 {
|
||
warns = append(warns,
|
||
"image models are only reachable through /v1/images/generations, not AUTO chat")
|
||
}
|
||
return chat, image, warns, nil
|
||
}
|
||
|
||
// discoverError is a client mistake rather than an upstream failure, which is
|
||
// why it gets its own type instead of fmt.Errorf.
|
||
type discoverError struct{ msg string }
|
||
|
||
func (e *discoverError) Error() string { return e.msg }
|
||
|
||
// defaultSourceName derives a source name from a base URL host, stripping the
|
||
// version segment and any "api." prefix. It returns "" for input that is not a
|
||
// URL at all — the caller must not fall back to echoing garbage into a source
|
||
// name, since the name becomes a config key and an API path segment.
|
||
func defaultSourceName(baseURL string) string {
|
||
s := strings.TrimSpace(baseURL)
|
||
if !strings.Contains(s, "://") {
|
||
return ""
|
||
}
|
||
s = s[strings.Index(s, "://")+3:]
|
||
if i := strings.IndexAny(s, "/?#"); i >= 0 {
|
||
s = s[:i]
|
||
}
|
||
if i := strings.LastIndex(s, ":"); i > 0 && !strings.Contains(s[i+1:], "]") {
|
||
s = s[:i] // strip port
|
||
}
|
||
// A host must still look like one after trimming the cosmetic prefixes.
|
||
s = strings.TrimPrefix(s, "api.")
|
||
s = strings.TrimPrefix(s, "www.")
|
||
if s == "" || strings.ContainsAny(s, " \t\"'<>") {
|
||
return ""
|
||
}
|
||
for _, r := range s {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||
case r == '.' || r == '-' || r == '_':
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
if !strings.Contains(s, ".") {
|
||
// A bare label is far more likely to be a mistake than a hostname.
|
||
return ""
|
||
}
|
||
return s
|
||
}
|