Compare commits

..

8 Commits
1.1.0 ... 1.2.0

Author SHA1 Message Date
a5384d9fb6 chore: bump version to 1.2.0 2026-08-27 12:26:36 +08:00
ab20f1be48 fix(webui): specify DELETE method for source/template removal
delSource and delTemplate called api() without a method, so fetch
defaulted to GET; the DELETE handlers never ran and the UI silently
left the item in place.
2026-08-27 12:14:41 +08:00
8334cffbc9 feat(templates): source templates for multi-key balancing
A template stores every source field except name and api_key, so
operators spin up N key-bearing sources from one shared skeleton
instead of duplicating the whole source block N times.

- config: SourceTemplate type + RuntimeConfig.SourceTemplates stored
  in runtime.json alongside runtime sources
- store: UpsertTemplate / ListTemplates / RemoveTemplate
- core: Templates / SaveTemplate / RemoveTemplate
- gateway: GET/POST/DELETE /api/source_templates
- webui: source list gains a Templates button opening a manager with
  per-template edit/delete; the add-source dialog gains 'from
  template' (event-delegated picker) and 'as template' (card modal)
  buttons in its header; z-index fixed so the template editor layers
  above the manager
2026-08-27 12:09:38 +08:00
a42ff62d06 feat(scheduler): separate image-generation AUTO chain with UI toggle
Image models previously could not be scheduled through a priority
chain: the chat AUTO chain explicitly skips image-kind slots, and
AUTO image requests fell back to unordered registry discovery.

- config: add auto_image rules (auto_image yaml / image_rules json);
  legacy auto rules keep their meaning as the chat chain
- core: buildAutoImageChain mirrors buildAutoChain with inverted kind
  filter (image-only); SaveAutoImageRules + AutoImageRules/AutoImageChain
- scheduler: ChainImage walks the chain tier-by-tier with round-robin
  and preference ordering, skipping cooling slots
- gateway: handleImage AUTO now runs down AutoImageChain when one is
  configured (falls back to legacy discovery otherwise) and records
  the actual served model; handleAutoAPI GET returns image_rules and
  PUT accepts image_rules independently of rules
- webui: priority page gains a chat/image toggle editing two
  independent lane sets; add-slot picker filters by active kind;
  persistAuto writes only the active chain's field
2026-08-26 21:03:51 +08:00
66585549f1 fix(webui): allow image models in priority/AUTO chain editor
The priority page and key-scope pickers skipped kind=image models,
so image sources (e.g. Kwai-Kolors/Kolors) could not be placed in
the AUTO chain or granted per-key. The backend already supports it:
handleImage resolves AUTO through the chain then filters with
imageOnly, while chat requests are protected by chatOnly, so image
slots never receive chat traffic.

Image models now appear in the priority canvas, the add-slot picker
(labelled ' (image)'), and the key scope dialog.
2026-08-26 20:36:05 +08:00
747dff5b76 fix(gateway): record actual served model for image requests
handleImage recorded the raw request model id, so AUTO image
generations showed up as model=AUTO in the request records and
by-model aggregates instead of the image model actually served
(e.g. Kwai-Kolors/Kolors).

UnifiedResponse gains an optional Model field; Provider.Image fills
it with the resolved id (AUTO resolves to the source's best image
model), and handleImage prefers it when writing the audit record.
2026-08-26 20:28:20 +08:00
5d045f97a8 chore: bump version to 1.1.1
Includes WebUI fix e48baa1 (missing HTTP method on fetch calls with
body) which was committed after the 1.1.0 installers were built.
2026-08-26 15:53:54 +08:00
e48baa1bb3 fix(webui): missing HTTP method on fetch calls with body
6 call sites used the fetch default GET while sending a request body,
which browsers reject outright ('Request with GET/HEAD method cannot
have body'). Affected flows: create key, save source, save auto rules,
upload adapter, update key model scope, and /api/chat streaming.

All now send the method their backend handlers require (POST or PUT)
with an explicit Content-Type.
2026-08-26 00:34:57 +08:00
14 changed files with 745 additions and 80 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ cmd/gui/*.log
# local ops scripts (machine-specific, not for sharing)
scripts/
cmd/gui/dist/

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.0.0",
"version": "1.2.0",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",
@ -4045,4 +4045,4 @@
}
}
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.1.0",
"version": "1.2.0",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -24,7 +24,8 @@ type Config struct {
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request
Sources []Source `yaml:"sources"`
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑)
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
}
@ -313,11 +314,31 @@ func (c *Config) ApplyDefaults() error {
// RuntimeConfig is the legacy runtime file format (kept for migration only).
type RuntimeConfig struct {
Sources []Source `json:"sources,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
Sources []Source `json:"sources,omitempty"`
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
}
// SourceTemplate stores reusable source configuration (everything except
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
RPM int `json:"rpm,omitempty"`
}
// GWKey is a gateway API key persisted in the config file. Role is "admin"

View File

@ -138,6 +138,49 @@ func (s *Store) DeletedAdapters() map[string]bool {
return out
}
// UpsertTemplate adds or replaces a source template and persists.
func (s *Store) UpsertTemplate(t SourceTemplate) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.SourceTemplates {
if s.data.SourceTemplates[i].Name == t.Name {
s.data.SourceTemplates[i] = t
return s.persistLocked()
}
}
s.data.SourceTemplates = append(s.data.SourceTemplates, t)
return s.persistLocked()
}
// ListTemplates returns the saved source templates.
func (s *Store) ListTemplates() []SourceTemplate {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]SourceTemplate, len(s.data.SourceTemplates))
copy(out, s.data.SourceTemplates)
return out
}
// RemoveTemplate deletes a source template by name and persists.
func (s *Store) RemoveTemplate(name string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
kept := s.data.SourceTemplates[:0]
removed := false
for _, t := range s.data.SourceTemplates {
if t.Name == name {
removed = true
continue
}
kept = append(kept, t)
}
s.data.SourceTemplates = kept
if removed {
return true, s.persistLocked()
}
return false, nil
}
// LoadLegacy returns the full legacy RuntimeConfig from the file (used for
// one-time migration into config.yaml). Returns nil if file is missing.
func (s *Store) LoadLegacy() *RuntimeConfig {

View File

@ -32,7 +32,8 @@ type Core struct {
store *config.Store
scheduler *scheduler.Scheduler
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain]
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
}
// New builds the core from a config file plus runtime overlay.
@ -181,6 +182,9 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
// AutoChain returns the current AUTO scheduling chain.
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
func (c *Core) DefaultModel() string { return c.cfg.DefaultModel }
func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys }
@ -294,6 +298,15 @@ func (c *Core) AutoRules() []config.ModelScope {
return out
}
// AutoImageRules returns the configured image-generation AUTO chain rules.
func (c *Core) AutoImageRules() []config.ModelScope {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]config.ModelScope, len(c.cfg.AutoImage))
copy(out, c.cfg.AutoImage)
return out
}
// cleanScopes drops empty model entries and normalizes placeholder source
// names; it returns nil when no entries survive so an empty scope means
// "unrestricted" (nil) instead of a restrictive-but-empty list — a non-nil
@ -325,16 +338,35 @@ func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
return err
}
c.buildAutoChain()
if ch := c.autoChain.Load(); ch != nil {
for _, tn := range ch.Tiers {
for _, sl := range tn.Slots {
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
p.ResetModelCooldown(sl.Model)
}
c.resetChainCooldowns(c.autoChain.Load())
return nil
}
// SaveAutoImageRules persists the image-generation AUTO chain. Image models
// are kept as-is (unlike buildAutoChain, which skips them).
func (c *Core) SaveAutoImageRules(entries []config.ModelScope) error {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.AutoImage = cleanScopes(entries)
if err := c.saveConfig(); err != nil {
return err
}
c.buildAutoImageChain()
c.resetChainCooldowns(c.autoImageChain.Load())
return nil
}
func (c *Core) resetChainCooldowns(ch *scheduler.Chain) {
if ch == nil {
return
}
for _, tn := range ch.Tiers {
for _, sl := range tn.Slots {
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
p.ResetModelCooldown(sl.Model)
}
}
}
return nil
}
// ResetHealth clears the scheduling backoff state of every provider.
@ -420,6 +452,7 @@ func (c *Core) rebuildRegistry() error {
c.registry.Replace(providers)
}
c.buildAutoChain()
c.buildAutoImageChain()
return nil
}
@ -466,6 +499,47 @@ func (c *Core) buildAutoChain() {
c.autoChain.Store(scheduler.BuildChain(sr, prov))
}
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
// Unlike buildAutoChain, only image-kind models participate: chat models in
// the rules are skipped so a stale chat slot can't receive image traffic.
func (c *Core) buildAutoImageChain() {
prov := func(model, source string) scheduler.Provider {
p := c.registry.ProviderForSlot(model, source)
if p == nil {
return nil
}
if m := p.ModelByID(model); m != nil && m.Kind != "image" {
return nil
}
return p
}
rules := c.cfg.AutoImage
sr := make([]scheduler.Rule, 0, len(rules))
for _, e := range rules {
model, source := e.Model, e.Source
if p := c.registry.ProviderForSlot(e.Model, e.Source); p != nil {
if exact := p.ModelIDFold(e.Model); exact != "" {
model = exact
}
if m := p.ModelByID(model); m != nil && m.Kind != "image" {
continue // chat-kind models never join the image AUTO chain
}
if source == "" {
source = p.Name()
}
}
sr = append(sr, scheduler.Rule{
Model: model,
Source: source,
Tier: e.Tier,
Quota: e.TokenQuota,
Period: e.Period,
Hours: e.Hours,
})
}
c.autoImageChain.Store(scheduler.BuildChain(sr, prov))
}
// AutoSlotState is the UI-facing health snapshot of one AUTO chain slot.
type AutoSlotState struct {
Model string `json:"model"`
@ -601,6 +675,33 @@ func (c *Core) Sources() []config.Source {
return c.mergedSources()
}
// Templates returns the saved source templates.
func (c *Core) Templates() []config.SourceTemplate {
c.mu.Lock()
defer c.mu.Unlock()
return c.store.ListTemplates()
}
// SaveTemplate upserts a source template.
func (c *Core) SaveTemplate(t config.SourceTemplate) error {
c.mu.Lock()
defer c.mu.Unlock()
if t.Name == "" {
return fmt.Errorf("template requires a name")
}
return c.store.UpsertTemplate(t)
}
// RemoveTemplate deletes a source template by name.
func (c *Core) RemoveTemplate(name string) error {
c.mu.Lock()
defer c.mu.Unlock()
if _, err := c.store.RemoveTemplate(name); err != nil {
return err
}
return nil
}
func normalizeSource(s *config.Source) error {
if s.Name == "" || s.BaseURL == "" {
return fmt.Errorf("source requires name and base_url")

View File

@ -134,6 +134,45 @@ func csvHeaders(w http.ResponseWriter, filename string) {
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
}
// handleSourceTemplatesAPI manages reusable source templates (a Source minus
// name and api_key) stored in the runtime file so the WebUI can spin up
// multiple key-bearing sources from one shared template.
func (g *Gateway) handleSourceTemplatesAPI(w http.ResponseWriter, r *http.Request) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/source_templates")
path = strings.Trim(path, "/")
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"templates": g.core.Templates()})
case http.MethodPost:
var t config.SourceTemplate
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.core.SaveTemplate(t); err != nil {
writeError(w, http.StatusBadRequest, "template_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "templates": g.core.Templates()})
case http.MethodDelete:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "template name required")
return
}
if err := g.core.RemoveTemplate(path); err != nil {
writeError(w, http.StatusBadRequest, "template_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// exportKey resolves which masked key id an export covers: admins may pass
// any key filter, user keys are always scoped to themselves (records and
// aggregates are keyed by the masked keyID form).

View File

@ -897,11 +897,45 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
if model == "" {
model = g.core.DefaultModel()
}
if !isAuto(model) {
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
if isAuto(model) {
if chain := g.core.AutoImageChain(); chain != nil && len(chain.Tiers) > 0 {
if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" {
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
return
}
done := g.stats.Begin()
defer done()
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, OK: false}
t0 := time.Now()
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainImage(r.Context(), chain, &req)
rec.LatMs = time.Since(t0).Milliseconds()
if err != nil {
rec.Status = upstreamErrStatus(err)
rec.Err = err.Error()
g.writeRec(rec)
writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err))
return
}
rec.Source = usedSrc
if usedModel != "" {
rec.Model = usedModel // actual image model served, not "AUTO"
}
rec.OK = true
rec.Status = http.StatusOK
rec.Compl = int64(len(resp.ImageData))
g.writeRec(rec)
writeJSON(w, http.StatusOK, types.ImageGenResponse{
Created: time.Now().Unix(),
Data: resp.ImageData,
})
return
}
// no image chain configured: fall through to legacy discovery (all
// sources exposing an image model, tried in registry order)
}
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
return
}
cands, _ := g.resolveByModel(model)
cands = imageOnly(cands)
@ -932,6 +966,9 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
if usedSrc != "" {
rec.Source = usedSrc
}
if resp.Model != "" {
rec.Model = resp.Model // record the actual model served, not the raw request id
}
rec.OK = true
rec.Status = http.StatusOK
rec.Compl = int64(len(resp.ImageData))

View File

@ -143,8 +143,9 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, map[string]interface{}{
"rules": g.core.AutoRules(),
"states": g.core.AutoSlotStates(),
"rules": g.core.AutoRules(),
"image_rules": g.core.AutoImageRules(),
"states": g.core.AutoSlotStates(),
})
return
}
@ -155,17 +156,30 @@ func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut, http.MethodPost:
var body struct {
Rules []config.ModelScope `json:"rules"`
Rules []config.ModelScope `json:"rules"`
ImageRules []config.ModelScope `json:"image_rules"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.core.SaveAutoRules(body.Rules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
if body.Rules != nil {
if err := g.core.SaveAutoRules(body.Rules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "rules": g.core.AutoRules()})
if body.ImageRules != nil {
if err := g.core.SaveAutoImageRules(body.ImageRules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"rules": g.core.AutoRules(),
"image_rules": g.core.AutoImageRules(),
})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}

View File

@ -218,6 +218,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleAdaptersAPI(w, r)
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
g.handleSourcesAPI(w, r)
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
g.handleSourceTemplatesAPI(w, r)
case r.URL.Path == "/api/chat":
g.handleChat(w, r)
case r.URL.Path == "/api/status":

View File

@ -192,6 +192,8 @@
.row{display:flex;gap:12px}.row>div{flex:1}
.model-row{display:flex;gap:6px;align-items:center;width:100%}
.model-row .m-id{flex:1;min-width:0;width:0}
.tpl-pick-row{padding:12px 14px;border-radius:13px;cursor:pointer;background:var(--card2);border:1px solid var(--line);margin-bottom:8px;transition:background .15s,border-color .15s}
.tpl-pick-row:hover{background:var(--accent-soft,#1e2a52);border-color:var(--primary)}
.model-row .m-kind{flex:0 0 96px;width:96px}
.model-row .del{flex:0 0 auto;padding:4px 8px}
.muted{color:var(--muted)}
@ -365,6 +367,8 @@
#modal-wrap .card{width:100%;max-width:480px;animation:cardIn .28s var(--ease-spring) both}
/* ---- scratch-style priority canvas (sakura restyle, same classes) ---- */
.kind-toggle{display:inline-flex;gap:4px;margin-right:8px}
.kind-toggle button{padding:4px 12px;font-size:11.5px}
.sort-canvas{position:relative;min-height:420px;padding:22px 14px 30px;margin:4px 0 10px;background-color:var(--card2);
background-image:linear-gradient(var(--line-strong) 1px,transparent 1px),linear-gradient(90deg,var(--line-strong) 1px,transparent 1px);
background-size:28px 28px;border:1px solid var(--line);border-radius:16px;overflow-x:auto;backdrop-filter:blur(10px)}
@ -792,6 +796,14 @@
cStream: "调用 /v1/chat/completions 流式 SSE",
srcEmpty: "还没有配置任何源",
srcAdd: "+ 新增源",
tplManage: "模板管理",
tplFrom: "从模板创建",
tplSave: "存为模板",
tplTitle: "模板管理",
tplEmpty: "还没有模板",
tplNew: "新建模板",
tplNamePrompt: "输入模板名称",
tplConfirmDel: "确认删除模板?",
srcEdit: "编辑",
srcDel: "删除",
adEmpty: "尚未加载适配器",
@ -992,6 +1004,14 @@
cStream: "calls /v1/chat/completions streaming SSE",
srcEmpty: "No sources configured yet",
srcAdd: "+ Add source",
tplManage: "Templates",
tplFrom: "From template",
tplSave: "As template",
tplTitle: "Template Manager",
tplEmpty: "No templates yet",
tplNew: "New template",
tplNamePrompt: "Template name",
tplConfirmDel: "Delete this template?",
srcEdit: "Edit",
srcDel: "Delete",
adEmpty: "No adapters loaded",
@ -2268,6 +2288,7 @@
firstChunk = Date.now();
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
@ -2404,6 +2425,7 @@
.join("");
$("#tab-sources").innerHTML = `
<div class="card"><h2><span>${t("srcTitle")}</span><span class="grow"></span>
<button class="ghost small" onclick="openTemplateModal()">${t("tplManage")}</button>
<button class="small" onclick="editSource('')">${t("srcAdd")}</button></h2>
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>
${rows || `<tr><td colspan="5" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
@ -2431,7 +2453,9 @@
)
.join("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</h2>
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
<div class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
@ -2516,6 +2540,8 @@
btn.disabled = true;
try {
await api("/api/sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
toast(t("toastSaved"));
@ -2528,10 +2554,273 @@
btn.disabled = false;
}
}
// collect the source form fields except name & api_key — the fields a
// template reuses. Returns the cleaned object or null on invalid JSON.
function readSourceFormExceptKeys() {
const models = [...document.querySelectorAll("#s-models .model-row")]
.map((row) => ({
id: row.querySelector(".m-id").value.trim(),
priority: parseInt(row.dataset.priority) || 0,
kind: row.querySelector(".m-kind").value,
}))
.filter((m) => m.id);
let meta = {};
try {
meta = JSON.parse($("#s-meta").value || "{}");
} catch (e) {
toast(t("toastBadJson"));
return null;
}
return {
base_url: $("#s-url").value.trim(),
adapter: $("#s-adapter").value.trim(),
endpoint: $("#s-ep").value.trim(),
image_endpoint: $("#s-img").value.trim(),
max_concurrent: parseInt($("#s-conc").value) || 8,
rpm: parseInt($("#s-rpm").value) || 0,
temperature: parseFloat($("#s-temp").value) || 0,
models,
meta,
};
}
// "存为模板": save every non-key/non-name form field as a template.
async function srcSaveAsTemplate() {
const body = readSourceFormExceptKeys();
if (!body) return;
const dflt = ($("#s-name").value || "").trim() || "tpl";
const wrap = document.createElement("div");
wrap.id = "tpl-save-modal";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplSave")}</h2>
<label>${t("tplNamePrompt")}</label>
<input id="tpl-save-name" value="${escAttr(dflt)}">
<p><button onclick="confirmSaveTemplate()">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#tpl-save-modal').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#tpl-save-name").focus();
window._templateSaveBody = body;
}
async function confirmSaveTemplate() {
const nm = ($("#tpl-save-name").value || "").trim();
if (!nm) { toast(t("tplNamePrompt")); return; }
const body = window._templateSaveBody;
if (!body) return;
try {
const r = await api("/api/source_templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nm, ...body }),
});
toast(t("toastSaved"));
$("#tpl-save-modal").remove();
} catch (e) {
toast(e.message);
}
}
// "从模板创建": pick a template and fill the form (except name/key).
async function srcFromTemplate() {
let tpls = [];
try {
tpls = (await api("/api/source_templates")).templates || [];
} catch (e) {
toast(e.message);
return;
}
if (!tpls.length) {
toast(t("tplEmpty"));
return;
}
const wrap = document.createElement("div");
wrap.id = "template-pick";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
const rows = tpls
.map(
(tpl) =>
`<div class="tpl-pick-row" data-tpl="${escAttr(tpl.name)}">
<div><b>${esc(tpl.name)}</b><span class="muted"> · ${esc(tpl.base_url || "")}</span></div>
<div class="muted">${esc((tpl.adapter || "") + " · " + (tpl.models || []).map((m) => m.id).join(", "))}</div>
</div>`,
)
.join("");
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplFrom")}</h2>
${rows}
<p><button class="ghost" onclick="this.closest('#template-pick').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
// delegate click to avoid inline-handler quoting issues
wrap.addEventListener("click", (e) => {
const row = e.target.closest(".tpl-pick-row");
if (!row) return;
pickTemplate(row.dataset.tpl);
});
}
// applies a template's fields onto the open source form (name + key stay).
function pickTemplate(name) {
api("/api/source_templates")
.then((r) => {
const tpl = (r.templates || []).find((t) => t.name === name);
if (!tpl) return;
$("#s-url").value = tpl.base_url || "";
$("#s-adapter").value = tpl.adapter || "";
$("#s-ep").value = tpl.endpoint || "";
$("#s-img").value = tpl.image_endpoint || "";
$("#s-conc").value = tpl.max_concurrent || 8;
$("#s-rpm").value = tpl.rpm || 0;
$("#s-temp").value = tpl.temperature ?? 0.7;
$("#s-meta").value = JSON.stringify(tpl.meta || {}, null, 2);
const box = $("#s-models");
box.innerHTML = "";
(tpl.models && tpl.models.length
? tpl.models
: [{ id: "", priority: 0, kind: "chat" }]
).forEach((m, i) =>
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
);
const w = $("#template-pick");
if (w) w.remove();
toast(t("toastSaved"));
})
.catch((e) => toast(e.message));
}
// template manager: list all templates with edit + delete.
async function openTemplateModal() {
let tpls = [];
try {
tpls = (await api("/api/source_templates")).templates || [];
} catch (e) {
toast(e.message);
return;
}
const wrap = document.createElement("div");
wrap.id = "tpl-manage";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
wrap.innerHTML = `<div class="card" style="width:560px;max-width:96%"><h2>${t("tplTitle")}<span class="grow"></span>
<button class="small" onclick="editTemplate('')">${t("tplNew")}</button></h2>
${tpls.length
? `<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>` +
tpls
.map(
(t) =>
`<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.base_url)}</td><td>${esc(t.adapter)}</td>
<td><div class="src-models">${(t.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(t.name).replace(/["<>]/g, (c) => ({ '"': "&quot;", "<": "&lt;", ">": "&gt;" }[c]))})">${t("srcEdit")}</button>
<button class="danger small" onclick="delTemplate('${escAttr(t.name)}')">${t("srcDel")}</button></td></tr>`,
)
.join("") +
"</table></div>"
: `<div class="empty">${t("tplEmpty")}</div>`}
<p><button class="ghost" onclick="this.closest('#tpl-manage').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
}
async function delTemplate(name) {
if (!confirm(tFmt("tplConfirmDel", name))) return;
try {
await api("/api/source_templates/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
openTemplateModal();
} catch (e) {
toast(e.message);
}
}
// edit or create a template (no name / api_key fields).
async function editTemplate(name) {
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
Promise.all([api("/api/source_templates"), api("/api/status")])
.then(([tplsR, st]) => {
const tlist = tplsR.templates || [];
const s = tlist.find((x) => x.name === name) || {
name: name,
models: [{ id: "", priority: 0, kind: "chat" }],
};
const cur = s.adapter || "openai";
const apps = ["", ...(st.adapters || []).map((a) => a.name)];
if (cur && !apps.includes(cur)) apps.push(cur);
const adSel =
`<select id="s-adapter"><option value="" ${!cur ? "selected" : ""}>${esc(t("mAliasAuto"))}</option>` +
apps
.filter((n) => n)
.map(
(n) =>
`<option value="${escAttr(n)}" ${n === cur ? "selected" : ""}>${esc(n)}</option>`,
)
.join("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("tplTitle") + ": " + name : t("tplNew"))}</h2>
<div class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
</div>
<div class="row">
<div><label>${t("mConc")}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>${t("mTemp")}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
</div>
<div class="row">
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
<div></div>
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
<p><button onclick="saveTemplate(this)">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
document.body.appendChild(wrap);
const box = $("#s-models");
(s.models && s.models.length
? s.models
: [{ id: "", priority: 0, kind: "chat" }]
).forEach((m, i) =>
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
);
})
.catch(() => {});
}
async function saveTemplate(btn) {
const nm = $("#s-name").value.trim();
if (!nm) {
toast(t("tplNamePrompt"));
return;
}
const body = readSourceFormExceptKeys();
if (!body) return;
btn.disabled = true;
try {
await api("/api/source_templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nm, ...body }),
});
toast(t("toastSaved"));
const w = $("#modal-wrap");
if (w) w.remove();
openTemplateModal();
} catch (e) {
toast(e.message);
btn.disabled = false;
}
}
async function delSource(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return;
await api("/api/sources/" + encodeURIComponent(name), {
});
await api("/api/sources/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderSources();
}
@ -2560,14 +2849,19 @@
function srcShort(name) {
return (name || "?").slice(0, 2).toUpperCase();
}
const sortState = { lanes: [], origin: null, drag: null };
const sortState = { lanes: [], origin: null, drag: null, kind: "chat" };
// per-kind editor state: chat lanes and image lanes are independent;
// the toggle switches which chain the canvas edits
const sortLanes = { chat: [], image: [] };
let sortStateMap = new Map();
async function renderSort() {
const j = await api("/api/sources");
let autoR = [];
let autoImg = [];
try {
const a = await api("/api/auto");
autoR = a.rules || [];
autoImg = a.image_rules || [];
sortStateMap = new Map(
(a.states || []).map((st) => [
st.model + "|" + (st.source || "*"),
@ -2580,58 +2874,69 @@
const sourceRows = new Map();
j.sources.forEach((s) =>
(s.models || []).forEach((m) => {
if (m.kind === "image") return;
const it = { src: s.name, id: m.id, prio: m.priority || 0 };
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
byPair.set(m.id + "|" + s.name, it);
if (!byModel.has(m.id)) byModel.set(m.id, it);
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
if (autoR.length) {
const tiers = [];
autoR.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
// build a lane structure for one kind from its saved rules; when
// rules are empty, fall back to discovery from sourceRows filtered by
// kind so an unconfigured chain still shows all available slots
const buildLanes = (rules, kind) => {
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
});
sortState.lanes = tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
} else {
sortState.lanes = [...sourceRows.entries()]
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
// discovery: group slots of this kind by their source priority
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
models.sort((x, y) =>
x.src < y.src ? -1 : x.src > y.src ? 1 : 0,
);
models.forEach((m) => {
m.uid = nexUid();
m.meta = null;
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
return { prio, models };
});
}
if (!same.length) return null;
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
return { prio, models: same };
})
.filter(Boolean);
};
sortLanes.chat = buildLanes(autoR, "chat");
sortLanes.image = buildLanes(autoImg, "image");
sortState.lanes = sortLanes[sortState.kind] || [];
// model picker for the "add slot" control: one option per (source, model)
// pair so identical model ids on different sources stay distinguishable
await loadModelPairs();
sortState.origin = JSON.stringify(sortState.lanes);
const kindToggle =
`<div class="kind-toggle"><button class="${sortState.kind === "chat" ? "" : "ghost"} small" onclick="switchSortKind('chat')">${t("kChat") || "聊天"}</button><button class="${sortState.kind === "image" ? "" : "ghost"} small" onclick="switchSortKind('image')">${t("kImage") || "生图"}</button></div>`;
$("#tab-sort").innerHTML = `
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
@ -2651,6 +2956,25 @@
</div>`;
paintSort();
}
function switchSortKind(k) {
if (sortState.kind === k) return;
sortLanes[sortState.kind] = sortState.lanes;
sortState.kind = k;
sortState.lanes = sortLanes[k] || [];
sortState.origin = JSON.stringify(sortState.lanes);
sortState.drag = null;
const h = $("#tab-sort").querySelector("h2");
if (h) {
const tg = h.querySelector(".kind-toggle");
if (tg) {
tg.querySelectorAll("button").forEach((b) => {
const isK = (b.getAttribute("onclick") || "").indexOf("'" + k + "'") >= 0;
b.className = isK ? "small" : "ghost small";
});
}
}
paintSort();
}
function healthTag(it) {
const st = sortStateMap.get(it.id + "|" + (it.src || "*"));
if (!st) return "";
@ -3113,8 +3437,15 @@
});
}),
);
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
body: JSON.stringify({ rules }),
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function scrAddModal() {
@ -3123,15 +3454,25 @@
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")}</h2>
// filter the model picker to the kind currently edited (image models
// in image mode, chat models in chat mode)
const kind = sortState.kind;
const pickModels = (allModels || []).filter((m) => {
const k = typeof m === "string" ? "chat" : m.kind || "chat";
return kind === "image" ? k === "image" : k !== "image";
});
const optsHtml = pickModels.length
? pickModels
.map((m) =>
typeof m === "string"
? `<option value="${escAttr(m)}">${esc(m)}</option>`
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
)
.join("")
: `<option value="" disabled selected>${esc(t("kEmpty") || "No models")}</option>`;
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")} ${kind === "image" ? "· 生图" : "· 聊天"}</h2>
<label>${t("kModelB")} <span class="muted">${t("kSrcHint")}</span></label>
<select id="a-model">${allModels
.map((m) =>
typeof m === "string"
? `<option value="${escAttr(m)}">${esc(m)}</option>`
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
)
.join("")}</select>
<select id="a-model">${optsHtml}</select>
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t("kQuotaHintB"))}">
<label>${t("kPeriodB")}</label>
@ -3372,6 +3713,8 @@
if (!name || !code) return toast(t("toastNeedAll"));
try {
await api("/api/adapters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, code }),
});
toast(t("toastUploaded"));
@ -3401,11 +3744,11 @@
const pairs = [];
(srcs || []).forEach((s) =>
(s.models || []).forEach((m) => {
if (m.kind === "image") return;
pairs.push({
src: s.name,
id: m.id,
label: m.id + " · " + s.name,
label: m.id + " · " + s.name + (m.kind === "image" ? " (image)" : ""),
kind: m.kind,
key: m.id + "|" + s.name,
});
}),
@ -3584,6 +3927,8 @@
}
async function putScope(key, scopes) {
await api("/api/keys/" + encodeURIComponent(key), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ models: scopes }),
});
}
@ -3905,6 +4250,8 @@
let j;
try {
j = await api("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
role: $("#kc-role").value,

View File

@ -988,6 +988,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
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
}
@ -1000,6 +1001,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
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
}

View File

@ -327,6 +327,63 @@ func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *type
return chunks, src, model, err
}
// ChainImage runs an image-generation AUTO request down the chain: tiers
// ascending (tier 1 highest priority), per-tier round-robin, same-tier order
// by preference. Each slot's model is pinned to its own image id (ModelFor),
// so a fallback switches per source. Cooling-down slots are skipped. Returns
// the response, serving source and the exact model id used; on total failure
// a *ChainErr summarizing every tier.
func (s *Scheduler) ChainImage(ctx context.Context, chain *Chain, req *types.ImageGenRequest) (*types.UnifiedResponse, string, string, error) {
if chain == nil || len(chain.Tiers) == 0 {
return nil, "", "", fmt.Errorf("no image auto slot configured")
}
var ce ChainErr
for _, tn := range chain.Tiers {
var cands []*Slot
for _, sl := range tn.Slots {
if !sl.Prov.ModelAvailable(sl.Model) {
continue
}
cands = append(cands, sl)
}
if len(cands) == 0 {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling)", tn.Tier))
continue
}
sort.SliceStable(cands, func(i, j int) bool {
return cands[i].Prov.Pref(cands[i].Model) > cands[j].Prov.Pref(cands[j].Model)
})
base := tn.NextStart()
var hard []TierError
for i := 0; i < len(cands); i++ {
sl := cands[(int(base)+i)%len(cands)]
if !sl.Prov.ModelAvailable(sl.Model) {
continue
}
r := *req
r.Model = sl.Model
resp, err := sl.Prov.Image(ctx, &r)
if ctx.Err() != nil {
return nil, "", "", ctx.Err()
}
if err == nil {
return resp, sl.Source, sl.Model, nil
}
if errors.Is(err, types.ErrBusy) {
continue
}
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
}
if len(hard) > 0 {
ce.Tiers = append(ce.Tiers, hard...)
}
}
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
return nil, "", "", fmt.Errorf("no image auto slot configured")
}
return nil, "", "", &ce
}
// ---- direct scheduling ----
// Chat runs a chat request across cands, falling back on failure. Each

View File

@ -71,6 +71,7 @@ type ToolCall struct {
// ---- Unified internal representation (what adapters produce) ----
type UnifiedResponse struct {
Model string `json:"model,omitempty"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`