dsh-gateway-provider 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/README.zh.md +218 -0
- package/cordis.patch.yml +10 -0
- package/index.js +398 -0
- package/lib/adapter.js +222 -0
- package/lib/catalog.js +367 -0
- package/lib/client.js +1164 -0
- package/lib/modelsdev.js +113 -0
- package/lib/pi-bridge.js +481 -0
- package/lib/pi-provider.js +148 -0
- package/lib/protocols.js +150 -0
- package/lib/thinking.js +219 -0
- package/package.json +64 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half: a self-built "Gateway Models" settings section.
|
|
3
|
+
*
|
|
4
|
+
* Registers a `settings.section` entry that renders a complete model-
|
|
5
|
+
* management UI for the `llm-newapi` namespace: gateway cards (connection
|
|
6
|
+
* config with validation + dirty tracking), the discovered model list per
|
|
7
|
+
* gateway with search/filter, per-model override editing (name / protocol /
|
|
8
|
+
* context / output cap / reasoning levels), custom-model add & delete, and
|
|
9
|
+
* thinking-level mapping. Reads and writes through the forwarded settings +
|
|
10
|
+
* llm services (the same APIs the shipped Models page uses), so it needs no
|
|
11
|
+
* Package-private RPC.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-gateway-provider/client
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: "dsh-gateway-provider",
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
var module = { exports: {} };
|
|
20
|
+
var exports = module.exports;
|
|
21
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
22
|
+
var React = require("react");
|
|
23
|
+
|
|
24
|
+
var NS = "llm-newapi";
|
|
25
|
+
var GATEWAY_FLAVORS = ["newapi", "litellm", "higress", "openai-compatible", "custom"];
|
|
26
|
+
var CATALOG_MODES = ["auto", "v1", "management"];
|
|
27
|
+
var PROTOCOLS = ["openai", "openai-response", "anthropic", "gemini"];
|
|
28
|
+
/** Gateway template fields (suffix "URL") → card protocol tag. */
|
|
29
|
+
var PROTOCOL_URL_KEYS = ["openaiURL", "responsesURL", "anthropicURL"];
|
|
30
|
+
|
|
31
|
+
// ---- i18n (manual zh/en switch; no locale registration needed) ----
|
|
32
|
+
var ZH = (typeof navigator !== "undefined" && navigator.language && navigator.language.startsWith("zh"));
|
|
33
|
+
var T = ZH ? {
|
|
34
|
+
nav: "网关模型", title: "网关模型管理", intro: "管理 OpenAI 兼容网关的模型列表、接入协议与覆盖设置。",
|
|
35
|
+
defaultRoute: "默认网关", custom: "自定义", route: "路由",
|
|
36
|
+
models: "模型", fetchModels: "拉取模型", fetching: "拉取中…", addModel: "添加自定义模型",
|
|
37
|
+
searchPlaceholder: "搜索模型…", filterAll: "全部", filterHidden: "已隐藏", filterCustom: "自定义",
|
|
38
|
+
noMatch: "没有匹配的模型", noModels: "暂无模型 — 点击「拉取模型」从网关获取,或添加自定义模型。",
|
|
39
|
+
modelId: "模型 ID", modelIdRequired: "必填", modelIdExists: "该模型 ID 已存在",
|
|
40
|
+
numInvalid: "必须是大于 0 的整数",
|
|
41
|
+
disabledTag: "已隐藏", enable: "显示", disable: "隐藏", expand: "配置", collapse: "收起",
|
|
42
|
+
protocol: "协议", name: "显示名称", contextWindow: "上下文窗口", maxTokens: "输出上限",
|
|
43
|
+
reasoningLevels: "思考级别",
|
|
44
|
+
protocolAuto: function (p) { return p ? "自动(" + p + ")" : "自动"; },
|
|
45
|
+
discoveredAs: function (v) { return "发现值:" + v; },
|
|
46
|
+
nameHint: "留空则跟随网关返回的显示名",
|
|
47
|
+
reasoningHint: "逗号分隔,如 off, low, medium, high;留空使用默认",
|
|
48
|
+
overrideBadge: "覆盖",
|
|
49
|
+
delete: "删除", save: "保存", cancel: "取消", reset: "重置", close: "收起",
|
|
50
|
+
confirmDelete: "确认删除该网关?其模型覆盖配置将一并移除。",
|
|
51
|
+
test: "测试", testing: "测试中…",
|
|
52
|
+
testOkN: function (n) { return "✓ 已连接 — " + n + " 个模型"; },
|
|
53
|
+
syncedN: function (n) { return "✓ 已同步 " + n + " 个模型"; },
|
|
54
|
+
allFiltered: "✗ 模型全部被排除规则过滤",
|
|
55
|
+
testFail: "✗ 连接失败", testEmpty: "✗ 无模型",
|
|
56
|
+
configSaved: "✓ 已保存", configSaveFail: "✗ 保存失败",
|
|
57
|
+
keySet: function (env) { return "✓ Key 已设置 · " + env; },
|
|
58
|
+
keyNotSet: function (env) { return "⚠ 未设置 Key · " + env; },
|
|
59
|
+
gatewayConfig: "网关配置", apiKeyLabel: "API Key",
|
|
60
|
+
baseURL: "基础地址", apiKeyEnv: "API Key 变量名", gatewayId: "网关 ID", label: "名称",
|
|
61
|
+
flavor: "网关类型", catalogMode: "模型列表来源",
|
|
62
|
+
inherit: "继承默认",
|
|
63
|
+
flavorHint: "预设模板;各模型协议仍自动选择。选「完全自定义」后仅启用填了地址的协议",
|
|
64
|
+
tplHintNewapi: "多协议聚合网关:OpenAI / Responses / Anthropic / Gemini,自动发现模型,鉴权失败回退管理 API",
|
|
65
|
+
tplHintLitellm: "LiteLLM Proxy:/v1/chat/completions 与 /v1/responses,经 /v1/models 自动发现",
|
|
66
|
+
tplHintHigress: "Higress AI 网关:OpenAI 格式为主,新版本同端口支持 Anthropic /v1/messages",
|
|
67
|
+
tplHintOpenaiCompatible: "任意 OpenAI 兼容网关:填一个基础地址,各协议路径自动派生",
|
|
68
|
+
tplHintCustom: "三种协议地址分别填写完整 URL;留空的协议不启用,模型仅以已启用协议请求",
|
|
69
|
+
tplNewapi: "NewAPI", tplLitellm: "LiteLLM", tplHigress: "Higress",
|
|
70
|
+
tplOpenaiCompatible: "OpenAI 兼容", tplCustom: "完全自定义",
|
|
71
|
+
openaiURL: "OpenAI 兼容地址", responsesURL: "Responses 地址", anthropicURL: "Anthropic 地址",
|
|
72
|
+
openaiURLHint: "完整地址,以 /chat/completions 结尾(也可只填到 /v1)",
|
|
73
|
+
responsesURLHint: "完整地址,以 /responses 结尾(也可只填到 /v1)",
|
|
74
|
+
anthropicURLHint: "完整地址,以 /v1/messages 结尾",
|
|
75
|
+
customNeedOne: "至少填写一个协议地址",
|
|
76
|
+
customURLPlaceholder: "https://gw.example.com/v1/chat/completions",
|
|
77
|
+
responsesURLPlaceholder: "https://gw.example.com/v1/responses",
|
|
78
|
+
anthropicURLPlaceholder: "https://gw.example.com/v1/messages",
|
|
79
|
+
catalogHint: "auto:优先 /v1/models,鉴权失败时回退管理 API",
|
|
80
|
+
baseURLClearHint: "留空则回退到环境变量 NEWAPI_BASE_URL 或官方云端地址",
|
|
81
|
+
labelClearHint: "留空则回退为 gateway:<id>",
|
|
82
|
+
keyEnvHint: "留空则继承默认网关的 Key(NEWAPI_API_KEY)",
|
|
83
|
+
keyKeepHint: "留空保持不变;填写则覆盖存储的 Key",
|
|
84
|
+
urlInvalid: "必须以 http:// 或 https:// 开头", urlRequired: "必填",
|
|
85
|
+
idExists: "该 ID 已被其他网关使用",
|
|
86
|
+
idPreview: function (s) { return "将创建路由 gateway:" + s; },
|
|
87
|
+
idInvalid: "小写字母、数字、-、_(自动转换)",
|
|
88
|
+
addGateway: "添加网关",
|
|
89
|
+
addGatewayHint: "每个网关成为独立的 gateway:<id> 模型路由",
|
|
90
|
+
idPlaceholder: "my-gateway", labelPlaceholder: "我的网关", urlPlaceholder: "https://…",
|
|
91
|
+
enterApiKey: "sk-…", modelIdPlaceholder: "gpt-5.2", modelNamePlaceholder: "GPT-5.2(可选)",
|
|
92
|
+
modelsTag: function (n) { return n + " 个模型"; },
|
|
93
|
+
} : {
|
|
94
|
+
nav: "Gateway Models", title: "Gateway Model Management", intro: "Manage OpenAI-compatible gateway model lists, protocols, and overrides.",
|
|
95
|
+
defaultRoute: "Default gateway", custom: "Custom", route: "route",
|
|
96
|
+
models: "Models", fetchModels: "Fetch Models", fetching: "Fetching…", addModel: "Add Custom Model",
|
|
97
|
+
searchPlaceholder: "Search models…", filterAll: "All", filterHidden: "Hidden", filterCustom: "Custom",
|
|
98
|
+
noMatch: "No matching models", noModels: "No models yet — fetch from the gateway, or add a custom model.",
|
|
99
|
+
modelId: "Model ID", modelIdRequired: "Required", modelIdExists: "This model ID already exists",
|
|
100
|
+
numInvalid: "Must be an integer > 0",
|
|
101
|
+
disabledTag: "hidden", enable: "Show", disable: "Hide", expand: "Configure", collapse: "Collapse",
|
|
102
|
+
protocol: "Protocol", name: "Display name", contextWindow: "Context window", maxTokens: "Output cap",
|
|
103
|
+
reasoningLevels: "Reasoning levels",
|
|
104
|
+
protocolAuto: function (p) { return p ? "Auto (" + p + ")" : "Auto"; },
|
|
105
|
+
discoveredAs: function (v) { return "Discovered: " + v; },
|
|
106
|
+
nameHint: "Leave empty to follow the gateway's display name",
|
|
107
|
+
reasoningHint: "Comma-separated, e.g. off, low, medium, high; empty = default",
|
|
108
|
+
overrideBadge: "override",
|
|
109
|
+
delete: "Delete", save: "Save", cancel: "Cancel", reset: "Reset", close: "Collapse",
|
|
110
|
+
confirmDelete: "Delete this gateway? Its model overrides will be removed too.",
|
|
111
|
+
test: "Test", testing: "Testing…",
|
|
112
|
+
testOkN: function (n) { return "✓ Connected — " + n + " models"; },
|
|
113
|
+
syncedN: function (n) { return "✓ Synced " + n + " models"; },
|
|
114
|
+
allFiltered: "✗ All models excluded by patterns",
|
|
115
|
+
testFail: "✗ Connection failed", testEmpty: "✗ No models",
|
|
116
|
+
configSaved: "✓ Saved", configSaveFail: "✗ Save failed",
|
|
117
|
+
keySet: function (env) { return "✓ Key set · " + env; },
|
|
118
|
+
keyNotSet: function (env) { return "⚠ No key set · " + env; },
|
|
119
|
+
gatewayConfig: "Gateway Config", apiKeyLabel: "API Key",
|
|
120
|
+
baseURL: "Base URL", apiKeyEnv: "API Key env var", gatewayId: "Gateway ID", label: "Label",
|
|
121
|
+
flavor: "Gateway type", catalogMode: "Model-list source",
|
|
122
|
+
inherit: "Inherit default",
|
|
123
|
+
flavorHint: "Preset template; per-model protocols stay automatic. Choosing Fully custom enables only protocols with an address",
|
|
124
|
+
tplHintNewapi: "Multi-protocol aggregator: OpenAI / Responses / Anthropic / Gemini; auto model discovery with management-API fallback",
|
|
125
|
+
tplHintLitellm: "LiteLLM proxy: /v1/chat/completions and /v1/responses; discovery via /v1/models",
|
|
126
|
+
tplHintHigress: "Higress AI gateway: OpenAI format; newer versions also serve Anthropic /v1/messages",
|
|
127
|
+
tplHintOpenaiCompatible: "Any OpenAI-compatible gateway: one base address, protocol paths derived automatically",
|
|
128
|
+
tplHintCustom: "Enter each protocol's full URL; empty protocols stay disabled and models use enabled protocols only",
|
|
129
|
+
tplNewapi: "NewAPI", tplLitellm: "LiteLLM", tplHigress: "Higress",
|
|
130
|
+
tplOpenaiCompatible: "OpenAI-compatible", tplCustom: "Fully custom",
|
|
131
|
+
openaiURL: "OpenAI-compatible URL", responsesURL: "Responses URL", anthropicURL: "Anthropic URL",
|
|
132
|
+
openaiURLHint: "Full address ending in /chat/completions (a /v1 base also works)",
|
|
133
|
+
responsesURLHint: "Full address ending in /responses (a /v1 base also works)",
|
|
134
|
+
anthropicURLHint: "Full address ending in /v1/messages",
|
|
135
|
+
customNeedOne: "Fill at least one protocol URL",
|
|
136
|
+
customURLPlaceholder: "https://gw.example.com/v1/chat/completions",
|
|
137
|
+
responsesURLPlaceholder: "https://gw.example.com/v1/responses",
|
|
138
|
+
anthropicURLPlaceholder: "https://gw.example.com/v1/messages",
|
|
139
|
+
catalogHint: "auto: prefer /v1/models, fall back to the management API on auth failure",
|
|
140
|
+
baseURLClearHint: "Empty falls back to NEWAPI_BASE_URL or the public cloud default",
|
|
141
|
+
labelClearHint: "Empty falls back to gateway:<id>",
|
|
142
|
+
keyEnvHint: "Empty inherits the default gateway's key (NEWAPI_API_KEY)",
|
|
143
|
+
keyKeepHint: "Empty keeps the stored key; type to overwrite",
|
|
144
|
+
urlInvalid: "Must start with http:// or https://", urlRequired: "Required",
|
|
145
|
+
idExists: "This ID is already used by another gateway",
|
|
146
|
+
idPreview: function (s) { return "Will create route gateway:" + s; },
|
|
147
|
+
idInvalid: "lowercase letters, digits, - and _ (auto-converted)",
|
|
148
|
+
addGateway: "Add Gateway",
|
|
149
|
+
addGatewayHint: "Each gateway becomes its own gateway:<id> model route",
|
|
150
|
+
idPlaceholder: "my-gateway", labelPlaceholder: "My Gateway", urlPlaceholder: "https://…",
|
|
151
|
+
enterApiKey: "sk-…", modelIdPlaceholder: "gpt-5.2", modelNamePlaceholder: "GPT-5.2 (optional)",
|
|
152
|
+
modelsTag: function (n) { return n + " models"; },
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Template metadata per gateway flavor (labels + form shaping). */
|
|
156
|
+
var FLAVOR_INFO = {
|
|
157
|
+
"newapi": { label: T.tplNewapi, hint: T.tplHintNewapi, url: "https://api.newapi.ai" },
|
|
158
|
+
"litellm": { label: T.tplLitellm, hint: T.tplHintLitellm, url: "http://localhost:4000" },
|
|
159
|
+
"higress": { label: T.tplHigress, hint: T.tplHintHigress, url: "http://localhost:8080" },
|
|
160
|
+
"openai-compatible": { label: T.tplOpenaiCompatible, hint: T.tplHintOpenaiCompatible, url: "https://your-gateway.com" },
|
|
161
|
+
"custom": { label: T.tplCustom, hint: T.tplHintCustom, url: "" },
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Protocol URL field key → { label, hint, placeholder, tag }. */
|
|
165
|
+
var PROTOCOL_URL_INFO = {
|
|
166
|
+
openaiURL: { label: T.openaiURL, hint: T.openaiURLHint, ph: T.customURLPlaceholder, tag: "openai" },
|
|
167
|
+
responsesURL: { label: T.responsesURL, hint: T.responsesURLHint, ph: T.responsesURLPlaceholder, tag: "responses" },
|
|
168
|
+
anthropicURL: { label: T.anthropicURL, hint: T.anthropicURLHint, ph: T.anthropicURLPlaceholder, tag: "anthropic" },
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Protocol tags a gateway card shows (configured URL fields only). */
|
|
172
|
+
function protocolTags(gw) {
|
|
173
|
+
return PROTOCOL_URL_KEYS.filter(function (k) { return gw && typeof gw[k] === "string" && gw[k].length > 0; })
|
|
174
|
+
.map(function (k) { return PROTOCOL_URL_INFO[k].tag; });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Build a CSS style tag once. */
|
|
178
|
+
function ensureStyles() {
|
|
179
|
+
if (typeof document === "undefined") return;
|
|
180
|
+
var tagId = "dsh-gateway-provider-styles";
|
|
181
|
+
if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
|
|
182
|
+
var tag = document.createElement("style");
|
|
183
|
+
tag.dataset.pluginCss = tagId;
|
|
184
|
+
tag.textContent = [
|
|
185
|
+
".na_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}",
|
|
186
|
+
".na_title{font-size:16px;font-weight:600;margin:0}",
|
|
187
|
+
".na_intro{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:0}",
|
|
188
|
+
".na_gateway{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:10px}",
|
|
189
|
+
".na_gatewayHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
190
|
+
".na_gatewayName{font-size:14px;font-weight:600}",
|
|
191
|
+
".na_tag{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px}",
|
|
192
|
+
".na_route{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px;color:var(--dsw-alias-label-secondary)}",
|
|
193
|
+
".na_meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
194
|
+
".na_metaUrl{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
|
|
195
|
+
".na_actions{display:flex;gap:6px;margin-left:auto;align-items:center;flex-wrap:wrap}",
|
|
196
|
+
".na_btn{height:26px;padding:0 12px;font-size:12px;font-weight:500;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:999px;cursor:pointer;display:inline-flex;align-items:center;gap:4px}",
|
|
197
|
+
".na_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}",
|
|
198
|
+
".na_btn:active:not(:disabled){transform:translateY(1px)}",
|
|
199
|
+
".na_btn:disabled{opacity:.45;cursor:default}",
|
|
200
|
+
".na_btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}",
|
|
201
|
+
".na_btnPrimary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border:none}",
|
|
202
|
+
".na_btnDanger{color:var(--dsw-alias-state-error-primary);border:none}",
|
|
203
|
+
".na_btnDanger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}",
|
|
204
|
+
".na_field{display:flex;flex-direction:column;gap:4px;min-width:0}",
|
|
205
|
+
".na_fieldLabel{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary)}",
|
|
206
|
+
".na_input{box-sizing:border-box;height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:0 10px;font:inherit;font-size:13px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);width:100%}",
|
|
207
|
+
".na_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}",
|
|
208
|
+
".na_input::placeholder{color:var(--dsw-alias-label-dimmed)}",
|
|
209
|
+
"select.na_input{padding:0 8px;cursor:pointer}",
|
|
210
|
+
".na_inputInvalid,.na_inputInvalid:focus{border-color:var(--dsw-alias-state-error-primary)}",
|
|
211
|
+
".na_fieldError{font-size:12px;color:var(--dsw-alias-state-error-primary)}",
|
|
212
|
+
".na_hint{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}",
|
|
213
|
+
".na_muted{color:var(--dsw-alias-label-secondary);font-size:12px}",
|
|
214
|
+
".na_badge{white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;display:inline-flex;align-items:center}",
|
|
215
|
+
".na_testOk{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}",
|
|
216
|
+
".na_testFail{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger)}",
|
|
217
|
+
".na_keyWarn{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}",
|
|
218
|
+
".na_config{border-top:1px solid var(--dsw-alias-border-l2);padding-top:10px;display:flex;flex-direction:column;gap:10px}",
|
|
219
|
+
".na_configGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px}",
|
|
220
|
+
".na_spanAll{grid-column:1/-1}",
|
|
221
|
+
".na_formRow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
|
|
222
|
+
".na_addForm{border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:10px}",
|
|
223
|
+
".na_addFormGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}",
|
|
224
|
+
".na_tplRow{display:flex;gap:6px;flex-wrap:wrap}",
|
|
225
|
+
".na_tpl{height:26px;padding:0 12px;font-size:12px;font-weight:500;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);border-radius:999px;cursor:pointer}",
|
|
226
|
+
".na_tpl:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
227
|
+
".na_tpl:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}",
|
|
228
|
+
".na_tplOn{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}",
|
|
229
|
+
".na_models{border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px;display:flex;flex-direction:column;gap:6px}",
|
|
230
|
+
".na_toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
|
|
231
|
+
".na_search{flex:1;min-width:140px;max-width:240px}",
|
|
232
|
+
".na_toolbar select.na_input{width:auto;flex:none}",
|
|
233
|
+
".na_count{font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
|
|
234
|
+
".na_modelRow{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr) auto auto auto;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}",
|
|
235
|
+
".na_modelRow:last-child{border-bottom:none}",
|
|
236
|
+
".na_modelId{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px;overflow-wrap:anywhere;display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}",
|
|
237
|
+
".na_modelNameCol{display:flex;flex-direction:column;gap:2px;min-width:0}",
|
|
238
|
+
".na_modelName{font-size:12px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
239
|
+
".na_modelMeta{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
240
|
+
".na_modelRowHidden .na_modelId,.na_modelRowHidden .na_modelNameCol,.na_modelRowHidden .na_modelMeta{opacity:.5}",
|
|
241
|
+
".na_editor{grid-column:1/-1;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px;background:var(--dsw-alias-bg-layer-1)}",
|
|
242
|
+
".na_editorGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}",
|
|
243
|
+
".na_empty{border:1px dashed var(--dsw-alias-border-l3);border-radius:10px;padding:16px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px;display:flex;flex-direction:column;gap:8px;align-items:center}",
|
|
244
|
+
].join("\n");
|
|
245
|
+
document.head.appendChild(tag);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function h(type, props) {
|
|
249
|
+
var args = [type, props || null];
|
|
250
|
+
for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
|
|
251
|
+
return React.createElement.apply(React, args);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 128000 → "128K", 2000000 → "2M"; "" for unknown. */
|
|
255
|
+
function fmtTokens(n) {
|
|
256
|
+
var v = Number(n);
|
|
257
|
+
if (!isFinite(v) || v <= 0) return "";
|
|
258
|
+
if (v >= 1000000) return (Math.round(v / 100000) / 10) + "M";
|
|
259
|
+
if (v >= 1000) return (Math.round(v / 100) / 10) + "K";
|
|
260
|
+
return String(Math.round(v));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A trimmed http(s) URL, or false. */
|
|
264
|
+
function validURL(s) {
|
|
265
|
+
return /^https?:\/\/\S+$/i.test(String(s || "").trim());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Sanitize a gateway id into a stable provider-route suffix. */
|
|
269
|
+
function sanitizeId(s) {
|
|
270
|
+
return String(s == null ? "" : s).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Replace or insert one model entry inside a stored array. */
|
|
274
|
+
function upsertModel(arr, entry) {
|
|
275
|
+
var next = arr.map(function (m) { return m.id === entry.id ? entry : m; });
|
|
276
|
+
if (!next.some(function (m) { return m.id === entry.id; })) next.push(entry);
|
|
277
|
+
return next;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Merge a freshly discovered list into the stored catalog: the gateway
|
|
282
|
+
* is authoritative for id/name/context/maxTokens/protocol, while user
|
|
283
|
+
* edits (disabled / overrides / custom names) are preserved. Entries
|
|
284
|
+
* absent from the discovery result are marked custom. A stored name
|
|
285
|
+
* wins only when it differs from the name discovery last reported
|
|
286
|
+
* (_discoveredName); legacy entries without that record always follow
|
|
287
|
+
* the fresh discovery name (the old UI never wrote name overrides).
|
|
288
|
+
*/
|
|
289
|
+
function mergeDiscovered(current, discovered) {
|
|
290
|
+
current = current || [];
|
|
291
|
+
var prevById = {};
|
|
292
|
+
var ids = {};
|
|
293
|
+
current.forEach(function (m) { prevById[m.id] = m; });
|
|
294
|
+
discovered.forEach(function (m) { ids[m.id] = true; });
|
|
295
|
+
var updated = discovered.map(function (d) {
|
|
296
|
+
var prev = prevById[d.id];
|
|
297
|
+
var entry = {
|
|
298
|
+
id: d.id,
|
|
299
|
+
_discoveredName: d.name || d.id,
|
|
300
|
+
_protocol: d.protocol,
|
|
301
|
+
_discoveredContext: d.contextWindow,
|
|
302
|
+
_discoveredMax: d.maxTokens,
|
|
303
|
+
_reasoning: d.reasoning === true,
|
|
304
|
+
};
|
|
305
|
+
if (prev !== undefined) {
|
|
306
|
+
if (prev._discoveredName !== undefined && typeof prev.name === "string" && prev.name.length > 0 && prev.name !== prev._discoveredName) {
|
|
307
|
+
entry.name = prev.name;
|
|
308
|
+
}
|
|
309
|
+
["protocol", "contextWindow", "maxTokens", "reasoningLevels", "disabled"].forEach(function (k) {
|
|
310
|
+
if (prev[k] !== undefined) entry[k] = prev[k];
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return entry;
|
|
314
|
+
});
|
|
315
|
+
var customs = current.filter(function (m) { return ids[m.id] === undefined; })
|
|
316
|
+
.map(function (m) { return Object.assign({}, m, { _custom: true }); });
|
|
317
|
+
return updated.concat(customs);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Label + control + optional hint / inline error. */
|
|
321
|
+
function Field(props) {
|
|
322
|
+
return h("div", { className: "na_field" + (props.spanAll ? " na_spanAll" : "") },
|
|
323
|
+
h("label", { className: "na_fieldLabel" }, props.label),
|
|
324
|
+
props.children,
|
|
325
|
+
props.error ? h("span", { className: "na_fieldError" }, props.error) : (props.hint ? h("span", { className: "na_hint" }, props.hint) : null),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Gateway connection form: prefilled from the current values, with
|
|
331
|
+
* dirty tracking, per-field validation, and explicit clear semantics
|
|
332
|
+
* (empty label / apiKeyEnv / flavor → inherit the shared defaults).
|
|
333
|
+
* The "custom" template swaps the single base URL for per-protocol
|
|
334
|
+
* full endpoint URLs (empty = that protocol stays disabled).
|
|
335
|
+
*/
|
|
336
|
+
function ConfigForm(props) {
|
|
337
|
+
var isDefault = props.isDefault;
|
|
338
|
+
var gw = props.gateway;
|
|
339
|
+
var protoOf = function (src, key) { return (src && src[key]) || ""; };
|
|
340
|
+
var initial = {
|
|
341
|
+
baseURL: (isDefault ? props.snapshot.baseURL : gw.baseURL) || "",
|
|
342
|
+
label: (isDefault ? props.snapshot.label : gw.label) || "",
|
|
343
|
+
apiKeyEnv: (isDefault ? "" : gw.apiKeyEnv) || "",
|
|
344
|
+
flavor: isDefault ? (props.snapshot.flavor || "newapi") : (gw.flavor || ""),
|
|
345
|
+
catalogMode: isDefault ? "" : (gw.catalogMode || ""),
|
|
346
|
+
openaiURL: protoOf(isDefault ? props.snapshot : gw, "openaiURL"),
|
|
347
|
+
responsesURL: protoOf(isDefault ? props.snapshot : gw, "responsesURL"),
|
|
348
|
+
anthropicURL: protoOf(isDefault ? props.snapshot : gw, "anthropicURL"),
|
|
349
|
+
apiKey: "",
|
|
350
|
+
};
|
|
351
|
+
var _v = React.useState(initial); var values = _v[0]; var setValues = _v[1];
|
|
352
|
+
var _b = React.useState(initial); var base = _b[0]; var setBase = _b[1];
|
|
353
|
+
var _st = React.useState(null); var status = _st[0]; var setStatus = _st[1];
|
|
354
|
+
var _sv = React.useState(false); var saving = _sv[0]; var setSaving = _sv[1];
|
|
355
|
+
|
|
356
|
+
var isCustom = values.flavor === "custom";
|
|
357
|
+
var editKeys = ["baseURL", "label", "apiKeyEnv", "flavor", "catalogMode"].concat(PROTOCOL_URL_KEYS);
|
|
358
|
+
var errors = {};
|
|
359
|
+
var trimmedURL = values.baseURL.trim();
|
|
360
|
+
if (trimmedURL.length > 0 && !validURL(trimmedURL)) errors.baseURL = T.urlInvalid;
|
|
361
|
+
if (!isDefault && !isCustom && trimmedURL.length === 0) errors.baseURL = T.urlRequired;
|
|
362
|
+
var customFilled = false;
|
|
363
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
364
|
+
var v = values[k].trim();
|
|
365
|
+
if (v.length > 0) customFilled = true;
|
|
366
|
+
else return;
|
|
367
|
+
if (!validURL(v)) errors[k] = T.urlInvalid;
|
|
368
|
+
});
|
|
369
|
+
if (isCustom && !customFilled) errors.openaiURL = errors.openaiURL || T.customNeedOne;
|
|
370
|
+
var dirty = editKeys.some(function (k) {
|
|
371
|
+
return (values[k] || "").trim() !== (base[k] || "").trim();
|
|
372
|
+
}) || values.apiKey.length > 0;
|
|
373
|
+
var canSave = dirty && Object.keys(errors).length === 0 && !saving;
|
|
374
|
+
|
|
375
|
+
var set = function (key, val) {
|
|
376
|
+
setValues(function (prev) { return Object.assign({}, prev, { [key]: val }); });
|
|
377
|
+
setStatus(null);
|
|
378
|
+
};
|
|
379
|
+
var submit = function () {
|
|
380
|
+
if (!canSave) return;
|
|
381
|
+
setSaving(true); setStatus(null);
|
|
382
|
+
// Send only what actually changed: absent = untouched, "" = clear
|
|
383
|
+
// (inherit). An API-key-only save carries just `apiKey`.
|
|
384
|
+
var changes = {};
|
|
385
|
+
editKeys.forEach(function (k) {
|
|
386
|
+
var cur = (values[k] || "").trim();
|
|
387
|
+
if (k === "baseURL" || PROTOCOL_URL_KEYS.indexOf(k) !== -1) cur = cur.replace(/\/+$/, "");
|
|
388
|
+
if (cur !== (base[k] || "").trim()) changes[k] = cur;
|
|
389
|
+
});
|
|
390
|
+
if (values.apiKey.length > 0) changes.apiKey = values.apiKey;
|
|
391
|
+
props.onSave(changes).then(function (result) {
|
|
392
|
+
setSaving(false);
|
|
393
|
+
setStatus(result);
|
|
394
|
+
if (result.ok) {
|
|
395
|
+
var nextBase = Object.assign({}, values, { apiKey: "" });
|
|
396
|
+
setValues(nextBase);
|
|
397
|
+
setBase(nextBase);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return h("form", { className: "na_config", onSubmit: function (e) { e.preventDefault(); submit(); } },
|
|
403
|
+
h("div", { className: "na_configGrid" },
|
|
404
|
+
!isCustom ? h(Field, { label: T.baseURL, spanAll: true, error: errors.baseURL, hint: isDefault ? T.baseURLClearHint : null },
|
|
405
|
+
h("input", { className: "na_input" + (errors.baseURL ? " na_inputInvalid" : ""), type: "text", value: values.baseURL, spellCheck: false, placeholder: "https://your-gateway.com", onChange: function (e) { set("baseURL", e.target.value); } }),
|
|
406
|
+
) : null,
|
|
407
|
+
isCustom ? PROTOCOL_URL_KEYS.map(function (k) {
|
|
408
|
+
var info = PROTOCOL_URL_INFO[k];
|
|
409
|
+
return h(Field, { key: k, label: info.label, spanAll: true, error: errors[k], hint: info.hint },
|
|
410
|
+
h("input", { className: "na_input" + (errors[k] ? " na_inputInvalid" : ""), type: "text", value: values[k], spellCheck: false, placeholder: info.ph, onChange: function (e) { set(k, e.target.value); } }),
|
|
411
|
+
);
|
|
412
|
+
}) : null,
|
|
413
|
+
!isDefault ? h(Field, { label: T.label, hint: T.labelClearHint },
|
|
414
|
+
h("input", { className: "na_input", type: "text", value: values.label, placeholder: gw.id, onChange: function (e) { set("label", e.target.value); } }),
|
|
415
|
+
) : h(Field, { label: T.label, hint: T.nameHint },
|
|
416
|
+
h("input", { className: "na_input", type: "text", value: values.label, placeholder: "NewAPI", onChange: function (e) { set("label", e.target.value); } }),
|
|
417
|
+
),
|
|
418
|
+
h(Field, { label: T.flavor, hint: (values.flavor && FLAVOR_INFO[values.flavor]) ? FLAVOR_INFO[values.flavor].hint : T.flavorHint },
|
|
419
|
+
h("select", { className: "na_input", value: values.flavor, onChange: function (e) { set("flavor", e.target.value); } },
|
|
420
|
+
!isDefault ? h("option", { value: "" }, T.inherit) : null,
|
|
421
|
+
GATEWAY_FLAVORS.map(function (f) { return h("option", { key: f, value: f }, FLAVOR_INFO[f].label); }),
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
!isDefault ? h(Field, { label: T.catalogMode, hint: T.catalogHint },
|
|
425
|
+
h("select", { className: "na_input", value: values.catalogMode, onChange: function (e) { set("catalogMode", e.target.value); } },
|
|
426
|
+
h("option", { value: "" }, T.inherit),
|
|
427
|
+
CATALOG_MODES.map(function (m) { return h("option", { key: m, value: m }, m); }),
|
|
428
|
+
),
|
|
429
|
+
) : null,
|
|
430
|
+
!isDefault ? h(Field, { label: T.apiKeyEnv, hint: T.keyEnvHint },
|
|
431
|
+
h("input", { className: "na_input", type: "text", value: values.apiKeyEnv, spellCheck: false, placeholder: "MY_GATEWAY_KEY", onChange: function (e) { set("apiKeyEnv", e.target.value); } }),
|
|
432
|
+
) : null,
|
|
433
|
+
h(Field, { label: T.apiKeyLabel, spanAll: true, hint: T.keyKeepHint },
|
|
434
|
+
h("input", { className: "na_input", type: "password", value: values.apiKey, placeholder: props.keyConfigured ? "••••••••" : T.enterApiKey, onChange: function (e) { set("apiKey", e.target.value); } }),
|
|
435
|
+
),
|
|
436
|
+
),
|
|
437
|
+
h("div", { className: "na_formRow" },
|
|
438
|
+
h("button", { className: "na_btn na_btnPrimary", type: "submit", disabled: !canSave }, saving ? "…" : T.save),
|
|
439
|
+
h("button", { className: "na_btn", type: "button", disabled: !dirty || saving, onClick: function () { setValues(Object.assign({}, base)); setStatus(null); } }, T.reset),
|
|
440
|
+
status ? h("span", { className: "na_badge " + (status.ok ? "na_testOk" : "na_testFail") }, status.label) : null,
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** One gateway card: connection header + config form + model list. */
|
|
446
|
+
function GatewayCard(props) {
|
|
447
|
+
var gw = props.gateway;
|
|
448
|
+
var models = props.models || [];
|
|
449
|
+
var _c = React.useState(false); var cfgOpen = _c[0]; var setCfgOpen = _c[1];
|
|
450
|
+
var hiddenCount = models.filter(function (m) { return m.disabled === true; }).length;
|
|
451
|
+
var protoTags = protocolTags(gw);
|
|
452
|
+
var flavorInfo = FLAVOR_INFO[gw.flavor || "openai-compatible"] || { label: gw.flavor };
|
|
453
|
+
return h("div", { className: "na_gateway" },
|
|
454
|
+
h("div", { className: "na_gatewayHead" },
|
|
455
|
+
h("span", { className: "na_gatewayName" }, gw.label || gw.id),
|
|
456
|
+
h("span", { className: "na_tag" }, flavorInfo.label),
|
|
457
|
+
props.isDefault ? h("span", { className: "na_tag" }, T.defaultRoute) : null,
|
|
458
|
+
models.length > 0 ? h("span", { className: "na_tag" }, T.modelsTag(models.length) + (hiddenCount > 0 ? " · " + hiddenCount + " " + T.filterHidden.toLowerCase() : "")) : null,
|
|
459
|
+
h("div", { className: "na_actions" },
|
|
460
|
+
h("button", { className: "na_btn", onClick: props.onTest, disabled: props.testing }, props.testing ? T.testing : T.test),
|
|
461
|
+
h("button", { className: "na_btn", onClick: props.onFetch, disabled: props.fetching }, props.fetching ? T.fetching : T.fetchModels),
|
|
462
|
+
h("button", { className: "na_btn", onClick: function () { setCfgOpen(!cfgOpen); } }, cfgOpen ? T.close : T.gatewayConfig),
|
|
463
|
+
props.isDefault ? null : h("button", { className: "na_btn na_btnDanger", onClick: props.onDelete }, T.delete),
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
h("div", { className: "na_meta" },
|
|
467
|
+
h("span", { className: "na_route" }, T.route + ": " + props.route),
|
|
468
|
+
protoTags.map(function (t) { return h("span", { key: t, className: "na_tag" }, t); }),
|
|
469
|
+
gw.baseURL ? h("span", { className: "na_metaUrl" }, gw.baseURL) : null,
|
|
470
|
+
props.credential ? h("span", { className: "na_badge " + (props.credential.configured ? "na_testOk" : "na_keyWarn") },
|
|
471
|
+
props.credential.configured ? T.keySet(props.apiKeyEnv) : T.keyNotSet(props.apiKeyEnv)) : null,
|
|
472
|
+
props.testStatus ? h("span", { className: "na_badge " + (props.testStatus.ok ? "na_testOk" : "na_testFail") }, props.testStatus.label) : null,
|
|
473
|
+
),
|
|
474
|
+
cfgOpen ? h(ConfigForm, {
|
|
475
|
+
gateway: gw, isDefault: props.isDefault, snapshot: props.snapshot,
|
|
476
|
+
keyConfigured: !!(props.credential && props.credential.configured),
|
|
477
|
+
onSave: props.onSaveConfig,
|
|
478
|
+
}) : null,
|
|
479
|
+
h(ModelList, {
|
|
480
|
+
models: models,
|
|
481
|
+
onChange: props.onModelChange,
|
|
482
|
+
onDelete: props.onModelDelete,
|
|
483
|
+
onAdd: props.onAddModel,
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Inline form adding one custom model to a gateway (no open state:
|
|
489
|
+
* the parent toggles rendering it below the toolbar). */
|
|
490
|
+
function AddModelForm(props) {
|
|
491
|
+
var _id = React.useState(""); var mid = _id[0]; var setMid = _id[1];
|
|
492
|
+
var _nm = React.useState(""); var mname = _nm[0]; var setMname = _nm[1];
|
|
493
|
+
var _pr = React.useState(""); var mproto = _pr[0]; var setMproto = _pr[1];
|
|
494
|
+
var _cw = React.useState(""); var mcw = _cw[0]; var setMcw = _cw[1];
|
|
495
|
+
var _mt = React.useState(""); var mmt = _mt[0]; var setMmt = _mt[1];
|
|
496
|
+
|
|
497
|
+
var trimmedId = mid.trim();
|
|
498
|
+
var duplicate = trimmedId.length > 0 && props.models.some(function (m) { return m.id === trimmedId; });
|
|
499
|
+
var badNum = function (s) { return s.length > 0 && (!/^\d+$/.test(s) || Number(s) <= 0); };
|
|
500
|
+
var canSubmit = trimmedId.length > 0 && !duplicate && !badNum(mcw) && !badNum(mmt);
|
|
501
|
+
|
|
502
|
+
var submit = function () {
|
|
503
|
+
if (!canSubmit) return;
|
|
504
|
+
var entry = { id: trimmedId, _custom: true };
|
|
505
|
+
if (mname.trim()) entry.name = mname.trim();
|
|
506
|
+
if (mproto) entry.protocol = mproto;
|
|
507
|
+
if (mcw) entry.contextWindow = Number(mcw);
|
|
508
|
+
if (mmt) entry.maxTokens = Number(mmt);
|
|
509
|
+
props.onAdd(entry);
|
|
510
|
+
};
|
|
511
|
+
return h("form", { className: "na_addForm", onSubmit: function (e) { e.preventDefault(); submit(); } },
|
|
512
|
+
h("div", { className: "na_addFormGrid" },
|
|
513
|
+
h(Field, { label: T.modelId, error: duplicate ? T.modelIdExists : null },
|
|
514
|
+
h("input", { className: "na_input" + (duplicate ? " na_inputInvalid" : ""), type: "text", value: mid, spellCheck: false, placeholder: T.modelIdPlaceholder, autoFocus: true, onChange: function (e) { setMid(e.target.value); } }),
|
|
515
|
+
),
|
|
516
|
+
h(Field, { label: T.name },
|
|
517
|
+
h("input", { className: "na_input", type: "text", value: mname, placeholder: T.modelNamePlaceholder, onChange: function (e) { setMname(e.target.value); } }),
|
|
518
|
+
),
|
|
519
|
+
h(Field, { label: T.protocol },
|
|
520
|
+
h("select", { className: "na_input", value: mproto, onChange: function (e) { setMproto(e.target.value); } },
|
|
521
|
+
h("option", { value: "" }, T.protocolAuto("")),
|
|
522
|
+
PROTOCOLS.map(function (p) { return h("option", { key: p, value: p }, p); }),
|
|
523
|
+
),
|
|
524
|
+
),
|
|
525
|
+
h(Field, { label: T.contextWindow, error: badNum(mcw) ? T.numInvalid : null },
|
|
526
|
+
h("input", { className: "na_input" + (badNum(mcw) ? " na_inputInvalid" : ""), type: "number", min: 1, value: mcw, placeholder: "128000", onChange: function (e) { setMcw(e.target.value); } }),
|
|
527
|
+
),
|
|
528
|
+
h(Field, { label: T.maxTokens, error: badNum(mmt) ? T.numInvalid : null },
|
|
529
|
+
h("input", { className: "na_input" + (badNum(mmt) ? " na_inputInvalid" : ""), type: "number", min: 1, value: mmt, placeholder: "32768", onChange: function (e) { setMmt(e.target.value); } }),
|
|
530
|
+
),
|
|
531
|
+
),
|
|
532
|
+
h("div", { className: "na_formRow" },
|
|
533
|
+
h("button", { className: "na_btn na_btnPrimary", type: "submit", disabled: !canSubmit }, T.save),
|
|
534
|
+
h("button", { className: "na_btn", type: "button", onClick: props.onClose }, T.cancel),
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Expanded per-model override editor with local state + save/cancel. */
|
|
540
|
+
function ModelEditor(props) {
|
|
541
|
+
var m = props.model;
|
|
542
|
+
var _v = React.useState({
|
|
543
|
+
name: m.name || "",
|
|
544
|
+
protocol: m.protocol || "",
|
|
545
|
+
contextWindow: m.contextWindow != null ? String(m.contextWindow) : "",
|
|
546
|
+
maxTokens: m.maxTokens != null ? String(m.maxTokens) : "",
|
|
547
|
+
reasoningLevels: (m.reasoningLevels || []).join(", "),
|
|
548
|
+
}); var values = _v[0]; var setValues = _v[1];
|
|
549
|
+
|
|
550
|
+
var set = function (key, val) { setValues(function (prev) { return Object.assign({}, prev, { [key]: val }); }); };
|
|
551
|
+
var badNum = function (s) { return s.length > 0 && (!/^\d+$/.test(s) || Number(s) <= 0); };
|
|
552
|
+
var errors = {};
|
|
553
|
+
if (badNum(values.contextWindow)) errors.contextWindow = T.numInvalid;
|
|
554
|
+
if (badNum(values.maxTokens)) errors.maxTokens = T.numInvalid;
|
|
555
|
+
var canSave = Object.keys(errors).length === 0;
|
|
556
|
+
|
|
557
|
+
var submit = function () {
|
|
558
|
+
if (!canSave) return;
|
|
559
|
+
var next = Object.assign({}, m);
|
|
560
|
+
// name: store only when it differs from what discovery reported
|
|
561
|
+
var name = values.name.trim();
|
|
562
|
+
if (name.length === 0 || name === (m._discoveredName || m.id)) delete next.name;
|
|
563
|
+
else next.name = name;
|
|
564
|
+
if (!values.protocol) delete next.protocol; else next.protocol = values.protocol;
|
|
565
|
+
["contextWindow", "maxTokens"].forEach(function (k) {
|
|
566
|
+
if (values[k] === "") delete next[k]; else next[k] = Number(values[k]);
|
|
567
|
+
});
|
|
568
|
+
var levels = values.reasoningLevels.split(",").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
|
|
569
|
+
if (levels.length === 0) delete next.reasoningLevels; else next.reasoningLevels = levels;
|
|
570
|
+
props.onChange(next);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
return h("form", { className: "na_editor", onSubmit: function (e) { e.preventDefault(); submit(); } },
|
|
574
|
+
h("div", { className: "na_editorGrid" },
|
|
575
|
+
h(Field, { label: T.name, spanAll: true, hint: T.nameHint },
|
|
576
|
+
h("input", { className: "na_input", type: "text", value: values.name, placeholder: m._discoveredName || m.id, onChange: function (e) { set("name", e.target.value); } }),
|
|
577
|
+
),
|
|
578
|
+
h(Field, { label: T.protocol },
|
|
579
|
+
h("select", { className: "na_input", value: values.protocol, onChange: function (e) { set("protocol", e.target.value); } },
|
|
580
|
+
h("option", { value: "" }, T.protocolAuto(m._protocol)),
|
|
581
|
+
PROTOCOLS.map(function (p) { return h("option", { key: p, value: p }, p); }),
|
|
582
|
+
),
|
|
583
|
+
),
|
|
584
|
+
h(Field, { label: T.contextWindow, error: errors.contextWindow, hint: m._discoveredContext ? T.discoveredAs(fmtTokens(m._discoveredContext)) : null },
|
|
585
|
+
h("input", { className: "na_input" + (errors.contextWindow ? " na_inputInvalid" : ""), type: "number", min: 1, value: values.contextWindow, placeholder: m._discoveredContext ? String(m._discoveredContext) : "200000", onChange: function (e) { set("contextWindow", e.target.value); } }),
|
|
586
|
+
),
|
|
587
|
+
h(Field, { label: T.maxTokens, error: errors.maxTokens, hint: m._discoveredMax ? T.discoveredAs(fmtTokens(m._discoveredMax)) : null },
|
|
588
|
+
h("input", { className: "na_input" + (errors.maxTokens ? " na_inputInvalid" : ""), type: "number", min: 1, value: values.maxTokens, placeholder: m._discoveredMax ? String(m._discoveredMax) : "32768", onChange: function (e) { set("maxTokens", e.target.value); } }),
|
|
589
|
+
),
|
|
590
|
+
h(Field, { label: T.reasoningLevels, spanAll: true, hint: T.reasoningHint },
|
|
591
|
+
h("input", { className: "na_input", type: "text", value: values.reasoningLevels, spellCheck: false, placeholder: "off, low, medium, high", onChange: function (e) { set("reasoningLevels", e.target.value); } }),
|
|
592
|
+
),
|
|
593
|
+
),
|
|
594
|
+
h("div", { className: "na_formRow" },
|
|
595
|
+
h("button", { className: "na_btn na_btnPrimary", type: "submit", disabled: !canSave }, T.save),
|
|
596
|
+
h("button", { className: "na_btn", type: "button", onClick: props.onClose }, T.cancel),
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** One model row: id + tags / name + meta / hide / expand / delete. */
|
|
602
|
+
function ModelRow(props) {
|
|
603
|
+
var m = props.model;
|
|
604
|
+
var _e = React.useState(false); var expanded = _e[0]; var setExpanded = _e[1];
|
|
605
|
+
var displayName = m.name || m._discoveredName || "";
|
|
606
|
+
var metaParts = [];
|
|
607
|
+
if (m._protocol) metaParts.push(m._protocol);
|
|
608
|
+
if (m._discoveredContext) metaParts.push(fmtTokens(m._discoveredContext) + " ctx");
|
|
609
|
+
if (m._reasoning) metaParts.push("reasoning");
|
|
610
|
+
var overridden = m.protocol || m.contextWindow || m.maxTokens || m.reasoningLevels || (m.name && m.name !== m._discoveredName);
|
|
611
|
+
var toggleHide = function () {
|
|
612
|
+
var next = Object.assign({}, m);
|
|
613
|
+
if (m.disabled) delete next.disabled; else next.disabled = true;
|
|
614
|
+
props.onChange(next);
|
|
615
|
+
};
|
|
616
|
+
return h("div", { className: "na_modelRow" + (m.disabled ? " na_modelRowHidden" : "") },
|
|
617
|
+
h("span", { className: "na_modelId" }, m.id,
|
|
618
|
+
m.disabled ? h("span", { className: "na_tag" }, T.disabledTag) : null,
|
|
619
|
+
m._custom ? h("span", { className: "na_tag" }, T.custom) : null,
|
|
620
|
+
overridden ? h("span", { className: "na_tag" }, T.overrideBadge) : null,
|
|
621
|
+
),
|
|
622
|
+
h("span", { className: "na_modelNameCol" },
|
|
623
|
+
displayName && displayName !== m.id ? h("span", { className: "na_modelName" }, displayName) : null,
|
|
624
|
+
metaParts.length > 0 ? h("span", { className: "na_modelMeta" }, metaParts.join(" · ")) : null,
|
|
625
|
+
),
|
|
626
|
+
h("button", { className: "na_btn", onClick: toggleHide }, m.disabled ? T.enable : T.disable),
|
|
627
|
+
h("button", { className: "na_btn", onClick: function () { setExpanded(!expanded); } }, expanded ? T.collapse : T.expand),
|
|
628
|
+
m._custom ? h("button", { className: "na_btn na_btnDanger", onClick: function () { props.onDelete(m.id); } }, T.delete) : h("span", null),
|
|
629
|
+
expanded ? h(ModelEditor, {
|
|
630
|
+
model: m,
|
|
631
|
+
onChange: function (next) { props.onChange(next); setExpanded(false); },
|
|
632
|
+
onClose: function () { setExpanded(false); },
|
|
633
|
+
}) : null,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Model list with search + filter + add-custom entry. */
|
|
638
|
+
function ModelList(props) {
|
|
639
|
+
var models = props.models || [];
|
|
640
|
+
var _q = React.useState(""); var query = _q[0]; var setQuery = _q[1];
|
|
641
|
+
var _f = React.useState(""); var filter = _f[0]; var setFilter = _f[1];
|
|
642
|
+
var _a = React.useState(false); var addOpen = _a[0]; var setAddOpen = _a[1];
|
|
643
|
+
var q = query.trim().toLowerCase();
|
|
644
|
+
var hiddenCount = models.filter(function (m) { return m.disabled === true; }).length;
|
|
645
|
+
var customCount = models.filter(function (m) { return m._custom === true; }).length;
|
|
646
|
+
var visible = models.filter(function (m) {
|
|
647
|
+
if (filter === "hidden" && m.disabled !== true) return false;
|
|
648
|
+
if (filter === "custom" && m._custom !== true) return false;
|
|
649
|
+
if (q.length === 0) return true;
|
|
650
|
+
var hay = (m.id + " " + (m.name || "") + " " + (m._discoveredName || "")).toLowerCase();
|
|
651
|
+
return hay.indexOf(q) !== -1;
|
|
652
|
+
});
|
|
653
|
+
return h("div", { className: "na_models" },
|
|
654
|
+
h("div", { className: "na_toolbar" },
|
|
655
|
+
h("input", { className: "na_input na_search", type: "search", value: query, placeholder: T.searchPlaceholder, onChange: function (e) { setQuery(e.target.value); } }),
|
|
656
|
+
h("select", { className: "na_input", value: filter, onChange: function (e) { setFilter(e.target.value); } },
|
|
657
|
+
h("option", { value: "" }, T.filterAll + " · " + models.length),
|
|
658
|
+
hiddenCount > 0 || filter === "hidden" ? h("option", { value: "hidden" }, T.filterHidden + " · " + hiddenCount) : null,
|
|
659
|
+
customCount > 0 || filter === "custom" ? h("option", { value: "custom" }, T.filterCustom + " · " + customCount) : null,
|
|
660
|
+
),
|
|
661
|
+
h("span", { style: { marginLeft: "auto" } },
|
|
662
|
+
h("button", { className: "na_btn", onClick: function () { setAddOpen(!addOpen); } }, addOpen ? T.cancel : "+ " + T.addModel),
|
|
663
|
+
),
|
|
664
|
+
),
|
|
665
|
+
addOpen ? h(AddModelForm, {
|
|
666
|
+
models: models,
|
|
667
|
+
onAdd: function (entry) { props.onAdd(entry); setAddOpen(false); },
|
|
668
|
+
onClose: function () { setAddOpen(false); },
|
|
669
|
+
}) : null,
|
|
670
|
+
models.length === 0 ? h("div", { className: "na_empty" }, T.noModels) :
|
|
671
|
+
visible.length === 0 ? h("div", { className: "na_empty" }, T.noMatch) :
|
|
672
|
+
visible.map(function (m) {
|
|
673
|
+
return h(ModelRow, { key: m.id, model: m, onChange: props.onChange, onDelete: props.onDelete });
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Inline form to add a new gateway to the gateways array. Starts from
|
|
680
|
+
* a gateway-type template (newapi / litellm / higress / OpenAI-
|
|
681
|
+
* compatible / fully custom); the custom template replaces the single
|
|
682
|
+
* base URL with per-protocol full endpoint URLs — empty ones stay
|
|
683
|
+
* disabled ("没填的就是没有").
|
|
684
|
+
*/
|
|
685
|
+
function AddGatewayForm(props) {
|
|
686
|
+
var existingIds = props.existingIds || [];
|
|
687
|
+
var _o = React.useState(false); var open = _o[0]; var setOpen = _o[1];
|
|
688
|
+
var _id = React.useState(""); var gwId = _id[0]; var setGwId = _id[1];
|
|
689
|
+
var _lb = React.useState(""); var gwLabel = _lb[0]; var setGwLabel = _lb[1];
|
|
690
|
+
var _u = React.useState(""); var gwURL = _u[0]; var setGwURL = _u[1];
|
|
691
|
+
var _p = React.useState({ openaiURL: "", responsesURL: "", anthropicURL: "" }); var protoURLs = _p[0]; var setProtoURLs = _p[1];
|
|
692
|
+
var _fl = React.useState("newapi"); var gwFlavor = _fl[0]; var setGwFlavor = _fl[1];
|
|
693
|
+
var _ke = React.useState(""); var gwKeyEnv = _ke[0]; var setGwKeyEnv = _ke[1];
|
|
694
|
+
var _kk = React.useState(""); var gwKey = _kk[0]; var setGwKey = _kk[1];
|
|
695
|
+
var _st = React.useState(null); var status = _st[0]; var setStatus = _st[1];
|
|
696
|
+
var _ut = React.useState(false); var urlTouched = _ut[0]; var setUrlTouched = _ut[1];
|
|
697
|
+
var _tr = React.useState(false); var tried = _tr[0]; var setTried = _tr[1];
|
|
698
|
+
|
|
699
|
+
var isCustom = gwFlavor === "custom";
|
|
700
|
+
var flavorInfo = FLAVOR_INFO[gwFlavor] || FLAVOR_INFO["newapi"];
|
|
701
|
+
var sid = sanitizeId(gwId);
|
|
702
|
+
var trimmedURL = gwURL.trim();
|
|
703
|
+
var errors = {};
|
|
704
|
+
if (gwId.length > 0 && sid.length === 0) errors.id = T.idInvalid;
|
|
705
|
+
else if (sid.length > 0 && existingIds.indexOf(sid) !== -1) errors.id = T.idExists;
|
|
706
|
+
var customFilled = false;
|
|
707
|
+
if (isCustom) {
|
|
708
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
709
|
+
var v = (protoURLs[k] || "").trim();
|
|
710
|
+
if (v.length === 0) return;
|
|
711
|
+
customFilled = true;
|
|
712
|
+
if (!validURL(v)) errors[k] = T.urlInvalid;
|
|
713
|
+
});
|
|
714
|
+
} else if (trimmedURL.length === 0) {
|
|
715
|
+
if (urlTouched || tried) errors.baseURL = T.urlRequired;
|
|
716
|
+
} else if (!validURL(trimmedURL)) {
|
|
717
|
+
errors.baseURL = T.urlInvalid;
|
|
718
|
+
}
|
|
719
|
+
var canSubmit = sid.length > 0 && !errors.id && Object.keys(errors).length === 0
|
|
720
|
+
&& (isCustom ? customFilled : validURL(trimmedURL));
|
|
721
|
+
|
|
722
|
+
var close = function () { setOpen(false); setStatus(null); setTried(false); };
|
|
723
|
+
var submit = function () {
|
|
724
|
+
setTried(true);
|
|
725
|
+
if (!canSubmit) return;
|
|
726
|
+
setStatus(null);
|
|
727
|
+
var entry = {
|
|
728
|
+
id: sid,
|
|
729
|
+
label: gwLabel.trim() || undefined,
|
|
730
|
+
flavor: gwFlavor,
|
|
731
|
+
apiKeyEnv: gwKeyEnv.trim() || undefined,
|
|
732
|
+
apiKey: gwKey || undefined,
|
|
733
|
+
};
|
|
734
|
+
if (isCustom) {
|
|
735
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
736
|
+
var v = (protoURLs[k] || "").trim().replace(/\/+$/, "");
|
|
737
|
+
if (v.length > 0) entry[k] = v;
|
|
738
|
+
});
|
|
739
|
+
} else {
|
|
740
|
+
entry.baseURL = trimmedURL.replace(/\/+$/, "");
|
|
741
|
+
}
|
|
742
|
+
props.onAdd(entry).then(function (result) {
|
|
743
|
+
if (result.ok) {
|
|
744
|
+
setGwId(""); setGwLabel(""); setGwURL(""); setGwFlavor("newapi"); setGwKeyEnv(""); setGwKey("");
|
|
745
|
+
setProtoURLs({ openaiURL: "", responsesURL: "", anthropicURL: "" });
|
|
746
|
+
setUrlTouched(false); setTried(false);
|
|
747
|
+
close();
|
|
748
|
+
} else { setStatus(result); }
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
var setProtoURL = function (key, val) {
|
|
752
|
+
setProtoURLs(function (prev) { return Object.assign({}, prev, { [key]: val }); });
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
if (!open) return h("button", { className: "na_btn na_btnPrimary", onClick: function () { setOpen(true); setStatus(null); } }, "+ " + T.addGateway);
|
|
756
|
+
return h("form", { className: "na_addForm", onSubmit: function (e) { e.preventDefault(); submit(); } },
|
|
757
|
+
h("div", { className: "na_muted" }, T.addGatewayHint),
|
|
758
|
+
h("div", { className: "na_field" },
|
|
759
|
+
h("label", { className: "na_fieldLabel" }, T.flavor),
|
|
760
|
+
h("div", { className: "na_tplRow", role: "radiogroup" },
|
|
761
|
+
GATEWAY_FLAVORS.map(function (f) {
|
|
762
|
+
return h("button", {
|
|
763
|
+
key: f, type: "button", className: "na_tpl" + (gwFlavor === f ? " na_tplOn" : ""),
|
|
764
|
+
"aria-pressed": gwFlavor === f, onClick: function () { setGwFlavor(f); setStatus(null); },
|
|
765
|
+
}, FLAVOR_INFO[f].label);
|
|
766
|
+
}),
|
|
767
|
+
),
|
|
768
|
+
h("span", { className: "na_hint" }, flavorInfo.hint),
|
|
769
|
+
),
|
|
770
|
+
h("div", { className: "na_addFormGrid" },
|
|
771
|
+
h(Field, { label: T.gatewayId, error: errors.id, hint: sid ? T.idPreview(sid) : T.idInvalid },
|
|
772
|
+
h("input", { className: "na_input" + (errors.id ? " na_inputInvalid" : ""), type: "text", value: gwId, spellCheck: false, placeholder: T.idPlaceholder, onChange: function (e) { setGwId(e.target.value); } }),
|
|
773
|
+
),
|
|
774
|
+
h(Field, { label: T.label },
|
|
775
|
+
h("input", { className: "na_input", type: "text", value: gwLabel, placeholder: T.labelPlaceholder, onChange: function (e) { setGwLabel(e.target.value); } }),
|
|
776
|
+
),
|
|
777
|
+
!isCustom ? h(Field, { label: T.baseURL, error: errors.baseURL },
|
|
778
|
+
h("input", { className: "na_input" + (errors.baseURL ? " na_inputInvalid" : ""), type: "text", value: gwURL, spellCheck: false, placeholder: flavorInfo.url || T.urlPlaceholder, onBlur: function () { setUrlTouched(true); }, onChange: function (e) { setGwURL(e.target.value); } }),
|
|
779
|
+
) : null,
|
|
780
|
+
isCustom ? PROTOCOL_URL_KEYS.map(function (k) {
|
|
781
|
+
var info = PROTOCOL_URL_INFO[k];
|
|
782
|
+
// Invalid URLs flag immediately; "at least one" once the user
|
|
783
|
+
// is actually configuring (id typed or submit attempted) —
|
|
784
|
+
// a completely empty form stays calm.
|
|
785
|
+
var askOne = k === "openaiURL" && !customFilled && (tried || sid.length > 0);
|
|
786
|
+
var err = errors[k] !== undefined ? errors[k] : (askOne ? T.customNeedOne : null);
|
|
787
|
+
return h(Field, { key: k, label: info.label, error: err, hint: info.hint },
|
|
788
|
+
h("input", { className: "na_input" + (err ? " na_inputInvalid" : ""), type: "text", value: protoURLs[k], spellCheck: false, placeholder: info.ph, onChange: function (e) { setProtoURL(k, e.target.value); } }),
|
|
789
|
+
);
|
|
790
|
+
}) : null,
|
|
791
|
+
h(Field, { label: T.apiKeyEnv, hint: T.keyEnvHint },
|
|
792
|
+
h("input", { className: "na_input", type: "text", value: gwKeyEnv, spellCheck: false, placeholder: "MY_GATEWAY_KEY", onChange: function (e) { setGwKeyEnv(e.target.value); } }),
|
|
793
|
+
),
|
|
794
|
+
h(Field, { label: T.apiKeyLabel, hint: T.keyKeepHint },
|
|
795
|
+
h("input", { className: "na_input", type: "password", value: gwKey, placeholder: T.enterApiKey, onChange: function (e) { setGwKey(e.target.value); } }),
|
|
796
|
+
),
|
|
797
|
+
),
|
|
798
|
+
h("div", { className: "na_formRow" },
|
|
799
|
+
h("button", { className: "na_btn na_btnPrimary", type: "submit", disabled: !canSubmit }, T.save),
|
|
800
|
+
h("button", { className: "na_btn", type: "button", onClick: close }, T.cancel),
|
|
801
|
+
status && !status.ok ? h("span", { className: "na_badge na_testFail" }, status.label) : null,
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The full settings section component. */
|
|
807
|
+
function GatewayModelsSection(props) {
|
|
808
|
+
var api = props.api;
|
|
809
|
+
var _s = React.useState(null); var snapshot = _s[0]; var setSnapshot = _s[1];
|
|
810
|
+
var _f = React.useState({}); var fetching = _f[0]; var setFetching = _f[1];
|
|
811
|
+
var _t = React.useState({}); var testing = _t[0]; var setTesting = _t[1];
|
|
812
|
+
var _ts = React.useState({}); var testStatus = _ts[0]; var setTestStatus = _ts[1];
|
|
813
|
+
var _cr = React.useState({}); var credentials = _cr[0]; var setCredentials = _cr[1];
|
|
814
|
+
// Latest-known snapshot, updated synchronously by every write so
|
|
815
|
+
// consecutive mutations inside one load() round-trip do not read a
|
|
816
|
+
// stale closure and drop each other (writeModels & friends).
|
|
817
|
+
// simplify: settings requests on one connection are applied in
|
|
818
|
+
// order; truly concurrent out-of-order applies would still need a
|
|
819
|
+
// server-side compare-and-swap.
|
|
820
|
+
var snapRef = React.useRef(null);
|
|
821
|
+
|
|
822
|
+
var load = React.useCallback(function () {
|
|
823
|
+
api.settings.describe({}).then(function (res) {
|
|
824
|
+
var nsList = res && res.result && res.result.ok ? (res.result.value.namespaces || []) : [];
|
|
825
|
+
var desc = nsList.find(function (n) { return n.ns === NS; });
|
|
826
|
+
var snap = desc ? desc.value || {} : {};
|
|
827
|
+
setSnapshot(snap);
|
|
828
|
+
snapRef.current = snap;
|
|
829
|
+
// Collect all credential refs (default + each gateway's apiKeyEnv).
|
|
830
|
+
var refs = [snap.apiKeyEnv || "NEWAPI_API_KEY"];
|
|
831
|
+
(snap.gateways || []).forEach(function (gw) {
|
|
832
|
+
if (gw.apiKeyEnv && refs.indexOf(gw.apiKeyEnv) === -1) refs.push(gw.apiKeyEnv);
|
|
833
|
+
});
|
|
834
|
+
return api.credentials.describe({ refs: refs }).then(function (cres) {
|
|
835
|
+
var creds = (cres && cres.result && cres.result.ok) ? (cres.result.value.credentials || {}) : {};
|
|
836
|
+
setCredentials(creds);
|
|
837
|
+
}).catch(function () { setCredentials({}); });
|
|
838
|
+
}).catch(function () { setSnapshot({}); });
|
|
839
|
+
}, [api]);
|
|
840
|
+
|
|
841
|
+
React.useEffect(function () { ensureStyles(); load(); }, [load]);
|
|
842
|
+
|
|
843
|
+
if (snapshot === null) return h("div", { className: "na_section" }, h("p", { className: "na_muted" }, "…"));
|
|
844
|
+
|
|
845
|
+
var gateways = snapshot.gateways || [];
|
|
846
|
+
var defaultModels = snapshot.models || [];
|
|
847
|
+
var defaultKeyEnv = snapshot.apiKeyEnv || "NEWAPI_API_KEY";
|
|
848
|
+
|
|
849
|
+
/** Mutate one path op into the settings namespace; always reload
|
|
850
|
+
* afterwards (success resyncs the ref, failure rolls it back). */
|
|
851
|
+
var mutate = function (ops) {
|
|
852
|
+
return api.settings.mutate({ ns: NS, ops: ops }).then(function (res) {
|
|
853
|
+
if (!(res && res.result && res.result.ok)) {
|
|
854
|
+
console.warn("llm-newapi: settings write failed", res && res.result && res.result.error);
|
|
855
|
+
load();
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
load();
|
|
859
|
+
return true;
|
|
860
|
+
}).catch(function (err) {
|
|
861
|
+
console.warn("llm-newapi: settings write failed", err);
|
|
862
|
+
load();
|
|
863
|
+
return false;
|
|
864
|
+
});
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/** Latest gateways array from the ref (survives the load round-trip). */
|
|
868
|
+
var currentGateways = function () {
|
|
869
|
+
return (snapRef.current && snapRef.current.gateways) || [];
|
|
870
|
+
};
|
|
871
|
+
/** Optimistically sync the ref's gateways before the write settles. */
|
|
872
|
+
var syncGateways = function (next) {
|
|
873
|
+
snapRef.current = Object.assign({}, snapRef.current || {}, { gateways: next });
|
|
874
|
+
};
|
|
875
|
+
var syncModels = function (next) {
|
|
876
|
+
snapRef.current = Object.assign({}, snapRef.current || {}, { models: next });
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
/** Write a model entry (default gateway → root `models`, else
|
|
880
|
+
* gateways[i].models). Reads and updates the ref synchronously so
|
|
881
|
+
* back-to-back edits do not lose each other. */
|
|
882
|
+
var writeModels = function (gwIdx, transform) {
|
|
883
|
+
var snap = snapRef.current || {};
|
|
884
|
+
if (gwIdx === -1) {
|
|
885
|
+
var nextModels = transform(snap.models || []);
|
|
886
|
+
syncModels(nextModels);
|
|
887
|
+
return mutate([{ op: "set", path: ["models"], value: nextModels }]);
|
|
888
|
+
}
|
|
889
|
+
var gws = snap.gateways || [];
|
|
890
|
+
var nextGateways = gws.slice();
|
|
891
|
+
nextGateways[gwIdx] = Object.assign({}, gws[gwIdx], { models: transform((gws[gwIdx] && gws[gwIdx].models) || []) });
|
|
892
|
+
syncGateways(nextGateways);
|
|
893
|
+
return mutate([{ op: "set", path: ["gateways"], value: nextGateways }]);
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
/** Discover models for one gateway (test = same call, no write).
|
|
897
|
+
* An empty baseURL (URL-addressed custom gateway) is sent as
|
|
898
|
+
* absent so the host uses its configured discovery base. */
|
|
899
|
+
var discover = function (gwIdx, baseURL, provider) {
|
|
900
|
+
return api.llm.discoverModels({ settingsNs: NS, provider: provider, baseURL: baseURL || undefined, apiKey: undefined }).then(function (res) {
|
|
901
|
+
if (!(res && res.result && res.result.ok)) {
|
|
902
|
+
var raw = res && res.result && res.result.error;
|
|
903
|
+
var msg = typeof raw === "string" ? raw : (raw && raw.message) || "";
|
|
904
|
+
throw new Error(msg || T.testFail);
|
|
905
|
+
}
|
|
906
|
+
return (res.result.value.models || []);
|
|
907
|
+
});
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
var fetchModels = function (gwIdx) {
|
|
911
|
+
var snap = snapRef.current || {};
|
|
912
|
+
var gws = snap.gateways || [];
|
|
913
|
+
var key = gwIdx === -1 ? "default" : String(gws[gwIdx].id);
|
|
914
|
+
var gw = gwIdx === -1 ? { baseURL: snap.baseURL } : gws[gwIdx];
|
|
915
|
+
var provider = gwIdx === -1 ? "newapi" : "gateway:" + gw.id;
|
|
916
|
+
setFetching(function (prev) { return Object.assign({}, prev, { [key]: true }); });
|
|
917
|
+
discover(gwIdx, gw.baseURL, provider).then(function (found) {
|
|
918
|
+
// Keep the stored list aligned with what the picker will show:
|
|
919
|
+
// drop entries matched by the configured exclude patterns.
|
|
920
|
+
var patterns = (snap.excludePatterns || []).map(function (p) {
|
|
921
|
+
try { return new RegExp(p); } catch (e) { return null; }
|
|
922
|
+
}).filter(Boolean);
|
|
923
|
+
var discovered = found
|
|
924
|
+
.filter(function (m) { return !patterns.some(function (re) { return re.test(m.id); }); })
|
|
925
|
+
.map(function (m) { return { id: m.id, name: m.name, contextWindow: m.contextWindow, maxTokens: m.maxTokens, protocol: m.protocol, reasoning: m.reasoning }; });
|
|
926
|
+
writeModels(gwIdx, function (arr) { return mergeDiscovered(arr, discovered); });
|
|
927
|
+
if (discovered.length === 0) {
|
|
928
|
+
var emptyLabel = found.length > 0 ? T.allFiltered : T.testEmpty;
|
|
929
|
+
setTestStatus(function (prev) { return Object.assign({}, prev, { [key]: { ok: false, label: emptyLabel } }); });
|
|
930
|
+
} else {
|
|
931
|
+
setTestStatus(function (prev) { return Object.assign({}, prev, { [key]: { ok: true, label: T.syncedN(discovered.length) } }); });
|
|
932
|
+
}
|
|
933
|
+
}).catch(function (err) {
|
|
934
|
+
var msg = (err && err.message) || String(err || "");
|
|
935
|
+
setTestStatus(function (prev) { return Object.assign({}, prev, { [key]: { ok: false, label: T.testFail + (msg ? ": " + msg.slice(0, 80) : "") } }); });
|
|
936
|
+
}).finally(function () {
|
|
937
|
+
setFetching(function (prev) { var next = Object.assign({}, prev); delete next[key]; return next; });
|
|
938
|
+
});
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
/** Test connectivity to one gateway without mutating stored settings. */
|
|
942
|
+
var testConnection = function (gwIdx) {
|
|
943
|
+
var snap = snapRef.current || {};
|
|
944
|
+
var gws = snap.gateways || [];
|
|
945
|
+
var key = gwIdx === -1 ? "default" : String(gws[gwIdx].id);
|
|
946
|
+
var gw = gwIdx === -1 ? { baseURL: snap.baseURL } : gws[gwIdx];
|
|
947
|
+
var provider = gwIdx === -1 ? "newapi" : "gateway:" + gw.id;
|
|
948
|
+
setTesting(function (prev) { return Object.assign({}, prev, { [key]: true }); });
|
|
949
|
+
setTestStatus(function (prev) { var next = Object.assign({}, prev); delete next[key]; return next; });
|
|
950
|
+
discover(gwIdx, gw.baseURL, provider).then(function (found) {
|
|
951
|
+
setTestStatus(function (prev) {
|
|
952
|
+
return Object.assign({}, prev, { [key]: { ok: found.length > 0, label: found.length > 0 ? T.testOkN(found.length) : T.testEmpty } });
|
|
953
|
+
});
|
|
954
|
+
}).catch(function (err) {
|
|
955
|
+
var msg = (err && err.message) || String(err || "");
|
|
956
|
+
setTestStatus(function (prev) {
|
|
957
|
+
return Object.assign({}, prev, { [key]: { ok: false, label: T.testFail + (msg ? ": " + msg.slice(0, 80) : "") } });
|
|
958
|
+
});
|
|
959
|
+
}).finally(function () {
|
|
960
|
+
setTesting(function (prev) { var next = Object.assign({}, prev); delete next[key]; return next; });
|
|
961
|
+
});
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Save gateway config. `changes` carries only fields the user
|
|
966
|
+
* actually edited (absent = untouched; "" = clear/inherit: root
|
|
967
|
+
* fields are unset, per-gateway fields are deleted from the
|
|
968
|
+
* entry). An API-key-only save writes no settings ops at all.
|
|
969
|
+
* Returns a Promise<{ok, label}>.
|
|
970
|
+
*/
|
|
971
|
+
var saveGatewayConfig = function (gwIdx, changes) {
|
|
972
|
+
var snap = snapRef.current || {};
|
|
973
|
+
var defaultEnv = snap.apiKeyEnv || "NEWAPI_API_KEY";
|
|
974
|
+
var writes = [];
|
|
975
|
+
if (gwIdx === -1) {
|
|
976
|
+
var ops = [];
|
|
977
|
+
if (changes.baseURL !== undefined) {
|
|
978
|
+
ops.push(changes.baseURL.length > 0
|
|
979
|
+
? { op: "set", path: ["baseURL"], value: changes.baseURL }
|
|
980
|
+
: { op: "unset", path: ["baseURL"] });
|
|
981
|
+
}
|
|
982
|
+
if (changes.label !== undefined) {
|
|
983
|
+
ops.push(changes.label.length > 0
|
|
984
|
+
? { op: "set", path: ["label"], value: changes.label }
|
|
985
|
+
: { op: "unset", path: ["label"] });
|
|
986
|
+
}
|
|
987
|
+
if (changes.flavor !== undefined && changes.flavor.length > 0) {
|
|
988
|
+
ops.push({ op: "set", path: ["flavor"], value: changes.flavor });
|
|
989
|
+
}
|
|
990
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
991
|
+
if (changes[k] === undefined) return;
|
|
992
|
+
ops.push(changes[k].length > 0
|
|
993
|
+
? { op: "set", path: [k], value: changes[k] }
|
|
994
|
+
: { op: "unset", path: [k] });
|
|
995
|
+
});
|
|
996
|
+
if (ops.length > 0) writes.push(api.settings.mutate({ ns: NS, ops: ops }));
|
|
997
|
+
if (changes.apiKey !== undefined) writes.push(api.credentials.set({ ref: defaultEnv, value: changes.apiKey }));
|
|
998
|
+
// Optimistically sync the ref; load() reconciles after settle.
|
|
999
|
+
var nextSnap = Object.assign({}, snap);
|
|
1000
|
+
if (changes.baseURL !== undefined) { if (changes.baseURL.length > 0) nextSnap.baseURL = changes.baseURL; else delete nextSnap.baseURL; }
|
|
1001
|
+
if (changes.label !== undefined) { if (changes.label.length > 0) nextSnap.label = changes.label; else delete nextSnap.label; }
|
|
1002
|
+
if (changes.flavor !== undefined && changes.flavor.length > 0) nextSnap.flavor = changes.flavor;
|
|
1003
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
1004
|
+
if (changes[k] === undefined) return;
|
|
1005
|
+
if (changes[k].length > 0) nextSnap[k] = changes[k]; else delete nextSnap[k];
|
|
1006
|
+
});
|
|
1007
|
+
snapRef.current = nextSnap;
|
|
1008
|
+
} else {
|
|
1009
|
+
var gws = snap.gateways || [];
|
|
1010
|
+
var gw = gws[gwIdx] || {};
|
|
1011
|
+
var updated = Object.assign({}, gw);
|
|
1012
|
+
var touched = false;
|
|
1013
|
+
var setOrDelete = function (key, value) {
|
|
1014
|
+
if (value.length > 0) updated[key] = value; else delete updated[key];
|
|
1015
|
+
touched = true;
|
|
1016
|
+
};
|
|
1017
|
+
if (changes.baseURL !== undefined) setOrDelete("baseURL", changes.baseURL);
|
|
1018
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
1019
|
+
if (changes[k] !== undefined) setOrDelete(k, changes[k]);
|
|
1020
|
+
});
|
|
1021
|
+
if (changes.label !== undefined) {
|
|
1022
|
+
setOrDelete("label", changes.label);
|
|
1023
|
+
}
|
|
1024
|
+
if (changes.apiKeyEnv !== undefined) {
|
|
1025
|
+
setOrDelete("apiKeyEnv", changes.apiKeyEnv);
|
|
1026
|
+
}
|
|
1027
|
+
if (changes.flavor !== undefined) {
|
|
1028
|
+
setOrDelete("flavor", changes.flavor);
|
|
1029
|
+
}
|
|
1030
|
+
if (changes.catalogMode !== undefined) {
|
|
1031
|
+
setOrDelete("catalogMode", changes.catalogMode);
|
|
1032
|
+
}
|
|
1033
|
+
if (touched) {
|
|
1034
|
+
var nextGateways = gws.slice();
|
|
1035
|
+
nextGateways[gwIdx] = updated;
|
|
1036
|
+
writes.push(api.settings.mutate({ ns: NS, ops: [{ op: "set", path: ["gateways"], value: nextGateways }] }));
|
|
1037
|
+
syncGateways(nextGateways);
|
|
1038
|
+
}
|
|
1039
|
+
if (changes.apiKey !== undefined) {
|
|
1040
|
+
var keyRef = changes.apiKeyEnv || gw.apiKeyEnv || defaultEnv;
|
|
1041
|
+
writes.push(api.credentials.set({ ref: keyRef, value: changes.apiKey }));
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return Promise.all(writes).then(function (results) {
|
|
1045
|
+
var ok = results.every(function (r) { return r && r.result && r.result.ok; });
|
|
1046
|
+
load(); // resync the ref with the persisted truth (success or not)
|
|
1047
|
+
return { ok: ok, label: ok ? T.configSaved : T.configSaveFail };
|
|
1048
|
+
}).catch(function () {
|
|
1049
|
+
load();
|
|
1050
|
+
return { ok: false, label: T.configSaveFail };
|
|
1051
|
+
});
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
/** Add a new gateway entry (plus its API key credential, if given). */
|
|
1055
|
+
var addGateway = function (spec) {
|
|
1056
|
+
var gws = currentGateways();
|
|
1057
|
+
if (gws.some(function (gw) { return gw.id === spec.id; })) {
|
|
1058
|
+
return Promise.resolve({ ok: false, label: T.idExists });
|
|
1059
|
+
}
|
|
1060
|
+
var entry = { id: spec.id };
|
|
1061
|
+
if (spec.label) entry.label = spec.label;
|
|
1062
|
+
if (spec.flavor) entry.flavor = spec.flavor;
|
|
1063
|
+
if (spec.apiKeyEnv) entry.apiKeyEnv = spec.apiKeyEnv;
|
|
1064
|
+
if (spec.baseURL) entry.baseURL = spec.baseURL;
|
|
1065
|
+
PROTOCOL_URL_KEYS.forEach(function (k) {
|
|
1066
|
+
if (spec[k]) entry[k] = spec[k];
|
|
1067
|
+
});
|
|
1068
|
+
var nextGateways = gws.concat([entry]);
|
|
1069
|
+
syncGateways(nextGateways);
|
|
1070
|
+
var writes = [
|
|
1071
|
+
api.settings.mutate({ ns: NS, ops: [{ op: "set", path: ["gateways"], value: nextGateways }] }),
|
|
1072
|
+
];
|
|
1073
|
+
if (spec.apiKey !== undefined && spec.apiKey.length > 0) {
|
|
1074
|
+
var keyEnv = (snapRef.current && snapRef.current.apiKeyEnv) || "NEWAPI_API_KEY";
|
|
1075
|
+
writes.push(api.credentials.set({ ref: spec.apiKeyEnv || keyEnv, value: spec.apiKey }));
|
|
1076
|
+
}
|
|
1077
|
+
return Promise.all(writes).then(function (results) {
|
|
1078
|
+
var ok = results.every(function (r) { return r && r.result && r.result.ok; });
|
|
1079
|
+
load();
|
|
1080
|
+
return { ok: ok, label: ok ? T.configSaved : T.configSaveFail };
|
|
1081
|
+
}).catch(function () {
|
|
1082
|
+
load();
|
|
1083
|
+
return { ok: false, label: T.configSaveFail };
|
|
1084
|
+
});
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
/** Delete one additional gateway after an explicit confirm. */
|
|
1088
|
+
var deleteGateway = function (gwIdx) {
|
|
1089
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(T.confirmDelete)) return;
|
|
1090
|
+
var nextGateways = currentGateways().slice();
|
|
1091
|
+
nextGateways.splice(gwIdx, 1);
|
|
1092
|
+
syncGateways(nextGateways);
|
|
1093
|
+
mutate([{ op: "set", path: ["gateways"], value: nextGateways }]);
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
return h("div", { className: "na_section" },
|
|
1097
|
+
h("h2", { className: "na_title" }, T.title),
|
|
1098
|
+
h("p", { className: "na_intro" }, T.intro),
|
|
1099
|
+
// Default gateway (legacy flat fields)
|
|
1100
|
+
h(GatewayCard, {
|
|
1101
|
+
key: "default",
|
|
1102
|
+
gateway: {
|
|
1103
|
+
id: "default", label: snapshot.label || "NewAPI", flavor: snapshot.flavor || "newapi", baseURL: snapshot.baseURL,
|
|
1104
|
+
openaiURL: snapshot.openaiURL, responsesURL: snapshot.responsesURL, anthropicURL: snapshot.anthropicURL,
|
|
1105
|
+
},
|
|
1106
|
+
models: defaultModels, isDefault: true, route: "newapi", snapshot: snapshot,
|
|
1107
|
+
fetching: fetching.default, onFetch: function () { fetchModels(-1); },
|
|
1108
|
+
testing: testing.default, onTest: function () { testConnection(-1); },
|
|
1109
|
+
testStatus: testStatus.default,
|
|
1110
|
+
apiKeyEnv: defaultKeyEnv,
|
|
1111
|
+
credential: credentials[defaultKeyEnv],
|
|
1112
|
+
onSaveConfig: function (v) { return saveGatewayConfig(-1, v); },
|
|
1113
|
+
onModelChange: function (entry) { writeModels(-1, function (arr) { return upsertModel(arr, entry); }); },
|
|
1114
|
+
onModelDelete: function (id) { writeModels(-1, function (arr) { return arr.filter(function (m) { return m.id !== id; }); }); },
|
|
1115
|
+
onAddModel: function (entry) { writeModels(-1, function (arr) { return upsertModel(arr, entry); }); },
|
|
1116
|
+
}),
|
|
1117
|
+
// Additional gateways
|
|
1118
|
+
gateways.map(function (gw, i) {
|
|
1119
|
+
return h(GatewayCard, {
|
|
1120
|
+
key: gw.id || i,
|
|
1121
|
+
gateway: gw, models: gw.models || [], route: "gateway:" + gw.id,
|
|
1122
|
+
fetching: fetching[gw.id], onFetch: function () { fetchModels(i); },
|
|
1123
|
+
testing: testing[gw.id], onTest: function () { testConnection(i); },
|
|
1124
|
+
testStatus: testStatus[gw.id],
|
|
1125
|
+
apiKeyEnv: gw.apiKeyEnv || defaultKeyEnv,
|
|
1126
|
+
credential: credentials[gw.apiKeyEnv || defaultKeyEnv],
|
|
1127
|
+
onSaveConfig: function (v) { return saveGatewayConfig(i, v); },
|
|
1128
|
+
onDelete: function () { deleteGateway(i); },
|
|
1129
|
+
onModelChange: function (entry) { writeModels(i, function (arr) { return upsertModel(arr, entry); }); },
|
|
1130
|
+
onModelDelete: function (id) { writeModels(i, function (arr) { return arr.filter(function (m) { return m.id !== id; }); }); },
|
|
1131
|
+
onAddModel: function (entry) { writeModels(i, function (arr) { return upsertModel(arr, entry); }); },
|
|
1132
|
+
});
|
|
1133
|
+
}),
|
|
1134
|
+
// Add gateway form
|
|
1135
|
+
h(AddGatewayForm, { onAdd: addGateway, existingIds: gateways.map(function (gw) { return gw.id; }) }),
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
var inject = ["slots", "connection"];
|
|
1140
|
+
|
|
1141
|
+
function apply(ctx) {
|
|
1142
|
+
var slots = ctx.get("slots");
|
|
1143
|
+
if (slots === undefined) return;
|
|
1144
|
+
slots.inject("settings.section", function () {
|
|
1145
|
+
slots.register({
|
|
1146
|
+
name: "settings.section",
|
|
1147
|
+
id: "newapi-gateways",
|
|
1148
|
+
order: 20,
|
|
1149
|
+
label: function () { return T.nav; },
|
|
1150
|
+
inject: function () { return { api: ctx.get("connection").api }; },
|
|
1151
|
+
}, function (props) {
|
|
1152
|
+
return React.createElement(GatewayModelsSection, { api: props.api });
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
exports.apply = apply;
|
|
1158
|
+
exports.inject = inject;
|
|
1159
|
+
// Pure helpers exported for the client render/merge unit tests.
|
|
1160
|
+
exports.mergeDiscovered = mergeDiscovered;
|
|
1161
|
+
exports.upsertModel = upsertModel;
|
|
1162
|
+
return module.exports;
|
|
1163
|
+
}
|
|
1164
|
+
});
|