Files
ModelRouter/internal/provider/provider.go
JianFeeeee ff7051d608 refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据
用户判据:「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 表达。

(cherry picked from commit 25970a2135)
2026-10-19 22:35:47 +08:00

1692 lines
56 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package provider binds a configured source + Lua adapter and performs the
// HTTP call / stream / image generation against the upstream LLM, with
// per-source concurrency limiting and availability backoff.
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/types"
)
// proxyFor picks the transport proxy for one source. An explicit per-source
// proxy_url wins (e.g. clash on 127.0.0.1:7890 for a geo-blocked upstream);
// otherwise fall back to the process-wide env proxy, which is direct by
// default.
func proxyFor(cfg config.Source) func(*http.Request) (*url.URL, error) {
if cfg.ProxyURL != "" {
proxyURL, err := url.Parse(cfg.ProxyURL)
if err == nil {
return http.ProxyURL(proxyURL)
}
}
return http.ProxyFromEnvironment
}
// ---- per-(source,model) scheduling state ----
// ModelState is the scheduling state of one (source, model) pair: a soft
// preference score used to order candidates within a priority tier, a
// consecutive-failure counter driving exponential cooldown, and a hard
// cooldown deadline. All fields are atomic and cooldown expiry is evaluated
// lazily (no timers, no goroutines). A model is never permanently blacklisted:
// cooldown always expires and any success resets the state, so a fixed
// upstream recovers on its own.
type ModelState struct {
pref atomic.Int64 // +prefSuccessStep per success / -prefFailStep per failure, clamped
failCount atomic.Int64
cooldownUntil atomic.Int64 // unix seconds; 0 = schedulable
// prefTouched is the unix second pref was last moved by an outcome. It is
// the reference point for decay: a negative score with no traffic since
// prefDecayAfter drifts one step toward 0 per elapsed interval, so a slot
// cannot stay written off forever just because nothing selected it.
prefTouched atomic.Int64
// cooldownFrom is the unix second the current cooldown window opened. It
// is the reference point for the half-cooldown probe gate: probing is
// allowed only in the second half of [cooldownFrom, cooldownUntil), so a
// freshly failed slot stays fully silent for a while instead of being
// hammered immediately.
cooldownFrom atomic.Int64
// probeInFlight is the single-token probe permit. At most one probe
// request may be in flight per (source, model) while cooling; the token is
// released by ProbeDone regardless of outcome.
probeInFlight atomic.Bool
}
const (
// prefFailStep is the penalty for a REAL failure (5xx, transport error,
// unusable adapter output): something is wrong with the upstream or the
// model, so two of them are enough to push a slot to the back of its tier.
prefFailStep = 5
// prefQuotaStep is the penalty for running out of allowance (quota/TPM
// exhausted). Deliberately much smaller than prefFailStep: an exhausted
// quota means "no budget right now", not "this model is bad", and for
// allowance-metered sources it is an everyday event rather than a fault.
// Charging it the full failure penalty made such sources drift to a deeply
// negative score with ZERO visible failures - RecordQuotaExhausted
// intentionally does not touch failCount, so the WebUI showed fails=0 and
// cooling=false while the score sat at -10.
prefQuotaStep = 1
// prefSuccessStep is the reward for a successful request. Recovery used to
// be +1 against a -5 penalty, a 5:1 asymmetry that made a slot at -10 need
// ten consecutive successes just to reach neutral. Rewarding +2 keeps
// failures meaningful (a real failure still costs more than one success
// earns) while letting a recovered slot return to normal rotation quickly.
prefSuccessStep = 2
prefMin = -20
prefMax = 20
// prefDecayAfter is how long a slot may sit at a negative score without any
// traffic before the score drifts back toward neutral. Without this a slot
// that was penalised and then simply not selected again (the score itself
// makes it unattractive) stays penalised indefinitely: it has no way to earn
// the successes that would rehabilitate it. Applied lazily on read, so there
// are no timers.
prefDecayAfter = 2 * time.Minute
backoffBase = 5 * time.Second
// backoffCap bounds the exponential schedule. It used to be 30min, which
// in practice meant "a slot whose upstream blipped is unusable for half an
// hour even though its quota recovered in seconds". Combined with the
// half-cooldown probe gate below a much shorter cap is safe: a still-dead
// upstream costs one probe request per cap/2, while a recovered one is back
// in full rotation on its first probe success.
backoffCap = 5 * time.Minute
// failures at/above this count back off at the capped duration
backoffCapN = 10
)
// authCooldown is the cooldown applied to 401/403 credential failures. A bad
// key does not fix itself in seconds, so it cools longer than a transport
// blip — but it is still a self-healing cooldown (a rotated key is picked up by
// the next probe), never a permanent blacklist.
const authCooldown = 10 * time.Minute
// maxQuotaCooldown bounds a quota-exhausted cooldown. Even when the reported
// reset window is huge (monthly quotas), the slot is re-probed at least this
// often so a manually topped-up allowance is noticed.
const maxQuotaCooldown = 30 * time.Minute
// quotaDefaultCooldown is used when the source gives no hint about its quota
// window length. It is long enough not to hammer an exhausted allowance and
// short enough that the half-cooldown probe retries within minutes.
const quotaDefaultCooldown = 10 * time.Minute
// rateLimitCooldown is the fixed cooldown applied on HTTP 429. A rate-limit
// rejection means "too fast", not "broken": unlike transport/5xx failures it
// must NOT escalate exponentially to backoffCap, or a quota-rich source gets
// locked out for up to 30 minutes while its allowance is untouched. A short
// fixed window lets the slot re-enter the rotation quickly.
const rateLimitCooldown = 30 * time.Second
func clampPref(a *atomic.Int64, lo, hi int64) {
for {
cur := a.Load()
if cur < lo {
if a.CompareAndSwap(cur, lo) {
return
}
continue
}
if cur > hi {
if a.CompareAndSwap(cur, hi) {
return
}
continue
}
return
}
}
// Available reports whether the model may be scheduled right now (cooldown
// expired or not yet set).
func (s *ModelState) Available() bool {
return s.cooldownUntil.Load() <= time.Now().Unix()
}
// RecordFailure counts one consecutive failure and schedules exponential
// cooldown (5s, 10s, 20s … capped at backoffCap). auth marks 401/403
// credential failures: they get the longer fixed authCooldown and a doubled
// preference penalty, but the model still recovers when the cooldown expires.
func (s *ModelState) RecordFailure(auth bool) {
n := s.failCount.Add(1)
var cd time.Duration
switch {
case auth:
// Persist the cap so FailCount() reports "this is a hard problem" and a
// following non-auth failure does not restart the ladder from the bottom.
if n < backoffCapN {
n = backoffCapN
s.failCount.Store(n)
}
cd = authCooldown
case n >= backoffCapN:
cd = backoffCap
default:
cd = backoffBase * time.Duration(1<<(n-1))
if cd > backoffCap {
cd = backoffCap
}
}
s.openCooldown(cd)
pen := int64(prefFailStep)
if auth {
pen *= 2
}
s.addPref(-pen)
}
// addPref moves the preference score and stamps prefTouched, which restarts the
// decay clock. Every outcome-driven score change must go through here so decay
// never fires on a slot that is actively being scored.
func (s *ModelState) addPref(delta int64) {
s.pref.Add(delta)
clampPref(&s.pref, prefMin, prefMax)
s.prefTouched.Store(time.Now().Unix())
}
// decayPref drifts a negative score back toward neutral once a slot has been
// idle for prefDecayAfter. This closes a starvation loop: a penalised slot is
// unattractive, so it stops being selected, so it never earns the successes that
// would rehabilitate it, so it stays penalised. One step per elapsed interval,
// never past 0, and positive scores are left alone (a proven-good slot should
// not be dragged down for being idle).
//
// Called lazily from Pref(); no timers and no goroutines.
func (s *ModelState) decayPref() {
cur := s.pref.Load()
if cur >= 0 {
return
}
last := s.prefTouched.Load()
if last <= 0 {
// no reference point yet: start the clock instead of decaying blindly
s.prefTouched.CompareAndSwap(0, time.Now().Unix())
return
}
step := int64(prefDecayAfter / time.Second)
if step <= 0 {
return
}
elapsed := time.Now().Unix() - last
if elapsed < step {
return
}
steps := elapsed / step
target := cur + steps
if target > 0 {
target = 0
}
// Advance prefTouched by exactly the consumed intervals so leftover time
// still counts toward the next step.
if s.pref.CompareAndSwap(cur, target) {
s.prefTouched.Store(last + steps*step)
}
}
// openCooldown starts a fresh cooldown window of length cd. Recording both
// ends of the window (not just the deadline) is what makes the half-cooldown
// probe gate possible, and resetting cooldownFrom on every new failure means a
// failed probe pushes the next probe to the middle of the NEW window instead of
// retrying immediately.
func (s *ModelState) openCooldown(cd time.Duration) {
now := time.Now()
s.cooldownFrom.Store(now.Unix())
s.cooldownUntil.Store(now.Add(cd).Unix())
}
// RecordRateLimit records an HTTP 429: a short fixed cooldown instead of the
// exponential schedule, so a merely-throttled source recovers in seconds and
// its remaining quota stays usable. The preference penalty still applies so
// other slots are preferred while cooling.
func (s *ModelState) RecordRateLimit() {
s.failCount.Add(1)
s.openCooldown(rateLimitCooldown)
// A 429 is "too fast", not "broken": charge it like an allowance event, not
// like a fault, or a throttled-but-healthy source sinks purely for being
// popular.
s.addPref(-prefQuotaStep)
}
// RecordQuotaExhausted marks the model as out of allowance until its quota
// window resets. Unlike a failure this is not "the upstream is broken", so it
// must not feed the exponential ladder: the slot is unusable until the window
// boundary and immediately usable again after it. resetIn is the time left in
// the current quota window; non-positive values fall back to the short
// rate-limit window (the caller could not determine a period).
func (s *ModelState) RecordQuotaExhausted(resetIn time.Duration) {
if resetIn <= 0 {
resetIn = rateLimitCooldown
}
if resetIn > maxQuotaCooldown {
resetIn = maxQuotaCooldown
}
s.openCooldown(resetIn)
// No failCount bump: an exhausted quota is not an error streak, and
// letting it inflate failCount would make the NEXT real failure jump
// straight to the capped backoff.
//
// The score penalty is prefQuotaStep, not prefFailStep, for the same
// reason: cooldown alone already keeps the slot out of rotation until the
// window resets, so the score only needs to express a mild preference for
// slots with budget left. Charging the full failure penalty here is what
// drove allowance-metered sources to -10 and beyond with fails=0.
s.addPref(-prefQuotaStep)
}
// RecordSuccess resets the failure counter and cooldown and rewards the
// preference score. A single success lifts a slot off the prefMin floor, which
// is what lets one successful probe return a written-off slot to normal
// rotation.
func (s *ModelState) RecordSuccess() {
s.failCount.Store(0)
s.cooldownUntil.Store(0)
s.cooldownFrom.Store(0)
s.addPref(prefSuccessStep)
}
func (s *ModelState) reset() {
s.failCount.Store(0)
s.cooldownUntil.Store(0)
s.cooldownFrom.Store(0)
s.probeInFlight.Store(false)
s.pref.Store(0)
s.prefTouched.Store(0)
}
// TryProbe attempts to claim the single probe permit for a model that normal
// scheduling refuses. It returns true only when a probe is both needed and due:
//
// - cooling: allowed once the window passed its midpoint (the first half stays
// completely silent).
// - cooldown expired but the preference score sank to prefMin: ModelAvailable
// still refuses the slot, so a probe is the ONLY way back. Without this a
// slot that failed prefMin/prefFailStep times in a row would be blacklisted
// forever despite every cooldown having long expired.
//
// A healthy, non-cooling model returns false: the caller should schedule it
// normally. On true the caller MUST call ProbeDone exactly once.
func (s *ModelState) TryProbe() bool {
now := time.Now().Unix()
until := s.cooldownUntil.Load()
if until <= now {
s.decayPref() // a naturally-recovered slot needs no probe
if s.pref.Load() > prefMin {
return false // schedulable normally, no probe needed
}
// preference floor: probe to earn the way out
return s.probeInFlight.CompareAndSwap(false, true)
}
from := s.cooldownFrom.Load()
if from <= 0 || from >= until {
return false // no measurable window (legacy/degenerate state)
}
if now < from+(until-from)/2 {
return false // first half of the window: stay silent
}
return s.probeInFlight.CompareAndSwap(false, true)
}
// ProbeDone releases the probe permit claimed by TryProbe.
func (s *ModelState) ProbeDone() { s.probeInFlight.Store(false) }
// Probing reports whether a probe request currently holds the permit.
func (s *ModelState) Probing() bool { return s.probeInFlight.Load() }
// CooldownFrom is the unix timestamp the current cooldown window opened; 0
// when the model is not cooling.
func (s *ModelState) CooldownFrom() int64 { return s.cooldownFrom.Load() }
// Pref is the current preference score (higher = preferred). Reading it also
// applies idle decay, so a slot penalised long ago and never selected since
// drifts back toward neutral instead of being written off permanently.
func (s *ModelState) Pref() int64 {
s.decayPref()
return s.pref.Load()
}
// FailCount is the number of consecutive failures.
func (s *ModelState) FailCount() int64 { return s.failCount.Load() }
// CooldownUntil is the unix timestamp until which the model is cooled; 0 when
// schedulable.
func (s *ModelState) CooldownUntil() int64 { return s.cooldownUntil.Load() }
// Provider is a single configured upstream LLM source.
type Provider struct {
cfg config.Source
vm *lua.VM
adapter string
// client bounds a whole non-streaming request (dial+read body). stream
// is used for SSE: no overall timeout (a long stream must not be cut),
// only the transport's ResponseHeaderTimeout bounds time-to-first-byte.
client *http.Client
stream *http.Client
mu sync.Mutex
sem chan struct{}
states map[string]*ModelState // key = model id
// proactive rate limiting: nextOK is the earliest unix-nano time the next
// request may leave; throttle() spaces requests 60s/RPM apart. Zero when
// cfg.RPM <= 0 (unlimited).
rateMu sync.Mutex
nextOK int64
rpmGap time.Duration
lastProbe struct {
ok bool
err string
at int64
}
}
func New(cfg config.Source, vm *lua.VM) *Provider {
if cfg.Timeout <= 0 {
cfg.Timeout = config.DefaultSourceTimeout
}
if cfg.MaxConcurrent <= 0 {
cfg.MaxConcurrent = config.DefaultSourceConcurrency
}
// Shared transport: ResponseHeaderTimeout bounds how long we wait for the
// first response byte (applies to both paths); the stream client has no
// client-level Timeout so the SSE body can run past the header timeout.
// A per-source ProxyURL (e.g. clash on 127.0.0.1:7890) overrides the
// process-wide env proxy for upstreams that are geo/IP-blocked; sources
// without one keep http.ProxyFromEnvironment (direct by default).
tr := &http.Transport{
Proxy: proxyFor(cfg),
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: cfg.Timeout,
}
p := &Provider{
cfg: cfg,
vm: vm,
adapter: cfg.Adapter,
client: &http.Client{Timeout: cfg.Timeout, Transport: tr},
stream: &http.Client{Transport: tr},
sem: make(chan struct{}, cfg.MaxConcurrent),
states: map[string]*ModelState{},
}
if cfg.MaxConcurrent <= 0 {
p.sem = nil
}
if cfg.RPM > 0 {
p.rpmGap = time.Minute / time.Duration(cfg.RPM)
}
for _, m := range cfg.Models {
p.states[m.ID] = &ModelState{}
}
return p
}
func (p *Provider) Name() string { return p.cfg.Name }
func (p *Provider) Adapter() string { return p.cfg.Adapter }
func (p *Provider) MaxConcurrent() int { return p.cfg.MaxConcurrent }
func (p *Provider) Config() *config.Source { return &p.cfg }
// Models returns the model ids exposed by this source.
func (p *Provider) Models() []string {
out := make([]string, 0, len(p.cfg.Models))
for _, m := range p.cfg.Models {
out = append(out, m.ID)
}
return out
}
// ModelsWithKind returns the same ids as Models but paired with each model's
// kind, in the same order.
//
// Models() cannot answer "which of these are image models", and that question
// has to be answerable: the chat test page lists models from one source list,
// and an image model offered there fails on every call because /api/chat is the
// chat endpoint.
func (p *Provider) ModelsWithKind() []ModelKindInfo {
out := make([]ModelKindInfo, 0, len(p.cfg.Models))
for _, m := range p.cfg.Models {
kind := m.Kind
if kind == "" {
kind = "chat" // config.Model documents "" as the chat default
}
out = append(out, ModelKindInfo{ID: m.ID, Kind: kind})
}
return out
}
// ModelByID returns the model definition if owned by this source.
func (p *Provider) ModelByID(id string) *config.Model {
for i := range p.cfg.Models {
if p.cfg.Models[i].ID == id {
return &p.cfg.Models[i]
}
}
return nil
}
// ModelIDFold returns the configured model id matching id case-insensitively
// ("" when no model matches). AUTO-chain slot models are normalized through
// this so cooldown state, quota windows and the upstream model id all refer
// to the exact configured spelling.
func (p *Provider) ModelIDFold(id string) string {
for _, m := range p.cfg.Models {
if strings.EqualFold(m.ID, id) {
return m.ID
}
}
return ""
}
// ModelFor resolves the model name this provider should send upstream.
// If the requested model is not owned by this provider (e.g. an AUTO chain
// fallback), it returns this provider's highest-priority chat model instead.
func (p *Provider) ModelFor(reqModel string) string {
if reqModel == "" || isAutoID(reqModel) {
return p.bestChatModel()
}
if p.ModelByID(reqModel) != nil {
return reqModel
}
return p.bestChatModel()
}
// bestChatModel returns the first chat-kind model of this source.
//
// ★ 曾经叫 "highest-priority chat model",但 `config.Model.Priority` 已删除
//
// (它从未参与跨源调度,只在这里用;而这里的语义其实只是"选一个能发 chat 的
// 模型",选第一个就够了 —— 源内模型顺序由配置/发现顺序决定)。
func (p *Provider) bestChatModel() string {
for _, m := range p.cfg.Models {
if m.Kind != "" && m.Kind != "chat" {
continue
}
return m.ID
}
if len(p.cfg.Models) > 0 {
return p.cfg.Models[0].ID
}
return ""
}
// bestImageModel returns the first image-kind model of this source (fallback:
// first configured model). Used for AUTO image generation so a mixed source
// never sends a chat model to /v1/images/generations.
func (p *Provider) bestImageModel() string {
for _, m := range p.cfg.Models {
if m.Kind != "" && m.Kind != "image" {
continue
}
return m.ID
}
if len(p.cfg.Models) > 0 {
return p.cfg.Models[0].ID
}
return ""
}
// IsAutoID reports whether s is an AUTO routing placeholder.
func isAutoID(s string) bool {
s = strings.TrimSpace(s)
return s == "" || strings.EqualFold(s, "AUTO")
}
// Endpoint resolves the upstream chat path. It may contain the placeholder
// "{model}"; use URL (or ChatURL) rather than calling this directly when the
// path has to be usable.
func (p *Provider) Endpoint() string {
if p.cfg.Endpoint != "" {
return p.cfg.Endpoint
}
if ep := p.vm.Endpoint(p.adapter); ep != "" {
return ep
}
return "/chat/completions"
}
// ImageEndpoint resolves the upstream image-generation path.
func (p *Provider) ImageEndpoint() string {
if p.cfg.ImageEndpoint != "" {
return p.cfg.ImageEndpoint
}
if ep := p.vm.Endpoint(p.adapter + "_image"); ep != "" {
return ep
}
return "/v1/images/generations"
}
// modelPlaceholder is the marker an adapter puts in its endpoint template when
// the upstream carries the model id in the PATH rather than in the body.
// Every other adapter's endpoint is a fixed path, so substituting is a no-op
// for them.
const modelPlaceholder = "{model}"
// ChatURL resolves the full chat URL for a specific model.
//
// Two substitutions happen here, both driven by endpoint declarations:
//
// - "{model}" is replaced by the model id actually being sent. Without this
// the gateway would POST to Gemini's model-LIST endpoint, which answers 405.
// - a streaming request may use a DIFFERENT endpoint. This is deliberately
// not a vendor rule: the kernel does not know that one vendor's verb
// changes, only that an endpoint declaration said "use this other path
// when streaming".
//
// Precedence (a source that spells its own path takes full responsibility for
// it — the old behaviour, which must not regress):
//
// source.endpoint_stream > source.endpoint > adapter.endpoint_stream > adapter.endpoint
//
// Note the third slot only applies when the source did NOT spell its own
// chat endpoint. A source with `endpoint: /custom/path` keeps that path for
// streaming too, because the operator overrode the adapter's template and the
// kernel has no business re-adding the adapter's stream path on top.
func (p *Provider) ChatURL(model string, stream bool) string {
ep := p.Endpoint()
if stream {
switch {
case p.cfg.StreamEndpoint != "":
ep = p.cfg.StreamEndpoint
case p.cfg.Endpoint == "":
// Source did not override the chat path, so the adapter's streaming
// endpoint (if any) is authoritative.
if se := p.vm.StreamEndpoint(p.adapter); se != "" {
ep = se
}
}
}
if strings.Contains(ep, modelPlaceholder) {
// A model id is put in a URL path, so it must be escaped: an id with a
// slash would otherwise silently address a different resource.
ep = strings.ReplaceAll(ep, modelPlaceholder, url.PathEscape(model))
}
return strings.TrimRight(p.cfg.BaseURL, "/") + ep
}
func (p *Provider) URL() string {
return p.ChatURL("", false)
}
func (p *Provider) ImageURL() string {
return strings.TrimRight(p.cfg.BaseURL, "/") + p.ImageEndpoint()
}
// ---- availability ----
// ErrBusy is returned when every concurrency slot of a source is in use. It
// is a soft signal: schedulers skip a busy candidate without recording any
// failure (busy is not a failure) and gateways map it to HTTP 429. It aliases
// types.ErrBusy so the scheduler layer (which must not depend on this package)
// can detect busy via the shared sentinel.
var ErrBusy = types.ErrBusy
// Available reports whether the source is schedulable at source level: at
// least one of its models is not cooling down. Per-model scheduling decisions
// must use ModelAvailable instead.
func (p *Provider) Available() bool {
p.mu.Lock()
defer p.mu.Unlock()
for _, s := range p.states {
if s.Available() {
return true
}
}
return false
}
// ModelAvailable reports whether the exact model is schedulable right now
// (its cooldown expired). An unknown model id is treated as available.
func (p *Provider) ModelAvailable(model string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if s, ok := p.states[model]; ok {
return s.Available() && s.Pref() > prefMin
}
return true
}
// ModelSchedulable is the scheduling gate that replaces a bare ModelAvailable
// check. It reports whether the model may take a request right now and whether
// doing so is a cooldown PROBE rather than normal traffic:
//
// - (true, false): fully available, schedule normally.
// - (true, true): cooling, but past the half-cooldown mark and this caller
// claimed the probe permit. The caller MUST call ProbeDone(model) once the
// attempt finished, and SHOULD prefer non-probe candidates first.
// - (false, false): cooling and not probeable (first half of the window, the
// permit is taken, or the slot is persistently bad).
//
// Probing is what stops a recovered upstream from sitting out a whole cooldown:
// the probe is a real request, so success immediately clears the cooldown via
// RecordSuccess and the slot is back in full rotation.
func (p *Provider) ModelSchedulable(model string) (ok bool, isProbe bool) {
p.mu.Lock()
s, known := p.states[model]
p.mu.Unlock()
if !known {
return true, false
}
if s.Available() && s.Pref() > prefMin {
return true, false
}
if s.TryProbe() {
return true, true
}
return false, false
}
// ProbeDone releases a probe permit claimed through ModelSchedulable.
func (p *Provider) ProbeDone(model string) { p.state(model).ProbeDone() }
// ProbeSlots is how many concurrent probe requests this source tolerates while
// a model cools: one per 10 configured concurrency slots, at least 1 and never
// more than 2. A source configured for max_concurrent=10 therefore lets exactly
// one request through to probe, which is the intended "small-scale probe"
// behaviour; a large source still cannot flood a cooling upstream.
func (p *Provider) ProbeSlots() int {
n := p.cfg.MaxConcurrent / 10
if n < 1 {
return 1
}
if n > 2 {
return 2
}
return n
}
// Pref returns the adaptive preference score of a model (higher = preferred
// within a priority tier). Used by the AUTO chain to order same-tier slots.
func (p *Provider) Pref(model string) int64 {
return p.state(model).Pref()
}
// ResetModelCooldown clears the cooldown and failure counter of a single
// model while preserving its preference score. Called after AUTO-chain edits
// so edited slots become schedulable immediately (a stored preference for a
// reliably good model is kept).
func (p *Provider) ResetModelCooldown(model string) {
s := p.state(model)
s.failCount.Store(0)
s.cooldownUntil.Store(0)
}
// ModelHealthInfo exposes the per-model scheduling state for the web UI.
// An unknown model id reports zeros.
func (p *Provider) ModelHealthInfo(model string) (pref, failCount, cooldownUntil int64) {
s := p.state(model)
return s.Pref(), s.FailCount(), s.CooldownUntil()
}
// ModelProbeInfo exposes the probe-gate state for the web UI: when the current
// cooldown window opened, when probing becomes allowed (its midpoint), and
// whether a probe is in flight right now. All zero when not cooling.
func (p *Provider) ModelProbeInfo(model string) (from, probeAfter int64, probing bool) {
s := p.state(model)
until := s.CooldownUntil()
from = s.CooldownFrom()
if until <= 0 || from <= 0 || from >= until {
return 0, 0, s.Probing()
}
return from, from + (until-from)/2, s.Probing()
}
// Probe performs a lightweight reachability + auth check against the source.
// It first tries GET <base>/models (fast, ~1s for OpenAI-compatible upstreams)
// and only falls back to a 1-token chat call when that endpoint is
// unavailable. It does NOT touch the health/backoff state so probing never
// disables a source.
func (p *Provider) Probe(ctx context.Context) (bool, string) {
ok, msg := p.probeModels(ctx)
if !ok && msg == "" {
ok, msg = p.probeChat(ctx)
}
p.mu.Lock()
p.lastProbe.ok = ok
p.lastProbe.err = msg
p.lastProbe.at = time.Now().Unix()
p.mu.Unlock()
return ok, msg
}
// probeModels GETs <base>/models. Returns (true,…) when reachable, (false,
// errortext) on an auth/permanent failure, and (false,"") when the endpoint
// simply isn't available so the caller can fall back to a chat probe.
func (p *Provider) probeModels(ctx context.Context) (bool, string) {
u := strings.TrimRight(p.cfg.BaseURL, "/") + "/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, ""
}
if hdrs, herr := p.buildHeaders("{}", u, ""); herr == nil {
req.Header = hdrs
}
resp, err := p.client.Do(req)
if err != nil {
return false, ""
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
switch {
case resp.StatusCode == 200:
return true, ""
case resp.StatusCode == 404 || resp.StatusCode == 405:
return false, ""
default:
return false, p.apiErrReason(resp.StatusCode, string(raw))
}
}
// probeChat sends a minimal single-token chat request to the chat endpoint.
func (p *Provider) probeChat(ctx context.Context) (bool, string) {
ok := false
msg := ""
model := p.bestChatModel()
if pm := p.ModelByID(model); pm != nil && pm.Kind == "image" {
model = ""
}
if model == "" {
if ms := p.Models(); len(ms) > 0 {
model = ms[0]
}
}
if model == "" {
msg = "no chat model configured"
} else {
probe := map[string]interface{}{
"model": model,
"messages": []map[string]interface{}{{"role": "user", "content": "hi"}},
"max_tokens": 1,
}
body, err := json.Marshal(probe)
if err == nil {
var hdr http.Header
probeURL := p.ChatURL(model, false)
if hdrs, herr := p.buildHeaders(string(body), probeURL, ""); herr == nil {
hdr = hdrs
}
raw, status, derr := p.do(ctx, probeURL, string(body), hdr)
if derr != nil {
msg = derr.Error()
} else if status == 200 {
ok = true
} else {
msg = p.apiErrReason(status, raw)
}
} else {
msg = err.Error()
}
}
return ok, msg
}
func (p *Provider) LastProbe() (bool, string, int64) {
p.mu.Lock()
defer p.mu.Unlock()
return p.lastProbe.ok, p.lastProbe.err, p.lastProbe.at
}
// state returns the ModelState for a model id, creating it on first use so
// dynamically requested models are still tracked. The registry keeps states
// alive across provider rebuilds only for configured models; a lazily created
// state simply lives for the provider's lifetime.
func (p *Provider) state(model string) *ModelState {
p.mu.Lock()
defer p.mu.Unlock()
s, ok := p.states[model]
if !ok {
s = &ModelState{}
p.states[model] = s
}
return s
}
// RecordFailure records a failed downstream attempt on model: consecutive
// failure count +1 and exponential cooldown (5s·2^n, capped at 30min).
// code 401/403 is treated as a credential problem: the cooldown jumps to the
// cap and the preference penalty doubles, but the model still recovers when
// the cooldown expires (no permanent blacklist). code 0 = transport failure.
func (p *Provider) RecordFailure(model string, code int) {
p.state(model).RecordFailure(code == 401 || code == 403)
}
// RecordSuccess resets the model's failure counter / cooldown and bumps its
// preference by one.
func (p *Provider) RecordSuccess(model string) {
p.state(model).RecordSuccess()
}
// ReportStatus records an upstream HTTP status for the given model and drives
// the (source, model) backoff state. 401/403 → long self-healing authCooldown
// with doubled penalty; 5xx → normal exponential backoff; 429 → short fixed
// cooldown (rateLimitCooldown): a rate rejection means "too fast", not
// "broken", so the slot must re-enter rotation quickly instead of escalating
// to a long lockout that wastes remaining quota.
//
// Prefer ReportStatusReason when the upstream error text is available: a
// quota-exhausted rejection is cooled to its window boundary instead of being
// treated as a failure streak.
func (p *Provider) ReportStatus(model string, code int) {
p.ReportStatusReason(model, code, "")
}
// ReportStatusReason is ReportStatus plus the upstream error text, which lets a
// quota/allowance rejection be distinguished from a generic rate limit. Quota
// exhaustion is not an error streak: it is cooled until the allowance window
// can plausibly have reset, and never inflates the exponential ladder.
func (p *Provider) ReportStatusReason(model string, code int, reason string) {
if code == 401 || code == 403 {
p.RecordFailure(model, code)
return
}
if code == 429 || code == 402 {
if quotaExhaustedReason(reason) {
p.state(model).RecordQuotaExhausted(p.quotaResetIn(model))
return
}
p.state(model).RecordRateLimit()
return
}
if code >= 500 {
p.RecordFailure(model, code)
}
}
// quotaResetIn estimates how long the model's allowance window still has to
// run. Upstreams do not report this, so it is taken from the source's optional
// `quota_hours` meta hint when present (aligned to the epoch, matching the
// gateway's own quota-window convention) and otherwise falls back to
// quotaDefaultCooldown. Either way the value is capped by maxQuotaCooldown, so
// a wrong hint can only delay recovery to that bound — and the half-cooldown
// probe fires at half of it.
func (p *Provider) quotaResetIn(model string) time.Duration {
hours := p.quotaHours()
if hours <= 0 {
return quotaDefaultCooldown
}
win := int64(hours * float64(time.Hour/time.Second))
if win <= 0 {
return quotaDefaultCooldown
}
elapsed := time.Now().Unix() % win
return time.Duration(win-elapsed) * time.Second
}
// quotaHours reads the optional per-source `quota_hours` meta hint describing
// how long its allowance window is. Absent or unparseable = 0 (unknown).
func (p *Provider) quotaHours() float64 {
v, ok := p.cfg.Meta["quota_hours"]
if !ok {
return 0
}
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0
}
return f
}
return 0
}
// quotaKeywords are the upstream phrases that mean "you are out of allowance"
// rather than "you are going too fast". Matching is case-insensitive substring
// matching against the adapter-normalized error reason.
var quotaKeywords = []string{
"insufficient_quota",
"insufficient quota",
"quota exceeded",
"quota_exceeded",
"exceeded your current quota",
"out of credit",
"insufficient balance",
"insufficient_user_quota",
"billing_hard_limit_reached",
"credit balance is too low",
"余额不足",
"额度不足",
"额度已用完",
}
func quotaExhaustedReason(reason string) bool {
if reason == "" {
return false
}
low := strings.ToLower(reason)
for _, k := range quotaKeywords {
if strings.Contains(low, k) {
return true
}
}
return false
}
// ResetHealth resets the scheduling state of every model of this source
// (cooldown and preference to zero), so the source becomes fully schedulable
// again. Called after AUTO-chain edits and from the admin UI.
func (p *Provider) ResetHealth() {
p.mu.Lock()
defer p.mu.Unlock()
for _, s := range p.states {
s.reset()
}
}
// HealthInfo exposes the source-level backoff state for the status page: the
// highest failure count and the latest cooldown deadline across all models of
// this source. permanent is always false — the permanent-blacklist semantics
// were removed; every cooldown expires on its own.
func (p *Provider) HealthInfo() (failCount int, until time.Time, permanent bool) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now().Unix()
for _, s := range p.states {
if n := int(s.FailCount()); n > failCount {
failCount = n
}
if t := s.CooldownUntil(); t > now && t > until.Unix() {
until = time.Unix(t, 0)
}
}
return failCount, until, false
}
// ---- concurrency limiting ----
// TryAcquire takes one concurrency slot without blocking: it returns nil when
// a slot is free and ErrBusy when the source is at capacity. A nil semaphore
// (MaxConcurrent <= 0) means unlimited and always succeeds. TryAcquire is the
// single busy/idle signal for schedulers; a busy source is skipped, never
// penalized.
func (p *Provider) TryAcquire(ctx context.Context) error {
if p.sem == nil {
return nil
}
select {
case p.sem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return ErrBusy
}
}
// Acquire waits for a free concurrency slot (bounded by cfg.QueueTimeout),
// or context cancel. The HTTP call itself is not truncated. Direct requests
// historically queued here; the scheduler now prefers TryAcquire so a full
// source fails fast instead of blocking the whole chain.
func (p *Provider) Acquire(ctx context.Context) error {
if p.sem == nil {
return nil
}
var qCtx context.Context
var cancel context.CancelFunc
if p.cfg.QueueTimeout > 0 {
qCtx, cancel = context.WithTimeout(ctx, p.cfg.QueueTimeout)
} else {
qCtx, cancel = context.WithCancel(ctx)
}
defer cancel()
select {
case p.sem <- struct{}{}:
return nil
case <-qCtx.Done():
return qCtx.Err()
}
}
func (p *Provider) Release() {
if p.sem == nil {
return
}
<-p.sem
}
// ---- proactive rate limiting ----
// Throttle blocks until this source's rate-limit window allows the next
// request, spacing outbound requests at least rpmGap apart (60s/RPM). It is a
// no-op when no rpm cap is configured. The wait happens AFTER the concurrency
// slot is taken, so a queued request still counts against max_concurrent —
// bounded by the caller's ctx (queue timeout / client disconnect).
func (p *Provider) Throttle(ctx context.Context) error {
if p.rpmGap <= 0 {
return nil
}
p.rateMu.Lock()
now := time.Now().UnixNano()
wait := p.nextOK - now
if wait > 0 {
p.nextOK += int64(p.rpmGap) // reserve the next window for the follower
p.rateMu.Unlock()
t := time.NewTimer(time.Duration(wait))
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
p.nextOK = now + int64(p.rpmGap)
p.rateMu.Unlock()
return nil
}
// ---- request construction ----
func (p *Provider) buildHeaders(body, url, clientSession string) (http.Header, error) {
meta := map[string]interface{}{
"url": url,
"method": http.MethodPost,
"body": body,
"api_key": p.cfg.APIKey,
"timestamp": types.Now(),
// client_session 是客户端自带的会话标识(请求头),为空时适配器自行推导。
"client_session": clientSession,
"source": map[string]interface{}{
"name": p.cfg.Name,
"meta": p.cfg.Meta,
},
}
hdrs, err := p.vm.BuildHeaders(p.adapter, meta)
if err != nil {
return nil, err
}
h := http.Header{}
h.Set("Content-Type", "application/json")
for k, v := range p.cfg.Headers {
h.Set(k, v)
}
for k, v := range hdrs {
if _, ok := p.cfg.Headers[k]; !ok {
h.Set(k, v)
}
}
if h.Get("Authorization") == "" && p.cfg.APIKey != "" {
h.Set("Authorization", "Bearer "+p.cfg.APIKey)
}
return h, nil
}
// ---- chat ----
// Chat performs a non-streaming round trip and returns the unified response.
// It fails fast with ErrBusy when the source is at capacity; success/failure
// is recorded against the resolved (source, model) scheduling state.
func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error) {
if err := p.TryAcquire(ctx); err != nil {
return nil, err
}
defer p.Release()
if err := p.Throttle(ctx); err != nil {
return nil, err
}
model := p.ModelFor(req.Model)
body, err := marshalTransform(p.vm, p.adapter, "transform_request", req)
if err != nil {
return nil, err
}
chatURL := p.ChatURL(model, false)
hdrs, err := p.buildHeaders(body, chatURL, req.ClientSession)
if err != nil {
return nil, err
}
raw, status, err := p.do(ctx, chatURL, body, hdrs)
if err != nil {
// a client disconnect or cancelled context is neither a success nor
// a failure for scheduling purposes — only upstream errors count
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, err
}
if status != 200 {
reason := p.apiErrReason(status, raw)
p.ReportStatusReason(model, status, reason)
return nil, fmt.Errorf("%s", reason)
}
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
if err != nil {
// adapter produced unusable output: a real failure the slot must
// back off from, otherwise a broken adapter source is retried at
// full latency forever
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, err
}
var out types.UnifiedResponse
if err := json.Unmarshal([]byte(unified), &out); err != nil {
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unified)
}
p.RecordSuccess(model)
return &out, nil
}
// ChatStream performs a streaming round trip, emitting unified chunks. It
// fails fast with ErrBusy when the source is at capacity. Only a failure
// before the first chunk (connect error or non-200 status) is recorded
// against the (source, model) state; afterwards the stream is pinned. A clean
// end ([DONE] or EOF without read errors, and no client disconnect) counts as
// success and resets the cooldown.
func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error) {
if err := p.TryAcquire(ctx); err != nil {
return nil, err
}
if err := p.Throttle(ctx); err != nil {
p.Release()
return nil, err
}
model := p.ModelFor(req.Model)
req.Stream = true
body, err := marshalTransform(p.vm, p.adapter, "transform_request", req)
if err != nil {
p.Release()
return nil, err
}
// stream=true so a path-carried model plus the streaming verb is resolved
// for THIS request's model, not the source default.
streamURL := p.ChatURL(model, true)
hdrs, err := p.buildHeaders(body, streamURL, req.ClientSession)
if err != nil {
p.Release()
return nil, err
}
type respOrErr struct {
resp *http.Response
err error
}
rc := make(chan respOrErr, 1)
go func() {
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
rc <- respOrErr{resp, err}
}()
inner := make(chan types.UnifiedChunk, 64)
sel := <-rc
if sel.err != nil {
// client disconnect/cancel before the first byte: not a scheduling
// failure (a healthy source must not be cooled by client cancellations)
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
p.Release()
return nil, sel.err
}
if sel.resp.StatusCode != 200 {
raw, _ := io.ReadAll(sel.resp.Body)
sel.resp.Body.Close()
reason := p.apiErrReason(sel.resp.StatusCode, string(raw))
p.ReportStatusReason(model, sel.resp.StatusCode, reason)
p.Release()
return nil, fmt.Errorf("%s", reason)
}
go func() {
defer p.Release()
defer close(inner)
defer sel.resp.Body.Close()
scanner := bufio.NewScanner(sel.resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var chunks int
var realChunks int
var doneSeen bool
var doneSent bool
// toolIdx maps a per-stream tool fragment's upstream index (Anthropic
// content-block ordinal, which is sparse when thinking/text blocks
// precede tool_use) to a dense 0-based OpenAI tool_call ordinal so
// clients that accumulate fragments by index reassemble multi-tool
// responses correctly.
toolIdx := newToolIndexRemapper()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
if data == "[DONE]" {
doneSeen = true
// adapters that already emitted their terminating done chunk
// (with the real finish reason) must not get a second,
// reason-less done from [DONE] — it would override the true
// finish_reason downstream.
if !doneSent {
select {
case inner <- types.UnifiedChunk{Done: true}:
case <-ctx.Done():
}
}
continue
}
unified, err := p.vm.Transform(p.adapter, "transform_stream_chunk", data)
if err != nil || unified == "" {
continue
}
if unified == data {
unified = standardSSEChunk(data)
if unified == "" {
continue
}
}
var ck types.UnifiedChunk
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue
}
ck.ToolCalls = toolIdx.remap(ck.ToolCalls)
if ck.Done {
doneSent = true
}
if !errorOnlyChunk(ck) {
realChunks++
}
chunks++
select {
case inner <- ck:
case <-ctx.Done():
return
}
}
// The stream ended cleanly ([DONE] seen or EOF without an upstream
// read error): record success so a previously cooled model can be
// retried. A client disconnect or mid-stream read error is neither
// success nor failure for scheduling purposes. A 200 that produced
// zero chunks and no [DONE] is an empty stream, i.e. a failure
// before the first chunk — record it so the slot can fall back.
// A stream whose only payload was error-only done chunks (e.g.
// finish_reason:"network_error") is likewise a failure, not a success.
if ctx.Err() == nil && scanner.Err() == nil {
if realChunks > 0 || (doneSeen && chunks == 0) {
p.RecordSuccess(model)
} else {
p.RecordFailure(model, 0)
}
}
}()
// Hold back the first chunk to validate the stream actually carries
// content: some upstreams answer HTTP 200 with a degenerate stream whose
// only payload is an error finish reason (zen free pool sends
// finish_reason:"network_error" with empty delta). Failing the candidate
// here — before any byte reaches the gateway — lets the scheduler fall
// through to the next source instead of serving the client an empty reply.
type heldChunk struct {
ck types.UnifiedChunk
ok bool
}
var first heldChunk
select {
case ck, ok := <-inner:
first = heldChunk{ck, ok}
case <-ctx.Done():
return nil, ctx.Err()
}
if !first.ok {
return nil, fmt.Errorf("provider %s: empty stream", p.Name())
}
if errorOnlyChunk(first.ck) {
go func() {
for range inner {
}
}()
return nil, fmt.Errorf("provider %s: upstream returned %q stream",
p.Name(), first.ck.FinishReason)
}
out := make(chan types.UnifiedChunk, 64)
go func() {
defer close(out)
out <- first.ck
for ck := range inner {
out <- ck
}
}()
return out, nil
}
// errorOnlyChunk reports whether ck carries nothing but an upstream error
// signal: a done chunk with a non-standard finish reason and zero content,
// tool calls, reasoning text or usage. Standard OpenAI finish reasons are
// never classified as errors, so legitimate instant-empty completions
// (finish_reason:"stop", no output) still reach the client.
// toolIndexRemapper maps per-stream tool_call fragment indices from a sparse
// upstream ordinal (e.g. Anthropic content_block index, which skips over
// thinking/text blocks) to a dense 0-based OpenAI tool_call ordinal.
type toolIndexRemapper struct {
seen map[int]int // upstream index -> dense ordinal
next int // next dense ordinal to assign
}
func newToolIndexRemapper() toolIndexRemapper {
return toolIndexRemapper{seen: make(map[int]int)}
}
func (t *toolIndexRemapper) remap(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return raw
}
// Decode into generic maps so every upstream field is preserved verbatim;
// only the index value is rewritten.
var frags []map[string]json.RawMessage
if err := json.Unmarshal(raw, &frags); err != nil || len(frags) == 0 {
return raw
}
changed := false
for i := range frags {
orig := 0
if v, ok := frags[i]["index"]; ok {
if err := json.Unmarshal(v, &orig); err != nil {
continue
}
}
mapped, ok := t.seen[orig]
if !ok {
mapped = t.next
t.next++
t.seen[orig] = mapped
}
if mapped != orig {
b, err := json.Marshal(mapped)
if err != nil {
continue
}
frags[i]["index"] = b
changed = true
}
}
if !changed {
return raw
}
out, err := json.Marshal(frags)
if err != nil {
return raw
}
return out
}
func errorOnlyChunk(ck types.UnifiedChunk) bool {
if !ck.Done || ck.FinishReason == "" {
return false
}
switch ck.FinishReason {
case "stop", "length", "tool_calls", "function_call", "content_filter":
return false
}
return ck.Content == "" && len(ck.ToolCalls) == 0 &&
ck.ReasoningContent == "" && ck.Usage == nil
}
// apiErrReason builds the client-facing reason for a non-200 upstream
// response: the adapter's optional transform_error hook wins (per-source
// protocol knowledge lives in Lua), otherwise clients get a uniform
// "unknown error" while the raw body stays in the server log for debugging.
func (p *Provider) apiErrReason(status int, raw string) string {
if reason, ok, err := p.vm.TransformError(p.adapter, status, raw); err == nil && ok {
if trimmed := strings.TrimSpace(reason); trimmed != "" {
return fmt.Sprintf("api error %d: %s", status, types.OneLine(trimmed, 200))
}
}
// ⚠️ 措辞必须**区分两种原因**(实测教训):适配器**有** transform_error 钩子
// 但它返回 nil(不认识的错误体)是**常见**情况,而"真的没这个钩子"是另一回事。
// 原先一律说 "lacks transform_error" —— 我照着这句话去适配器里找那个函数,
// 查了几轮才发现函数明明在、只是抽不出消息。
// 这里直接问内核要事实,不再猜(判据也据此断言)。
if p.vm.HasFn(p.adapter, "transform_error") {
log.Printf("[provider] unhandled upstream error body (adapter %q: transform_error returned no reason for this shape): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
} else {
log.Printf("[provider] unhandled upstream error body (adapter %q defines no transform_error): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
}
return fmt.Sprintf("api error %d: unknown error", status)
}
// Image generates images via /v1/images/generations. Same scheduling-state
// accounting as Chat: fail fast on busy, record per (source, model).
func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error) {
if err := p.TryAcquire(ctx); err != nil {
return nil, err
}
defer p.Release()
// AUTO/unknown model: resolve to this source's image model, never to the
// best chat model (a mixed source would otherwise send a chat id to
// /v1/images/generations). An explicitly pinned model is honored as-is.
if isAutoID(req.Model) || p.ModelByID(req.Model) == nil {
r := *req
r.Model = p.bestImageModel()
req = &r
}
model := req.Model
b, _ := json.Marshal(req)
transformed, err := p.vm.Transform(p.adapter+"_image", "transform_request", string(b))
if err != nil {
// fall back to passthrough adapter (openai-style)
transformed = string(b)
}
hdrs, err := p.buildHeaders(transformed, p.ImageURL(), "")
if err != nil {
return nil, err
}
raw, status, err := p.do(ctx, p.ImageURL(), transformed, hdrs)
if err != nil {
// client disconnect/cancel: not a scheduling failure
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, err
}
if status != 200 {
reason := p.apiErrReason(status, raw)
p.ReportStatusReason(model, status, reason)
return nil, fmt.Errorf("%s", reason)
}
var out types.UnifiedResponse
// try adapter transform_response; if missing, parse standard openai image format
unified, terr := p.vm.Transform(p.adapter+"_image", "transform_response", raw)
if terr == nil && unified != raw {
if err := json.Unmarshal([]byte(unified), &out); err == nil {
out.Model = model // actual model served (AUTO resolved to bestImageModel)
p.RecordSuccess(model)
return &out, nil
}
}
var img types.ImageGenResponse
if err := json.Unmarshal([]byte(raw), &img); err != nil {
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, fmt.Errorf("unmarshal image response: %w", err)
}
out.ImageData = img.Data
out.Model = model // actual model served (AUTO resolved to bestImageModel)
p.RecordSuccess(model)
return &out, nil
}
// ---- http helpers ----
func (p *Provider) do(ctx context.Context, url, body string, hdr http.Header) (string, int, error) {
resp, err := p.doRaw(ctx, url, body, hdr)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return string(raw), resp.StatusCode, nil
}
func (p *Provider) doRaw(ctx context.Context, url, body string, hdr http.Header) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
if err != nil {
return nil, err
}
httpReq.Header = hdr
return p.client.Do(httpReq)
}
// doRawStream is the streaming variant of doRaw: it uses the no-overall-timeout
// stream client so a long SSE body is not cut by client.Timeout. The transport
// still enforces ResponseHeaderTimeout on time-to-first-byte.
func (p *Provider) doRawStream(ctx context.Context, url, body string, hdr http.Header) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
if err != nil {
return nil, err
}
httpReq.Header = hdr
return p.stream.Do(httpReq)
}
func marshalTransform(vm *lua.VM, adapter, fn string, v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
out, err := vm.Transform(adapter, fn, string(b))
if err != nil {
return "", err
}
return out, nil
}
func standardSSEChunk(data string) string {
var raw struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
UpstreamUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Prompt int `json:"prompt"`
Completion int `json:"completion"`
Total int `json:"total"`
PromptCacheHit int `json:"prompt_cache_hit_tokens"`
PromptCacheMiss int `json:"prompt_cache_miss_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
} `json:"usage"`
// Cost is a top-level decimal string on OpenCode Zen/Go responses.
Cost json.RawMessage `json:"cost"`
}
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return ""
}
if len(raw.Choices) == 0 && raw.UpstreamUsage.Total == 0 && raw.UpstreamUsage.TotalTokens == 0 {
// Cost-only chunk: OpenCode sends the charge on its own frame
// {"choices":[],"cost":"0"} with no usage at all. Dropping it here
// would lose the only cost signal the client can see.
if c := rawCostString(raw.Cost); c != "" {
out, _ := json.Marshal(types.UnifiedChunk{Cost: c})
return string(out)
}
return ""
}
var usage *types.TokenUsage
pu := raw.UpstreamUsage
if pu.Total > 0 || pu.TotalTokens > 0 {
usage = &types.TokenUsage{
Prompt: pickFirst(pu.PromptTokens, pu.Prompt),
Completion: pickFirst(pu.CompletionTokens, pu.Completion),
Total: pickFirst(pu.TotalTokens, pu.Total),
PromptCacheHit: pu.PromptCacheHit,
PromptCacheMiss: pu.PromptCacheMiss,
}
if pu.PromptTokensDetails != nil && pu.PromptTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails = &types.PromptTokensDetails{
CachedTokens: pu.PromptTokensDetails.CachedTokens,
}
}
if pu.CompletionTokensDetails != nil {
usage.CompletionTokensDetails = &types.CompletionTokensDetails{
ReasoningTokens: pu.CompletionTokensDetails.ReasoningTokens,
}
}
}
finish := ""
done := false
if len(raw.Choices) > 0 && raw.Choices[0].FinishReason != nil {
// empty-string finish reasons (sensenova sends "" on every chunk)
// are not a finish signal
if fr := *raw.Choices[0].FinishReason; fr != "" {
finish, done = fr, true
}
}
out, _ := json.Marshal(types.UnifiedChunk{
Content: func() string {
if len(raw.Choices) > 0 {
return raw.Choices[0].Delta.Content
}
return ""
}(),
Done: done,
FinishReason: finish,
Usage: usage,
})
return string(out)
}
// rawCostString normalizes an upstream cost field into a plain string. The
// value is a decimal **string** on OpenCode Zen/Go ("0", "0.0012"), but other
// upstreams have been seen to send a bare number, so both are accepted.
// Returns "" when absent, null, or empty.
func rawCostString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
if s[0] == '"' {
var unq string
if json.Unmarshal(raw, &unq) == nil {
return strings.TrimSpace(unq)
}
}
return s
}
// pickFirst returns a if non-zero, else b (for usage keys that may appear in
// either standard *_tokens or legacy short form).
func pickFirst(a, b int) int {
if a != 0 {
return a
}
return b
}