pi-custom-provider-model 0.1.1
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 +175 -0
- package/docs/design.md +54 -0
- package/package.json +53 -0
- package/src/discovery.ts +124 -0
- package/src/index.ts +63 -0
- package/src/limits.ts +75 -0
- package/src/probes.ts +150 -0
- package/src/server.ts +109 -0
- package/src/service.ts +205 -0
- package/src/standalone.ts +15 -0
- package/src/storage.ts +117 -0
- package/src/types.ts +112 -0
- package/web/app.js +589 -0
- package/web/index.html +73 -0
- package/web/style.css +293 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
const translations = {
|
|
3
|
+
id: {
|
|
4
|
+
local:"Workspace lokal",providers:"Provider",addProvider:"+ Tambah provider",configuration:"KONFIGURASI",docs:"Dokumentasi resmi Pi ↗",connections:"KONEKSI",subtitle:"Endpoint dan model pilihanmu. Konfigurasi asli Pi.",refresh:"Muat ulang konfigurasi",connection:"Koneksi",native:"Format Pi",readonly:"Provider ini dikelola Pi atau integrasi lain. Buat provider custom untuk mengedit di sini.",providerId:"ID provider",idHelp:"Nama unik di Pi, misalnya my-gateway.",protocol:"Protokol API",protocolHelp:"Pilih protokol yang didukung endpoint.",requestPreview:"PREVIEW REQUEST",credentialSource:"SUMBER KREDENSIAL",removeKey:"Hapus key tersimpan",bearer:"Tambahkan header Authorization: Bearer secara eksplisit (Pi authHeader)",advanced:"Field lanjutan dan header custom yang sudah ada dipertahankan. Override per model dapat mengubah request efektif.",testConnection:"Tes koneksi",fetchModels:"Tarik model ↗",testHint:"Memeriksa daftar model. Tanpa request generasi.",models:"Model",addModel:"+ Model manual",modelHint:"Pilih model untuk disimpan. ID yang terdaftar belum menjamin dukungan chat, vision, atau tools.",selectAll:"Pilih yang terlihat",modelId:"ID model",context:"Konteks",input:"Input",actions:"Aksi",noModels:"Hubungkan model pertamamu",noModelsHint:"Tarik model dari endpoint atau tambahkan ID secara manual.",saveHelp:"Model → models.json · Key → auth.json",deleteProvider:"Hapus provider",save:"Simpan provider",footnote:"Konfigurasi disimpan lokal. Buka /model di Pi untuk memilih model. Default berlaku saat Pi dibuka kembali.",editModel:"Pengaturan model",displayName:"Nama tampilan (opsional)",maxOutput:"Maksimal token output",vision:"Input gambar",reasoning:"Dukungan reasoning",defaultText:"Default Pi (teks)",defaultOff:"Default Pi (nonaktif)",textOnly:"Teks saja",textImage:"Teks + gambar",off:"Nonaktif",on:"Aktif",metadataHelp:"Biarkan nilai yang belum diketahui memakai default Pi. Mengaktifkan opsi tidak menambahkan kemampuan pada model upstream.",applyModel:"Terapkan pengaturan",newProvider:"Provider baru",selected:"dipilih",edit:"Edit",test:"Tes",setDefault:"Jadikan default",default:"Default",remove:"Hapus",unsaved:"Ada perubahan yang belum disimpan. Lanjutkan?",keyHelp:"Kosongkan untuk mempertahankan key. Key baru disimpan di auth.json.",openaiHelp:"URL asli Pi. Biasanya diakhiri /v1; SDK menambahkan /chat/completions atau /responses.",anthropicHelp:"URL asli Pi. Biasanya tanpa /v1 di akhir; SDK menambahkan /v1/messages. Prefix gateway seperti /anthropic tetap dipakai.",busy:"Sedang memproses…",saved:"Provider tersimpan. Buka /model di Pi untuk memilihnya.",confirmDelete:"Hapus konfigurasi provider ini? Kredensialnya tetap tersimpan.",confirmKey:"Hapus key provider dari auth.json? Sumber key lain tetap dapat dipakai Pi.",confirmTest:"Kirim prompt singkat ke model ini? Tes memakai token, tanpa mengirim file atau percakapan proyek.",defaultSaved:"Default tersimpan untuk sesi Pi berikutnya.",fetchDone:"Daftar model diterima",allFetched:"Semua halaman yang dilaporkan endpoint telah diambil.",removeModel:"Hapus model ini dari pilihan? Perubahan berlaku setelah disimpan.",missingToken:"Buka manager dari link yang ditampilkan Pi.",search:"Cari ID model…",noProviders:"Belum ada provider custom.",saveFirst:"Simpan provider dahulu.",duplicate:"ID model sudah ada.",fallback:"default Pi",keyRemoved:"Key di auth.json dihapus.",working:"Bekerja",connectionOk:"Endpoint daftar model merespons.",refreshDone:"Konfigurasi dimuat ulang.",defaultHelp:"Berlaku saat Pi dibuka kembali.",partial:"Sebagian hasil",matches:"terlihat",
|
|
5
|
+
},
|
|
6
|
+
en: {
|
|
7
|
+
newProvider:"New provider",selected:"selected",edit:"Edit",test:"Test",setDefault:"Set default",default:"Default",remove:"Remove",unsaved:"You have unsaved changes. Continue?",keyHelp:"Leave blank to keep the current key. New keys are stored in auth.json.",openaiHelp:"Pi-native URL. Usually ends in /v1; the SDK appends /chat/completions or /responses.",anthropicHelp:"Pi-native URL. Usually no trailing /v1; the SDK appends /v1/messages. Gateway prefixes such as /anthropic are preserved.",busy:"Working…",saved:"Provider saved. Open /model in Pi to select it.",confirmDelete:"Delete this provider configuration? Its credentials will be kept.",confirmKey:"Remove this provider's key from auth.json? Pi may still use other configured key sources.",confirmTest:"Send a short prompt to this model? This uses tokens, but sends no project files or chat history.",defaultSaved:"Default saved for new Pi launches.",fetchDone:"Model list received",allFetched:"All pages reported by the endpoint have been fetched.",removeModel:"Remove this model from the selection? Changes take effect after saving.",missingToken:"Open the manager using the link printed by Pi.",search:"Search model IDs…",noProviders:"No custom providers yet.",saveFirst:"Save the provider first.",duplicate:"This model ID already exists.",fallback:"Pi default",keyRemoved:"Saved key removed from auth.json.",working:"Working",connectionOk:"Model-list endpoint responded.",refreshDone:"Configuration reloaded.",defaultHelp:"Applies to new Pi launches.",partial:"Partial results",matches:"visible",
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
const original = new Map([...document.querySelectorAll("[data-i18n]")].map((el) => [el, el.textContent]));
|
|
11
|
+
Object.assign(translations.en, {
|
|
12
|
+
testChat: "Test chat", checkCapabilities: "Check capabilities", testResults: "Model test results", hideResults: "Hide results", showResults: "Show results",
|
|
13
|
+
chat: "Chat / streaming", tools: "Tool calling", reasoning: "Reasoning", vision: "Image input",
|
|
14
|
+
supported: "Verified", inconclusive: "Unconfirmed", failed: "Test failed", untested: "Not tested", queued: "Queued", running: "Testing…",
|
|
15
|
+
stopTests: "Stop tests", testsFinished: "Tests finished", testsStopped: "Tests stopped", testingModel: "Testing model",
|
|
16
|
+
probeScope: "Results apply to this endpoint and configuration. Unconfirmed or failed tests do not prove a capability is unsupported.",
|
|
17
|
+
applyVerified: "Apply verified capabilities", verifiedApplied: "Verified vision/reasoning added to the draft. Save provider to keep these settings.",
|
|
18
|
+
requests: "request(s)", tokenUsage: "tokens in / out",
|
|
19
|
+
chat_reply: "Pi received a streaming text reply.", no_text: "No text within the 256-token budget. This model may require more reasoning tokens.",
|
|
20
|
+
tool_roundtrip: "A valid tool call was received and the model correctly read its result in a second turn.",
|
|
21
|
+
no_tool_call: "The model replied without calling the test tool. Tool support is not confirmed.",
|
|
22
|
+
invalid_tool_call: "The returned tool call did not match the required name, arguments or completion state.",
|
|
23
|
+
tool_result_unconfirmed: "A valid tool call was received, but the follow-up did not confirm its result.",
|
|
24
|
+
reasoning_observed: "Pi received a thinking block or reported reasoning-token usage.",
|
|
25
|
+
no_reasoning_evidence: "Reasoning was requested, but no thinking block or reasoning tokens were reported. The gateway may hide or ignore them.",
|
|
26
|
+
reasoning_budget: "The configured output limit is below the 2,048 tokens required for this reasoning check.",
|
|
27
|
+
vision_matched: "The model correctly identified all four colors in a generated test image.",
|
|
28
|
+
vision_unconfirmed: "An image was sent, but the answer did not match the visual challenge. Image support is not confirmed.",
|
|
29
|
+
auth_failed: "The endpoint refused access. Check the API key and model permissions.", rate_limited: "The endpoint rate-limited the request. Retry later.",
|
|
30
|
+
redirect: "The endpoint redirected the request. Check its base URL.", request_rejected: "The endpoint rejected this request. Check model access, capability parameters and Pi compatibility settings.",
|
|
31
|
+
invalid_stream: "Pi could not parse a completed streaming reply.", connection_failed: "Could not connect to the model endpoint.",
|
|
32
|
+
timeout: "The test reached its time limit. Support is not determined.", cancelled: "Test stopped.",
|
|
33
|
+
developer_role_unsupported: "The gateway rejects Pi's developer role. Edit this model, choose system under System prompt role, then test again and save the provider.",
|
|
34
|
+
gateway_error: "The gateway or upstream server returned an HTTP 5xx error. Retry later; this does not establish model support or identify the failing parameter.",
|
|
35
|
+
fixSystemRole: "Edit system prompt role",
|
|
36
|
+
systemRole: "System prompt role (OpenAI)", roleAuto: "Follow Pi / provider settings", roleSystem: "system — compatible gateways", roleDeveloper: "developer — when reasoning is enabled",
|
|
37
|
+
roleHelp: "For gateways that reject the developer role with HTTP 400/422, choose system. Saved as compat.supportsDeveloperRole; reasoning remains enabled.",
|
|
38
|
+
providerSystemRole: "Default system prompt role — this provider", providerRoleAuto: "Pi automatic detection",
|
|
39
|
+
providerRoleHelp: "For OpenAI-compatible APIs. Applies to existing and newly added models that follow the provider setting. Model-specific overrides take priority.",
|
|
40
|
+
inheritedRole: "Provider default", modelRoleOverride: "Model override",
|
|
41
|
+
fillLimits: "Fill missing limits", useLimits: "Use detected limits", tokenLimits: "Token limits",
|
|
42
|
+
limitsHelp: "Context/output defaults come from endpoint metadata, then exact, unambiguous matches in Pi's bundled catalog. Gateway limits can differ. Existing values are kept; save the provider to persist filled defaults.",
|
|
43
|
+
limitEndpoint: "Endpoint metadata", limitCatalog: "Pi catalog reference", limitConfigured: "Saved / custom value", limitManual: "Manual value",
|
|
44
|
+
limitUnknown: "Maximum unknown · Pi fallback", limitAmbiguous: "Catalog values disagree; maximum unknown", limitSuggestion: "Detected value",
|
|
45
|
+
limitsFilled: "Missing known limits filled in the draft. Existing values kept. Save provider to apply.",
|
|
46
|
+
limitsUnchanged: "No missing known limits to fill. Fetch models for endpoint metadata; unknown maxima stay at Pi defaults.",
|
|
47
|
+
limitsLookup: "Looking up Pi catalog…", limitsLookupFailed: "Catalog lookup failed. You can enter limits manually.",
|
|
48
|
+
limitsCaveat: "Metadata reference, not a measured maximum. Output may also be limited by remaining context and your gateway/account.",
|
|
49
|
+
});
|
|
50
|
+
Object.assign(translations.id, {
|
|
51
|
+
probeHelp: "Tes chat mengirim satu request singkat. Cek kemampuan menguji chat, tool dua giliran, reasoning, dan gambar buatan (maksimal 5 request). Memakai token API: maksimal 256 token output per request, 2.048 untuk reasoning. Hasil tampil di bawah masing-masing model.",
|
|
52
|
+
testChat: "Tes chat", checkCapabilities: "Cek kemampuan", testResults: "Hasil tes model", hideResults: "Tutup hasil", showResults: "Lihat hasil",
|
|
53
|
+
chat: "Chat / streaming", tools: "Tool calling", reasoning: "Reasoning", vision: "Input gambar",
|
|
54
|
+
supported: "Terverifikasi", inconclusive: "Belum terkonfirmasi", failed: "Tes gagal", untested: "Belum diuji", queued: "Menunggu", running: "Menguji…",
|
|
55
|
+
stopTests: "Hentikan tes", testsFinished: "Tes selesai", testsStopped: "Tes dihentikan", testingModel: "Menguji model",
|
|
56
|
+
probeScope: "Hasil berlaku untuk endpoint dan konfigurasi ini. Tes gagal atau belum terkonfirmasi bukan bukti bahwa kemampuan tidak didukung.",
|
|
57
|
+
applyVerified: "Terapkan kemampuan terverifikasi", verifiedApplied: "Vision/reasoning terverifikasi ditambahkan ke draft. Simpan provider untuk menyimpan pengaturan ini.",
|
|
58
|
+
requests: "request", tokenUsage: "token masuk / keluar",
|
|
59
|
+
chat_reply: "Pi menerima balasan teks streaming.", no_text: "Tidak ada teks dalam batas 256 token. Model mungkin memerlukan token reasoning lebih banyak.",
|
|
60
|
+
tool_roundtrip: "Tool call valid diterima dan model membaca hasil tool dengan benar pada giliran kedua.",
|
|
61
|
+
no_tool_call: "Model membalas tanpa memanggil tool uji. Dukungan tool belum terkonfirmasi.",
|
|
62
|
+
invalid_tool_call: "Tool call tidak sesuai nama, argumen, atau status penyelesaian yang diminta.",
|
|
63
|
+
tool_result_unconfirmed: "Tool call valid diterima, tetapi balasan berikutnya belum mengonfirmasi hasilnya.",
|
|
64
|
+
reasoning_observed: "Pi menerima blok thinking atau laporan penggunaan token reasoning.",
|
|
65
|
+
no_reasoning_evidence: "Reasoning diminta, tetapi tidak ada blok thinking atau token reasoning yang dilaporkan. Gateway mungkin menyembunyikan atau mengabaikannya.",
|
|
66
|
+
reasoning_budget: "Batas output model kurang dari 2.048 token yang diperlukan untuk pemeriksaan reasoning ini.",
|
|
67
|
+
vision_matched: "Model mengenali keempat warna dalam gambar uji yang dibuat secara lokal dengan benar.",
|
|
68
|
+
vision_unconfirmed: "Gambar telah dikirim, tetapi jawaban tidak sesuai gambar uji. Dukungan gambar belum terkonfirmasi.",
|
|
69
|
+
auth_failed: "Endpoint menolak akses. Periksa API key dan izin model.", rate_limited: "Endpoint membatasi request. Coba lagi nanti.",
|
|
70
|
+
redirect: "Endpoint mengalihkan request. Periksa base URL.", request_rejected: "Endpoint menolak request. Periksa akses model, parameter kemampuan, dan pengaturan kompatibilitas Pi.",
|
|
71
|
+
invalid_stream: "Pi tidak dapat membaca balasan streaming yang selesai.", connection_failed: "Tidak dapat terhubung ke endpoint model.",
|
|
72
|
+
timeout: "Waktu tes habis. Dukungan belum dapat ditentukan.", cancelled: "Tes dihentikan.",
|
|
73
|
+
developer_role_unsupported: "Gateway menolak role developer dari Pi. Edit model ini, pilih system pada Role system prompt, lalu tes ulang dan simpan provider.",
|
|
74
|
+
gateway_error: "Gateway atau server upstream mengembalikan error HTTP 5xx. Coba lagi nanti; hasil ini belum menentukan dukungan model atau parameter yang bermasalah.",
|
|
75
|
+
fixSystemRole: "Edit role system prompt",
|
|
76
|
+
systemRole: "Role system prompt (OpenAI)", roleAuto: "Ikuti pengaturan Pi / provider", roleSystem: "system — gateway kompatibel", roleDeveloper: "developer — saat reasoning aktif",
|
|
77
|
+
roleHelp: "Jika gateway menolak role developer dengan HTTP 400/422, pilih system. Disimpan sebagai compat.supportsDeveloperRole; reasoning tetap aktif.",
|
|
78
|
+
providerSystemRole: "Default role system prompt — provider ini", providerRoleAuto: "Deteksi otomatis Pi",
|
|
79
|
+
providerRoleHelp: "Untuk API kompatibel OpenAI. Berlaku bagi model lama dan baru yang mengikuti pengaturan provider. Override per model diprioritaskan.",
|
|
80
|
+
inheritedRole: "Default provider", modelRoleOverride: "Override model",
|
|
81
|
+
fillLimits: "Isi batas yang kosong", useLimits: "Gunakan batas terdeteksi", tokenLimits: "Batas token",
|
|
82
|
+
limitsHelp: "Default konteks/output diambil dari metadata endpoint, lalu kecocokan ID persis tanpa konflik di katalog bawaan Pi. Batas gateway bisa berbeda. Nilai yang sudah diisi dipertahankan; simpan provider untuk menerapkan default.",
|
|
83
|
+
limitEndpoint: "Metadata endpoint", limitCatalog: "Referensi katalog Pi", limitConfigured: "Nilai tersimpan / custom", limitManual: "Nilai manual",
|
|
84
|
+
limitUnknown: "Maksimum belum diketahui · default Pi", limitAmbiguous: "Nilai katalog berbeda; maksimum belum diketahui", limitSuggestion: "Nilai terdeteksi",
|
|
85
|
+
limitsFilled: "Batas kosong yang diketahui diisi pada draft. Nilai lama dipertahankan. Simpan provider untuk menerapkan.",
|
|
86
|
+
limitsUnchanged: "Tidak ada batas kosong yang dapat diisi. Tarik model untuk metadata endpoint; maksimum yang belum diketahui memakai default Pi.",
|
|
87
|
+
limitsLookup: "Mencari di katalog Pi…", limitsLookupFailed: "Pencarian katalog gagal. Batas dapat diisi manual.",
|
|
88
|
+
limitsCaveat: "Referensi metadata, bukan maksimum yang diukur. Output juga dapat dibatasi sisa konteks dan gateway/akunmu.",
|
|
89
|
+
});
|
|
90
|
+
for (const [el, text] of original) translations.en[el.dataset.i18n] ??= text;
|
|
91
|
+
let language = localStorage.getItem("pi-manager-language") || "en";
|
|
92
|
+
let token = new URLSearchParams(location.hash.slice(1)).get("token") || sessionStorage.getItem("pi-manager-token") || "";
|
|
93
|
+
if (token) sessionStorage.setItem("pi-manager-token", token);
|
|
94
|
+
history.replaceState(null, "", location.pathname);
|
|
95
|
+
const state = { config: null, id: null, models: [], dirty: false, busy: false, readOnly: false, results: new Map() };
|
|
96
|
+
const capabilities = ["chat", "tools", "reasoning", "vision"];
|
|
97
|
+
let activeProbe;
|
|
98
|
+
let editedModel = null;
|
|
99
|
+
const limitFields = ["contextWindow", "maxTokens"];
|
|
100
|
+
const limitInputs = { contextWindow: "edit-context", maxTokens: "edit-max" };
|
|
101
|
+
const fallbackLimits = { contextWindow: 128000, maxTokens: 16384 };
|
|
102
|
+
let editingLimits;
|
|
103
|
+
let limitLookupTimer;
|
|
104
|
+
let limitLookupSequence = 0;
|
|
105
|
+
let submittingModel = false;
|
|
106
|
+
let previewTimer;
|
|
107
|
+
let previewSequence = 0;
|
|
108
|
+
let noticeTranslation;
|
|
109
|
+
const t = (key) => translations[language]?.[key] || translations.en[key] || key;
|
|
110
|
+
|
|
111
|
+
function translate() {
|
|
112
|
+
document.documentElement.lang = language;
|
|
113
|
+
$("language").value = language;
|
|
114
|
+
original.forEach((text, el) => { el.textContent = translations[language]?.[el.dataset.i18n] || text; });
|
|
115
|
+
$("model-search").placeholder = t("search");
|
|
116
|
+
$("key-help").textContent = t("keyHelp");
|
|
117
|
+
$("url-help").textContent = t($("api").value === "anthropic-messages" ? "anthropicHelp" : "openaiHelp");
|
|
118
|
+
$("editor-title").textContent = state.id || t("newProvider");
|
|
119
|
+
if (noticeTranslation) $("notice").textContent = t(noticeTranslation);
|
|
120
|
+
if (editingLimits) renderLimitEditor();
|
|
121
|
+
renderProviders(); renderModels();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function api(path, data, signal) {
|
|
125
|
+
const response = await fetch(`/api/${path}`, {
|
|
126
|
+
method: data === undefined ? "GET" : "POST", signal,
|
|
127
|
+
headers: { "X-Manager-Token": token, ...(data === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
128
|
+
...(data === undefined ? {} : { body: JSON.stringify(data) }),
|
|
129
|
+
});
|
|
130
|
+
const result = await response.json();
|
|
131
|
+
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function notify(message, kind = "success") {
|
|
136
|
+
noticeTranslation = Object.keys(translations[language] || {}).find((key) => t(key) === message);
|
|
137
|
+
$("notice").hidden = false; $("notice").className = kind; $("notice").textContent = message;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function operation(fn, diagnostics = false) {
|
|
141
|
+
if (state.busy) return;
|
|
142
|
+
state.busy = true;
|
|
143
|
+
setDisabled();
|
|
144
|
+
if (diagnostics) { $("diagnostics").hidden = false; $("diagnostics").className = "diagnostics"; $("diagnostics").textContent = t("busy"); }
|
|
145
|
+
try { await fn(); }
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (diagnostics) { $("diagnostics").className = "diagnostics error"; $("diagnostics").textContent = error.message; }
|
|
148
|
+
else notify(error.message, "error");
|
|
149
|
+
} finally { state.busy = false; setDisabled(); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function setDisabled() {
|
|
153
|
+
document.querySelectorAll("button").forEach((button) => { button.disabled = state.busy; });
|
|
154
|
+
$("connection-fields").disabled = state.busy || state.readOnly;
|
|
155
|
+
$("provider-id").disabled = state.busy || !!state.id || state.readOnly;
|
|
156
|
+
for (const id of ["test-connection", "fetch-models", "add-model", "fill-limits", "save-provider", "delete-provider", "remove-key"]) {
|
|
157
|
+
$(id).disabled = state.busy || state.readOnly;
|
|
158
|
+
}
|
|
159
|
+
document.querySelectorAll("#models-body button,#models-body input").forEach((el) => { el.disabled = state.busy || state.readOnly; });
|
|
160
|
+
$("select-all").disabled = state.busy || state.readOnly;
|
|
161
|
+
document.querySelectorAll("[data-probe-toggle]").forEach((button) => { button.disabled = false; });
|
|
162
|
+
document.querySelectorAll("[data-probe-cancel]").forEach((button) => { button.disabled = !activeProbe; });
|
|
163
|
+
if (editingLimits) $("use-model-limits").disabled = !limitFields.some((field) => editingLimits.hints[field]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderProviders() {
|
|
167
|
+
const list = $("provider-list"); list.replaceChildren();
|
|
168
|
+
const providers = state.config?.providers || [];
|
|
169
|
+
$("provider-count").textContent = providers.length;
|
|
170
|
+
if (!providers.length) { const p = document.createElement("p"); p.className = "hint"; p.textContent = t("noProviders"); list.append(p); }
|
|
171
|
+
for (const provider of providers) {
|
|
172
|
+
const button = document.createElement("button"); button.type = "button";
|
|
173
|
+
button.className = state.id === provider.id ? "active" : "";
|
|
174
|
+
const title = document.createElement("strong"); title.textContent = provider.id;
|
|
175
|
+
const detail = document.createElement("small"); detail.textContent = `${provider.models.length} models · ${provider.api || "Pi managed"}`;
|
|
176
|
+
button.append(title, detail);
|
|
177
|
+
button.onclick = () => { if (!state.dirty || confirm(t("unsaved"))) loadProvider(provider.id); };
|
|
178
|
+
list.append(button);
|
|
179
|
+
}
|
|
180
|
+
setDisabled();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function loadProvider(id = null, preserveResults = false) {
|
|
184
|
+
const previous = preserveResults ? new Map(state.models.map((model) => [model.id, model])) : new Map();
|
|
185
|
+
if (!preserveResults) state.results.clear();
|
|
186
|
+
const provider = state.config?.providers.find((p) => p.id === id);
|
|
187
|
+
state.id = provider?.id || null;
|
|
188
|
+
state.readOnly = provider?.readOnly || false;
|
|
189
|
+
state.models = (provider?.models || []).map((model) => {
|
|
190
|
+
const old = previous.get(model.id);
|
|
191
|
+
return { ...model, selected: true, limitHints: old?.limitHints || model.limitHints,
|
|
192
|
+
limitSources: Object.fromEntries(limitFields.filter((field) => model[field] !== undefined).map((field) => [field, "configured"])) };
|
|
193
|
+
});
|
|
194
|
+
state.dirty = false;
|
|
195
|
+
if (!state.readOnly && !preserveResults) for (const model of state.models) if (fillMissingLimits(model)) state.dirty = true;
|
|
196
|
+
$("provider-id").value = provider?.id || "";
|
|
197
|
+
$("base-url").value = provider?.baseUrl || "";
|
|
198
|
+
$("api").value = provider?.api || "openai-completions";
|
|
199
|
+
$("api-key").value = "";
|
|
200
|
+
$("auth-header").checked = provider?.authHeader || false;
|
|
201
|
+
const role = provider?.compat?.supportsDeveloperRole;
|
|
202
|
+
$("provider-system-role").value = typeof role === "boolean" ? role ? "developer" : "system" : "";
|
|
203
|
+
$("auth-source").textContent = provider?.authSource || "not configured";
|
|
204
|
+
$("remove-key").hidden = provider?.credentialType !== "api_key";
|
|
205
|
+
$("readonly-note").hidden = !state.readOnly;
|
|
206
|
+
$("advanced-note").hidden = !provider?.hasHiddenSettings;
|
|
207
|
+
$("delete-provider").hidden = !provider;
|
|
208
|
+
$("model-search").value = "";
|
|
209
|
+
$("diagnostics").hidden = true;
|
|
210
|
+
translate(); preview();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function draft(onlySelected = true) {
|
|
214
|
+
return {
|
|
215
|
+
id: $("provider-id").value.trim(), baseUrl: $("base-url").value.trim(), api: $("api").value,
|
|
216
|
+
apiKey: $("api-key").value.trim() || undefined, authHeader: $("auth-header").checked,
|
|
217
|
+
compat: { supportsDeveloperRole: $("provider-system-role").value ? $("provider-system-role").value === "developer" : null },
|
|
218
|
+
revision: state.config?.revision,
|
|
219
|
+
models: state.models.filter((m) => !onlySelected || m.selected).map(({ selected, limitHints, limitSources, ...model }) => model),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateConnection() {
|
|
224
|
+
for (const id of ["provider-id", "base-url"]) if (!$(id).reportValidity()) return false;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function preview() {
|
|
229
|
+
const sequence = ++previewSequence;
|
|
230
|
+
$("url-help").textContent = t($("api").value === "anthropic-messages" ? "anthropicHelp" : "openaiHelp");
|
|
231
|
+
if (!$("base-url").value.trim()) { $("models-url").textContent = "…"; $("chat-url").textContent = "…"; return; }
|
|
232
|
+
try {
|
|
233
|
+
const value = draft(); value.id ||= "preview"; value.apiKey = undefined;
|
|
234
|
+
const result = await api("preview", value);
|
|
235
|
+
if (sequence !== previewSequence) return;
|
|
236
|
+
$("models-url").textContent = result.models; $("chat-url").textContent = result.chat;
|
|
237
|
+
} catch { if (sequence === previewSequence) { $("models-url").textContent = "—"; $("chat-url").textContent = "—"; } }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function visibleModels() { const query = $("model-search").value.toLowerCase(); return state.models.filter((m) => `${m.id} ${m.name || ""}`.toLowerCase().includes(query)); }
|
|
241
|
+
|
|
242
|
+
function action(text, fn, className = "quiet") { const button = document.createElement("button"); button.type = "button"; button.textContent = text; button.className = className; button.onclick = fn; return button; }
|
|
243
|
+
|
|
244
|
+
function fillMissingLimits(model) {
|
|
245
|
+
let changed = false;
|
|
246
|
+
for (const field of limitFields) if (model[field] === undefined && model.limitHints?.[field]) {
|
|
247
|
+
model[field] = model.limitHints[field].value;
|
|
248
|
+
(model.limitSources ??= {})[field] = model.limitHints[field].source;
|
|
249
|
+
changed = true;
|
|
250
|
+
}
|
|
251
|
+
if (changed) state.results.delete(model.id);
|
|
252
|
+
return changed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function limitSourceText(source, hint) {
|
|
256
|
+
return t({ endpoint: "limitEndpoint", catalog: "limitCatalog", manual: "limitManual", configured: "limitConfigured" }[source] || "limitConfigured")
|
|
257
|
+
+ (source === "endpoint" && hint?.field ? ` · ${hint.field}` : "");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderLimitCell(model) {
|
|
261
|
+
const cell = element("td", "model-limits");
|
|
262
|
+
for (const field of limitFields) {
|
|
263
|
+
const hint = model.limitHints?.[field];
|
|
264
|
+
const value = model[field];
|
|
265
|
+
const block = element("div", "model-limit"); block.dataset.limit = field;
|
|
266
|
+
block.append(element("strong", "", `${t(field === "contextWindow" ? "context" : "maxOutput")}: ${(value ?? fallbackLimits[field]).toLocaleString()}`));
|
|
267
|
+
block.append(element("small", "", value !== undefined ? limitSourceText(model.limitSources?.[field], hint)
|
|
268
|
+
: model.limitHints?.ambiguous?.includes(field) ? t("limitAmbiguous") : t("limitUnknown")));
|
|
269
|
+
cell.append(block);
|
|
270
|
+
}
|
|
271
|
+
return cell;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderModels() {
|
|
275
|
+
const tbody = $("models-body"); tbody.replaceChildren();
|
|
276
|
+
const visible = visibleModels();
|
|
277
|
+
const count = state.models.filter((m) => m.selected).length;
|
|
278
|
+
$("model-count").textContent = state.models.length;
|
|
279
|
+
$("selected-count").textContent = `${count} ${t("selected")}`;
|
|
280
|
+
$("save-summary").textContent = `${count} ${t("selected")}${state.dirty ? " · ●" : ""}`;
|
|
281
|
+
$("empty-models").hidden = state.models.length > 0;
|
|
282
|
+
$("select-all").checked = !!visible.length && visible.every((m) => m.selected);
|
|
283
|
+
$("select-all").indeterminate = visible.some((m) => m.selected) && !$("select-all").checked;
|
|
284
|
+
// Bounded DOM rendering; searching and select-visible still operate on all results.
|
|
285
|
+
for (const model of visible.slice(0, 300)) {
|
|
286
|
+
const tr = document.createElement("tr"); tr.className = "model-row"; tr.dataset.modelId = model.id;
|
|
287
|
+
const checkCell = document.createElement("td");
|
|
288
|
+
const check = document.createElement("input"); check.type = "checkbox"; check.checked = model.selected;
|
|
289
|
+
check.setAttribute("aria-label", `Select ${model.id}`);
|
|
290
|
+
check.onchange = () => { model.selected = check.checked; state.dirty = true; renderModels(); };
|
|
291
|
+
checkCell.append(check);
|
|
292
|
+
const nameCell = document.createElement("td"); const name = document.createElement("code"); name.textContent = model.id; nameCell.append(name);
|
|
293
|
+
if (model.name) { const sub = document.createElement("small"); sub.textContent = model.name; nameCell.append(sub); }
|
|
294
|
+
const roleOverride = model.compat?.supportsDeveloperRole;
|
|
295
|
+
if (typeof roleOverride === "boolean" || $("provider-system-role").value) {
|
|
296
|
+
nameCell.append(element("small", "", typeof roleOverride === "boolean"
|
|
297
|
+
? `${t("modelRoleOverride")}: ${roleOverride ? "developer" : "system"}`
|
|
298
|
+
: `${t("inheritedRole")}: ${$("provider-system-role").value}`));
|
|
299
|
+
}
|
|
300
|
+
const context = renderLimitCell(model);
|
|
301
|
+
const input = document.createElement("td"); input.textContent = model.input?.includes("image") ? t("textImage") : t("textOnly");
|
|
302
|
+
input.dataset.label = t("input");
|
|
303
|
+
if (!model.input) { const label = document.createElement("small"); label.textContent = t("fallback"); input.append(label); }
|
|
304
|
+
const actions = document.createElement("td"); const row = document.createElement("div"); row.className = "action-row";
|
|
305
|
+
row.append(action(t("testChat"), () => testModel(model, ["chat"])), action(t("checkCapabilities"), () => testModel(model, capabilities)), action(t("edit"), () => openModel(model)));
|
|
306
|
+
if (state.id === state.config?.defaultProvider && model.id === state.config?.defaultModel) {
|
|
307
|
+
const badge = document.createElement("span"); badge.className = "default-badge"; badge.textContent = t("default"); row.append(badge);
|
|
308
|
+
} else row.append(action(t("setDefault"), () => setDefault(model)));
|
|
309
|
+
row.append(action("×", () => { if (confirm(t("removeModel"))) { state.models = state.models.filter((m) => m !== model); state.results.delete(model.id); state.dirty = true; renderModels(); } }, "text-danger"));
|
|
310
|
+
row.lastChild.setAttribute("aria-label", `${t("remove")} ${model.id}`);
|
|
311
|
+
actions.append(row); tr.append(checkCell, nameCell, context, input, actions); tbody.append(tr);
|
|
312
|
+
const result = state.results.get(model.id);
|
|
313
|
+
if (result) {
|
|
314
|
+
const summary = element("div", "probe-summary");
|
|
315
|
+
for (const capability of capabilities) if (result.checks[capability]) {
|
|
316
|
+
const outcome = result.checks[capability].outcome;
|
|
317
|
+
summary.append(element("span", `probe-badge ${outcome}`, `${t(capability)} · ${t(outcome)}`));
|
|
318
|
+
}
|
|
319
|
+
nameCell.append(summary);
|
|
320
|
+
const toggle = action(t(result.expanded ? "hideResults" : "showResults"), () => { result.expanded = !result.expanded; renderModels(); });
|
|
321
|
+
toggle.dataset.probeToggle = ""; toggle.setAttribute("aria-expanded", String(result.expanded)); row.append(toggle);
|
|
322
|
+
if (result.expanded) tbody.append(renderProbeResults(model, result));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (visible.length > 300) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = 5; td.textContent = `300 / ${visible.length} ${t("matches")} — ${t("search")}`; tr.append(td); tbody.append(tr); }
|
|
326
|
+
setDisabled();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function diagnostics(result, summary) {
|
|
330
|
+
$("diagnostics").hidden = false; $("diagnostics").className = `diagnostics${result.warning ? " warning" : ""}`;
|
|
331
|
+
$("diagnostics").textContent = `${summary}\nHTTP ${result.status} · ${result.durationMs} ms${result.pages ? ` · ${result.pages} page(s)` : ""}\n${result.url}\n${result.warning || result.message || (result.complete ? t("allFetched") : "")}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function element(tag, className, text) {
|
|
335
|
+
const el = document.createElement(tag); el.className = className;
|
|
336
|
+
if (text !== undefined) el.textContent = text;
|
|
337
|
+
return el;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function renderProbeResults(model, result) {
|
|
341
|
+
const tr = element("tr", "model-results"); tr.dataset.resultModel = model.id;
|
|
342
|
+
const td = document.createElement("td"); td.colSpan = 5;
|
|
343
|
+
const panel = element("section", "probe-panel"); panel.setAttribute("aria-label", `${t("testResults")}: ${model.id}`);
|
|
344
|
+
const header = element("div", "probe-header");
|
|
345
|
+
const title = element("div", ""); title.append(element("h3", "", t("testResults")), element("code", "", model.id));
|
|
346
|
+
const progress = element("span", "probe-progress", t(result.running ? "testingModel" : result.stopped ? "testsStopped" : "testsFinished"));
|
|
347
|
+
progress.setAttribute("role", "status");
|
|
348
|
+
if (result.running) {
|
|
349
|
+
const done = Object.values(result.checks).filter((check) => check.outcome !== "running" && check.outcome !== "queued").length;
|
|
350
|
+
progress.textContent += ` · ${done}/${Object.keys(result.checks).length}`;
|
|
351
|
+
}
|
|
352
|
+
header.append(title, progress);
|
|
353
|
+
if (result.running) {
|
|
354
|
+
const cancel = action(t("stopTests"), () => activeProbe?.abort()); cancel.dataset.probeCancel = ""; header.append(cancel);
|
|
355
|
+
}
|
|
356
|
+
panel.append(header);
|
|
357
|
+
const grid = element("div", "probe-grid");
|
|
358
|
+
for (const capability of capabilities) {
|
|
359
|
+
const check = result.checks[capability]; const outcome = check?.outcome || "untested";
|
|
360
|
+
const card = element("div", `probe-check ${outcome}`); card.dataset.capability = capability; card.dataset.outcome = outcome;
|
|
361
|
+
const heading = element("div", "probe-check-heading");
|
|
362
|
+
heading.append(element("strong", "", t(capability)), element("span", `probe-badge ${outcome}`, t(outcome)));
|
|
363
|
+
card.append(heading);
|
|
364
|
+
if (check?.code || check?.error) card.append(element("p", "", check.error || t(check.code)));
|
|
365
|
+
if (check?.code === "developer_role_unsupported") card.append(action(t("fixSystemRole"), () => { openModel(model); $("edit-system-role").focus(); }));
|
|
366
|
+
if (check?.durationMs !== undefined) {
|
|
367
|
+
card.append(element("p", "probe-meta", `${check.status ? `HTTP ${check.status} · ` : ""}${check.durationMs.toLocaleString()} ms · ${check.requests} ${t("requests")}`));
|
|
368
|
+
card.append(element("p", "probe-meta", `${check.inputTokens} / ${check.outputTokens} ${t("tokenUsage")}`));
|
|
369
|
+
card.append(element("code", "probe-url", check.url));
|
|
370
|
+
}
|
|
371
|
+
grid.append(card);
|
|
372
|
+
}
|
|
373
|
+
panel.append(grid, element("p", "hint", t("probeScope")));
|
|
374
|
+
if (result.applied) panel.append(element("p", "probe-applied", t("verifiedApplied")));
|
|
375
|
+
else if (result.checks.vision?.outcome === "supported" || result.checks.reasoning?.outcome === "supported") {
|
|
376
|
+
panel.append(action(t("applyVerified"), () => {
|
|
377
|
+
if (result.checks.vision?.outcome === "supported") model.input = ["text", "image"];
|
|
378
|
+
if (result.checks.reasoning?.outcome === "supported") model.reasoning = true;
|
|
379
|
+
state.dirty = true; result.applied = true; renderModels();
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
td.append(panel); tr.append(td); return tr;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function openModel(model = null) {
|
|
386
|
+
clearTimeout(limitLookupTimer); limitLookupSequence++;
|
|
387
|
+
editedModel = model;
|
|
388
|
+
editingLimits = { id: model?.id || "", hints: model?.limitHints || {}, sources: { ...model?.limitSources }, touched: new Set(), status: "" };
|
|
389
|
+
$("edit-model-id").value = model?.id || ""; $("edit-model-id").disabled = !!model;
|
|
390
|
+
$("edit-model-name").value = model?.name || "";
|
|
391
|
+
$("edit-context").value = model?.contextWindow || ""; $("edit-max").value = model?.maxTokens || "";
|
|
392
|
+
$("edit-vision").value = model?.input === undefined ? "" : model.input.includes("image") ? "yes" : "no";
|
|
393
|
+
$("edit-reasoning").value = model?.reasoning === undefined ? "" : model.reasoning ? "yes" : "no";
|
|
394
|
+
const developerRole = model?.compat?.supportsDeveloperRole;
|
|
395
|
+
$("edit-system-role").value = typeof developerRole === "boolean" ? developerRole ? "developer" : "system" : "";
|
|
396
|
+
renderLimitEditor();
|
|
397
|
+
$("model-dialog").showModal();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function renderLimitEditor() {
|
|
401
|
+
for (const field of limitFields) {
|
|
402
|
+
const input = $(limitInputs[field]);
|
|
403
|
+
const hint = editingLimits.hints[field];
|
|
404
|
+
input.placeholder = `${t("fallback")}: ${fallbackLimits[field]}`;
|
|
405
|
+
const source = editingLimits.sources[field];
|
|
406
|
+
let text = input.value ? limitSourceText(source, hint) : editingLimits.hints.ambiguous?.includes(field) ? t("limitAmbiguous") : t("limitUnknown");
|
|
407
|
+
if (hint && (!input.value || Number(input.value) !== hint.value || !["endpoint", "catalog"].includes(source))) {
|
|
408
|
+
text += ` · ${t("limitSuggestion")}: ${hint.value.toLocaleString()} (${limitSourceText(hint.source, hint)})`;
|
|
409
|
+
}
|
|
410
|
+
$(`${limitInputs[field]}-source`).textContent = text;
|
|
411
|
+
}
|
|
412
|
+
$("use-model-limits").disabled = !limitFields.some((field) => editingLimits.hints[field]);
|
|
413
|
+
$("limit-lookup-status").textContent = t(editingLimits.status || "limitsCaveat");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function lookupEditorLimits() {
|
|
417
|
+
const id = $("edit-model-id").value.trim();
|
|
418
|
+
const sequence = ++limitLookupSequence;
|
|
419
|
+
if (!id) return;
|
|
420
|
+
editingLimits.status = "limitsLookup"; renderLimitEditor();
|
|
421
|
+
try {
|
|
422
|
+
const hints = await api("model-limits", { modelId: id });
|
|
423
|
+
if (sequence !== limitLookupSequence || !$("model-dialog").open || id !== $("edit-model-id").value.trim()) return;
|
|
424
|
+
editingLimits.hints = hints; editingLimits.status = "";
|
|
425
|
+
for (const field of limitFields) if (!editingLimits.touched.has(field) && hints[field]) {
|
|
426
|
+
$(limitInputs[field]).value = hints[field].value;
|
|
427
|
+
editingLimits.sources[field] = hints[field].source;
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
if (sequence !== limitLookupSequence) return;
|
|
431
|
+
editingLimits.status = "limitsLookupFailed";
|
|
432
|
+
}
|
|
433
|
+
renderLimitEditor();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
$("edit-model-id").addEventListener("input", () => {
|
|
437
|
+
clearTimeout(limitLookupTimer); limitLookupSequence++;
|
|
438
|
+
editingLimits.id = $("edit-model-id").value.trim(); editingLimits.hints = {}; editingLimits.status = "";
|
|
439
|
+
for (const field of limitFields) if (!editingLimits.touched.has(field)) { $(limitInputs[field]).value = ""; delete editingLimits.sources[field]; }
|
|
440
|
+
renderLimitEditor(); limitLookupTimer = setTimeout(lookupEditorLimits, 200);
|
|
441
|
+
});
|
|
442
|
+
for (const field of limitFields) $(limitInputs[field]).addEventListener("input", () => {
|
|
443
|
+
editingLimits.touched.add(field); editingLimits.sources[field] = "manual"; renderLimitEditor();
|
|
444
|
+
});
|
|
445
|
+
$("use-model-limits").onclick = () => {
|
|
446
|
+
for (const field of limitFields) if (editingLimits.hints[field]) {
|
|
447
|
+
$(limitInputs[field]).value = editingLimits.hints[field].value;
|
|
448
|
+
editingLimits.sources[field] = editingLimits.hints[field].source;
|
|
449
|
+
editingLimits.touched.delete(field);
|
|
450
|
+
}
|
|
451
|
+
renderLimitEditor();
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
$("model-form").onsubmit = async (event) => {
|
|
455
|
+
event.preventDefault();
|
|
456
|
+
if (submittingModel) return;
|
|
457
|
+
const id = $("edit-model-id").value.trim();
|
|
458
|
+
if (!id) return;
|
|
459
|
+
if (!editedModel && state.models.some((m) => m.id === id)) { alert(t("duplicate")); return; }
|
|
460
|
+
if (!editedModel && editingLimits.id === id && !limitFields.some((field) => editingLimits.hints[field])) {
|
|
461
|
+
const editor = editingLimits;
|
|
462
|
+
submittingModel = true;
|
|
463
|
+
try { clearTimeout(limitLookupTimer); await lookupEditorLimits(); } finally { submittingModel = false; }
|
|
464
|
+
if (editor !== editingLimits || !$("model-dialog").open || id !== $("edit-model-id").value.trim()) return;
|
|
465
|
+
}
|
|
466
|
+
const model = { id, selected: editedModel?.selected ?? true, limitHints: editingLimits.hints, limitSources: { ...editingLimits.sources } };
|
|
467
|
+
if ($("edit-model-name").value.trim()) model.name = $("edit-model-name").value.trim();
|
|
468
|
+
if ($("edit-context").value) model.contextWindow = Number($("edit-context").value);
|
|
469
|
+
if ($("edit-max").value) model.maxTokens = Number($("edit-max").value);
|
|
470
|
+
if ($("edit-vision").value) model.input = $("edit-vision").value === "yes" ? ["text", "image"] : ["text"];
|
|
471
|
+
if ($("edit-reasoning").value) model.reasoning = $("edit-reasoning").value === "yes";
|
|
472
|
+
const role = $("edit-system-role").value;
|
|
473
|
+
if (role || editedModel?.compat?.supportsDeveloperRole !== undefined) model.compat = { supportsDeveloperRole: role ? role === "developer" : null };
|
|
474
|
+
if (editedModel) state.models[state.models.indexOf(editedModel)] = model; else state.models.push(model);
|
|
475
|
+
state.results.delete(model.id);
|
|
476
|
+
limitLookupSequence++; state.dirty = true; $("model-dialog").close(); renderModels();
|
|
477
|
+
};
|
|
478
|
+
$("close-dialog").onclick = () => $("model-dialog").close();
|
|
479
|
+
$("model-dialog").addEventListener("close", () => { clearTimeout(limitLookupTimer); limitLookupSequence++; });
|
|
480
|
+
$("add-model").onclick = () => openModel();
|
|
481
|
+
$("new-provider").onclick = () => { if (!state.dirty || confirm(t("unsaved"))) loadProvider(); };
|
|
482
|
+
$("model-search").oninput = renderModels;
|
|
483
|
+
$("select-all").onchange = () => { visibleModels().forEach((m) => { m.selected = $("select-all").checked; }); state.dirty = true; renderModels(); };
|
|
484
|
+
$("language").onchange = () => { language = $("language").value; localStorage.setItem("pi-manager-language", language); translate(); };
|
|
485
|
+
for (const id of ["provider-id", "base-url", "api", "api-key", "auth-header", "provider-system-role"]) {
|
|
486
|
+
$(id).addEventListener("input", () => {
|
|
487
|
+
if (["provider-id", "base-url", "api", "api-key"].includes(id)) for (const model of state.models) for (const field of limitFields) {
|
|
488
|
+
if (model.limitHints?.[field]?.source === "endpoint") delete model.limitHints[field];
|
|
489
|
+
if (model.limitSources?.[field] === "endpoint") { delete model[field]; delete model.limitSources[field]; }
|
|
490
|
+
}
|
|
491
|
+
state.dirty = true; state.results.clear(); renderModels(); clearTimeout(previewTimer); previewTimer = setTimeout(preview, 200);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
$("fill-limits").onclick = () => {
|
|
495
|
+
let changed = false;
|
|
496
|
+
for (const model of state.models) if (fillMissingLimits(model)) changed = true;
|
|
497
|
+
state.dirty ||= changed; renderModels(); notify(t(changed ? "limitsFilled" : "limitsUnchanged"));
|
|
498
|
+
};
|
|
499
|
+
$("test-connection").onclick = () => {
|
|
500
|
+
if (!validateConnection()) return;
|
|
501
|
+
operation(async () => { const result = await api("connection", draft(false)); diagnostics(result, t("connectionOk")); }, true);
|
|
502
|
+
};
|
|
503
|
+
$("fetch-models").onclick = () => {
|
|
504
|
+
if (!validateConnection()) return;
|
|
505
|
+
operation(async () => {
|
|
506
|
+
const result = await api("discover", draft(false));
|
|
507
|
+
const known = new Map(state.models.map((m) => [m.id, m]));
|
|
508
|
+
for (const model of result.models) {
|
|
509
|
+
const existing = known.get(model.id);
|
|
510
|
+
if (existing) {
|
|
511
|
+
// Endpoint metadata supersedes an unsaved catalog default, never a saved/manual value.
|
|
512
|
+
for (const field of limitFields) if (["catalog", "endpoint"].includes(existing.limitSources?.[field]) && model.limitHints?.[field]) {
|
|
513
|
+
if (existing[field] !== model[field]) { state.results.delete(existing.id); state.dirty = true; }
|
|
514
|
+
existing[field] = model[field]; existing.limitSources[field] = model.limitHints[field].source;
|
|
515
|
+
}
|
|
516
|
+
existing.limitHints = model.limitHints;
|
|
517
|
+
if (fillMissingLimits(existing)) state.dirty = true;
|
|
518
|
+
} else {
|
|
519
|
+
const added = { ...model, selected: false, limitSources: Object.fromEntries(limitFields.filter((field) => model.limitHints?.[field]).map((field) => [field, model.limitHints[field].source])) };
|
|
520
|
+
state.models.push(added); known.set(model.id, added);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
diagnostics(result, `${t("fetchDone")}: ${result.models.length}`); renderModels();
|
|
524
|
+
}, true);
|
|
525
|
+
};
|
|
526
|
+
async function testModel(model, checks) {
|
|
527
|
+
if (state.busy || !validateConnection()) return;
|
|
528
|
+
const provider = draft(false);
|
|
529
|
+
const result = state.results.get(model.id) || { checks: {} };
|
|
530
|
+
Object.assign(result, { expanded: true, running: true, stopped: false, applied: false });
|
|
531
|
+
for (const capability of checks) result.checks[capability] = { outcome: "queued" };
|
|
532
|
+
state.results.set(model.id, result); state.busy = true; activeProbe = new AbortController();
|
|
533
|
+
renderModels();
|
|
534
|
+
const panel = [...document.querySelectorAll(".model-results")].find((el) => el.dataset.resultModel === model.id);
|
|
535
|
+
panel?.scrollIntoView({ block: "nearest" });
|
|
536
|
+
try {
|
|
537
|
+
for (const capability of checks) {
|
|
538
|
+
if (activeProbe.signal.aborted) break;
|
|
539
|
+
result.checks[capability] = { outcome: "running" }; renderModels();
|
|
540
|
+
try {
|
|
541
|
+
result.checks[capability] = await api("test-model", { provider, modelId: model.id, capability }, activeProbe.signal);
|
|
542
|
+
if (["auth_failed", "rate_limited", "redirect", "connection_failed", "developer_role_unsupported", "gateway_error"].includes(result.checks[capability].code)) {
|
|
543
|
+
result.stopped = true; break;
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
result.checks[capability] = activeProbe.signal.aborted ? { outcome: "inconclusive", code: "cancelled" } : { outcome: "failed", error: error.message };
|
|
547
|
+
result.stopped = true; break;
|
|
548
|
+
}
|
|
549
|
+
renderModels();
|
|
550
|
+
}
|
|
551
|
+
} finally {
|
|
552
|
+
result.stopped ||= activeProbe.signal.aborted;
|
|
553
|
+
for (const capability of checks) if (result.checks[capability].outcome === "queued") delete result.checks[capability];
|
|
554
|
+
result.running = false; state.busy = false; activeProbe = undefined; renderModels();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async function setDefault(model) {
|
|
558
|
+
if (!state.id || state.dirty || !model.selected) { notify(t("saveFirst"), "warning"); return; }
|
|
559
|
+
await operation(async () => { const result = await api("default", { id: state.id, modelId: model.id }); state.config = result.state; renderModels(); notify(t("defaultSaved")); });
|
|
560
|
+
}
|
|
561
|
+
$("provider-form").onsubmit = (event) => {
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
operation(async () => {
|
|
564
|
+
const value = draft(); const result = await api("save", value);
|
|
565
|
+
state.config = result.state; loadProvider(value.id, true); notify(result.warning || t("saved"), result.warning ? "warning" : "success");
|
|
566
|
+
});
|
|
567
|
+
};
|
|
568
|
+
$("delete-provider").onclick = () => {
|
|
569
|
+
if (!state.id || !confirm(t("confirmDelete"))) return;
|
|
570
|
+
operation(async () => { const result = await api("delete", { id: state.id, revision: state.config.revision }); state.config = result.state; loadProvider(); notify(result.message); });
|
|
571
|
+
};
|
|
572
|
+
$("remove-key").onclick = () => {
|
|
573
|
+
if (state.dirty && !confirm(t("unsaved"))) return;
|
|
574
|
+
if (!state.id || !confirm(t("confirmKey"))) return;
|
|
575
|
+
operation(async () => { const result = await api("remove-key", { id: state.id }); state.config = result.state; loadProvider(state.id); notify(t("keyRemoved")); });
|
|
576
|
+
};
|
|
577
|
+
$("refresh-state").onclick = () => {
|
|
578
|
+
if (state.dirty && !confirm(t("unsaved"))) return;
|
|
579
|
+
operation(async () => { state.config = await api("state"); loadProvider(state.id); notify(t("refreshDone")); });
|
|
580
|
+
};
|
|
581
|
+
window.addEventListener("beforeunload", (event) => { if (state.dirty) { event.preventDefault(); event.returnValue = ""; } });
|
|
582
|
+
|
|
583
|
+
translate();
|
|
584
|
+
if (!token) notify(t("missingToken"), "error");
|
|
585
|
+
else operation(async () => {
|
|
586
|
+
state.config = await api("state");
|
|
587
|
+
$("config-path").textContent = state.config.dir; $("version").textContent = `Pi ${state.config.piVersion} · Manager 0.1.0`;
|
|
588
|
+
loadProvider(state.config.providers.find((p) => !p.readOnly)?.id || null);
|
|
589
|
+
});
|