pi-custom-provider-model 0.1.1 → 0.1.3
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/README.md +26 -5
- package/docs/design.md +3 -2
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/server.ts +2 -1
- package/src/service.ts +44 -4
- package/src/storage.ts +24 -1
- package/src/vision-fallback.ts +88 -0
- package/web/app.js +47 -7
- package/web/index.html +11 -1
- package/web/style.css +10 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A small, local browser panel for configuring custom providers in [Pi](https://pi.dev). English and Indonesian UI. Built against the official **Pi 0.85.1** public extension and SDK APIs.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Published on npm as [`pi-custom-provider-model`](https://www.npmjs.com/package/pi-custom-provider-model). Development uses a local path install; tagged releases are published through GitHub Actions.
|
|
6
6
|
|
|
7
7
|
## What works
|
|
8
8
|
|
|
@@ -16,12 +16,25 @@ A small, local browser panel for configuring custom providers in [Pi](https://pi
|
|
|
16
16
|
- Save provider/model definitions to `models.json`, API keys through Pi's native `auth.json` handling, and startup defaults to `settings.json`.
|
|
17
17
|
- Preserve existing unknown settings/model overrides and JSON comments when editing; detect stale writes and retain the last 10 `models.json` backups.
|
|
18
18
|
- Show credential source without returning saved keys or headers to the browser.
|
|
19
|
+
- Optionally route images through an authenticated vision-capable fallback model while keeping the selected Pi model responsible for the main reasoning and tool loop.
|
|
19
20
|
|
|
20
|
-
##
|
|
21
|
+
## Install
|
|
21
22
|
|
|
22
|
-
Requires Node **22.19+** and Pi **0.85.1+**. Compatibility is currently tested with 0.85.1 on Windows/Edge
|
|
23
|
+
Requires Node **22.19+** and Pi **0.85.1+**. Compatibility is currently tested with 0.85.1 on Windows/Edge.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
For normal use:
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
pi install npm:pi-custom-provider-model
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
To update later:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
pi update npm:pi-custom-provider-model
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For a one-off local development run from this project directory:
|
|
25
38
|
|
|
26
39
|
```powershell
|
|
27
40
|
npm install --ignore-scripts
|
|
@@ -81,6 +94,12 @@ The extension uses Pi's `getAgentDir()`, including `PI_CODING_AGENT_DIR`. It doe
|
|
|
81
94
|
6. Use **Test chat** for a quick streaming check, or **Check capabilities** for chat, tools, reasoning and image input. Progress and results appear directly under that model; **Stop tests** cancels the remaining work.
|
|
82
95
|
7. Click **Save provider**, then select it in Pi's `/model` picker. **Set default** applies to future Pi launches.
|
|
83
96
|
|
|
97
|
+
### Vision fallback
|
|
98
|
+
|
|
99
|
+
Choose an authenticated image-capable model under **Vision fallback** and click **Save fallback**. When the active Pi model is text-only, the extension sends attached user images and images returned by tools to that fallback model for description. It then gives the description—not the image—to the active model, which remains selected and performs the main reasoning and tool loop.
|
|
100
|
+
|
|
101
|
+
Fallback is bypassed when the active model already declares image input. If fallback analysis fails, a direct image prompt is stopped rather than silently dropping the image; a tool image is replaced with an explicit failure note. The fallback makes an additional model request and may incur provider usage/cost. Configuration is stored separately in `<agentDir>/pi-custom-provider.json`; native `models.json`, `auth.json`, and `settings.json` formats are not extended.
|
|
102
|
+
|
|
84
103
|
### Base URL examples
|
|
85
104
|
|
|
86
105
|
| Protocol | Base URL | Model list | Chat |
|
|
@@ -151,6 +170,8 @@ Provider-scoped API keys use Pi's public `ModelRuntime.login()` / `logout()` and
|
|
|
151
170
|
|
|
152
171
|
Replacing a credential with provider-scoped `env` settings is currently refused to avoid losing that advanced state through Pi's login replacement behavior. Use Pi's native auth configuration for those entries. Deleting a provider retains its key; remove the key first if desired. Removing an `auth.json` key does not remove a fallback key from `models.json` or the environment.
|
|
153
172
|
|
|
173
|
+
Vision fallback selection lives in `<agentDir>/pi-custom-provider.json`. The file contains only provider/model IDs, never credentials.
|
|
174
|
+
|
|
154
175
|
Backups live in `<agentDir>/provider-manager-backups/`. To restore, stop the panel and copy the desired backup over `models.json`, then reopen `/model`. Backups can contain pre-existing secrets from models.json and are created with owner-only permissions where supported. Only `models.json` is backed up; authentication and defaults use Pi's own writers. A partial save reports which operation needs attention.
|
|
155
176
|
|
|
156
177
|
## Development and verification
|
|
@@ -168,7 +189,7 @@ npm run pack:check
|
|
|
168
189
|
|
|
169
190
|
Tests use isolated temporary directories and mock endpoints. Real gateway compatibility and RAM footprint have not been benchmarked yet.
|
|
170
191
|
|
|
171
|
-
See [`docs/design.md`](docs/design.md) for the version-pinned official documentation and
|
|
192
|
+
See [`docs/design.md`](docs/design.md) for the version-pinned official documentation and release design. Tagged releases are published to npm as `pi-custom-provider-model` through GitHub Actions.
|
|
172
193
|
|
|
173
194
|
## License
|
|
174
195
|
|
package/docs/design.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# v0.1 design and reference map
|
|
2
2
|
|
|
3
|
-
Status:
|
|
3
|
+
Status: published as `pi-custom-provider-model`. Pi compatibility baseline: 0.85.1. Tagged releases are published through GitHub Actions.
|
|
4
4
|
|
|
5
5
|
## Official Pi sources (version pinned)
|
|
6
6
|
|
|
@@ -32,6 +32,7 @@ API discovery complements Pi's documentation with [OpenAI Models](https://develo
|
|
|
32
32
|
- API keys go through the public ModelRuntime login/logout interface using its native authPath-backed credential store. This keeps key interpolation and locking in Pi itself. No imports of private AuthStorage internals. API-key replacement follows Pi's /login semantics; unrelated providers and OAuth entries are preserved.
|
|
33
33
|
- All configuration updates merge into the latest document under lock, check revisions, keep unknown fields/comments, and back up models.json. Conflicting external edits return a conflict instead of replacing the latest version. Native OAuth entries remain managed by Pi /login.
|
|
34
34
|
- Existing built-in provider overrides are displayed read-only in v0.1. Manage custom provider definitions here.
|
|
35
|
+
- Optional vision fallback is extension-owned configuration in `<agentDir>/pi-custom-provider.json`, not a non-native model/settings field. For a text-only active model, user images are transformed into a fallback model's textual description before the main agent starts; images from tool results are similarly replaced and nested usage is attached to that tool result. The active model remains selected. Native vision models bypass fallback, and direct prompts fail closed instead of silently dropping images when fallback fails.
|
|
35
36
|
|
|
36
37
|
## Components
|
|
37
38
|
|
|
@@ -51,4 +52,4 @@ Bind to 127.0.0.1 on a random port. A session token in the URL fragment is excha
|
|
|
51
52
|
|
|
52
53
|
## Publication checklist
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
Run `npm run verify` and `npm run pack:check`, update the committed package version, then push the matching `v*` tag. GitHub Actions validates the tag/version match and publishes to npm. Include the prebuilt web assets (there is no frontend build). Gallery indexing uses npm's `pi-package` keyword; listing timing is outside this project's control.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { getAgentDir, VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { startManager } from "./server.ts";
|
|
4
|
+
import { transformToolImages, transformUserImages } from "./vision-fallback.ts";
|
|
4
5
|
|
|
5
6
|
function openBrowser(url: string) {
|
|
6
7
|
const [program, args] = process.platform === "win32"
|
|
@@ -35,6 +36,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
35
36
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
36
37
|
if (pendingModelSync) await syncActiveModel(ctx);
|
|
37
38
|
});
|
|
39
|
+
pi.on("input", async (event, ctx) => transformUserImages(getAgentDir(), event, ctx));
|
|
40
|
+
pi.on("tool_result", async (event, ctx) => transformToolImages(getAgentDir(), event, ctx));
|
|
38
41
|
pi.registerCommand("custom-provider", {
|
|
39
42
|
description: "Manage custom providers, API keys and models in a local browser",
|
|
40
43
|
handler: async (args, ctx) => {
|
package/src/server.ts
CHANGED
|
@@ -63,7 +63,7 @@ export async function startManager(dir: string, onSaved?: () => Promise<void>) {
|
|
|
63
63
|
const controller = new AbortController();
|
|
64
64
|
response.on("close", () => { if (!response.writableEnded) controller.abort(); });
|
|
65
65
|
const signal = AbortSignal.any([controller.signal, lifecycle.signal]);
|
|
66
|
-
const writes = ["/api/save", "/api/delete", "/api/remove-key", "/api/default"];
|
|
66
|
+
const writes = ["/api/save", "/api/delete", "/api/remove-key", "/api/default", "/api/vision-fallback"];
|
|
67
67
|
const isWrite = writes.includes(path);
|
|
68
68
|
if (isWrite && mutationPending) throw new AppError("Another save is in progress. Try again.", 409);
|
|
69
69
|
if (isWrite) mutationPending = true;
|
|
@@ -79,6 +79,7 @@ export async function startManager(dir: string, onSaved?: () => Promise<void>) {
|
|
|
79
79
|
case "/api/delete": result = await service.remove(input.id, input.revision); break;
|
|
80
80
|
case "/api/remove-key": result = await service.removeKey(input.id); break;
|
|
81
81
|
case "/api/default": result = await service.setDefault(input.id, input.modelId); break;
|
|
82
|
+
case "/api/vision-fallback": result = await service.setVisionFallback(input.provider, input.modelId, input.revision); break;
|
|
82
83
|
default: throw new AppError("Not found.", 404);
|
|
83
84
|
}
|
|
84
85
|
json(response, 200, result);
|
package/src/service.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { ModelRuntime, readStoredCredential, SettingsManager, VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { ConfigStore, parseDocument, patch, readText } from "./storage.ts";
|
|
5
|
+
import { ConfigStore, ExtensionConfigStore, parseDocument, patch, readText } from "./storage.ts";
|
|
4
6
|
import { discover, endpointUrls, requestHeaders } from "./discovery.ts";
|
|
5
7
|
import { CAPABILITIES, probeModel, type Capability } from "./probes.ts";
|
|
6
8
|
import { LimitCatalog, LIMIT_FIELDS } from "./limits.ts";
|
|
7
9
|
import { APIS, AppError, mergeCompat, validateDraft, validateId, type JsonObject, type ModelInput, type ProviderDraft } from "./types.ts";
|
|
8
10
|
|
|
11
|
+
const MANAGER_VERSION = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version as string;
|
|
12
|
+
|
|
9
13
|
function visibleCompat(entry: JsonObject): ModelInput["compat"] {
|
|
10
14
|
return typeof entry.compat?.supportsDeveloperRole === "boolean" ? { supportsDeveloperRole: entry.compat.supportsDeveloperRole } : undefined;
|
|
11
15
|
}
|
|
@@ -20,9 +24,13 @@ function visibleModel(model: JsonObject): ModelInput {
|
|
|
20
24
|
|
|
21
25
|
export class ProviderService {
|
|
22
26
|
readonly store: ConfigStore;
|
|
27
|
+
readonly extensionStore: ExtensionConfigStore;
|
|
23
28
|
private reserved: Set<string> = new Set();
|
|
24
29
|
private limitCatalog = new LimitCatalog();
|
|
25
|
-
constructor(readonly dir: string, private onSaved?: () => Promise<void>) {
|
|
30
|
+
constructor(readonly dir: string, private onSaved?: () => Promise<void>) {
|
|
31
|
+
this.store = new ConfigStore(dir);
|
|
32
|
+
this.extensionStore = new ExtensionConfigStore(dir);
|
|
33
|
+
}
|
|
26
34
|
|
|
27
35
|
private async runtime() {
|
|
28
36
|
return ModelRuntime.create({ authPath: join(this.dir, "auth.json"), modelsPath: this.store.path, refreshOnCreate: false });
|
|
@@ -35,7 +43,13 @@ export class ProviderService {
|
|
|
35
43
|
|
|
36
44
|
async state() {
|
|
37
45
|
const current = await this.store.read();
|
|
46
|
+
const extension = await this.extensionStore.read();
|
|
38
47
|
const settings = parseDocument(await readText(join(this.dir, "settings.json")));
|
|
48
|
+
const runtime = await this.runtime();
|
|
49
|
+
const available = await runtime.getAvailable();
|
|
50
|
+
const visionModels = available.filter((model) => model.input.includes("image")).map((model) => ({
|
|
51
|
+
provider: model.provider, id: model.id, name: model.name,
|
|
52
|
+
})).sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
|
|
39
53
|
const providers = Object.entries(current.data.providers).map(([id, entry]) => {
|
|
40
54
|
const p = entry as JsonObject;
|
|
41
55
|
const credential = readStoredCredential(id, join(this.dir, "auth.json"));
|
|
@@ -50,8 +64,9 @@ export class ProviderService {
|
|
|
50
64
|
};
|
|
51
65
|
});
|
|
52
66
|
return {
|
|
53
|
-
dir: this.dir, piVersion: VERSION, revision: current.revision, providers,
|
|
67
|
+
dir: this.dir, piVersion: VERSION, managerVersion: MANAGER_VERSION, revision: current.revision, providers,
|
|
54
68
|
defaultProvider: settings.defaultProvider ?? "", defaultModel: settings.defaultModel ?? "",
|
|
69
|
+
visionFallback: extension.data.visionFallback ?? null, visionFallbackRevision: extension.revision, visionModels,
|
|
55
70
|
reservedIds: [...this.reserved],
|
|
56
71
|
};
|
|
57
72
|
}
|
|
@@ -175,7 +190,9 @@ export class ProviderService {
|
|
|
175
190
|
const current = await this.store.read();
|
|
176
191
|
this.assertEditable(id, current.data.providers[id]);
|
|
177
192
|
const settings = parseDocument(await readText(join(this.dir, "settings.json")));
|
|
178
|
-
if (settings.defaultProvider === id)
|
|
193
|
+
if (settings.defaultProvider === id) {
|
|
194
|
+
throw new AppError("This provider is Pi's default and cannot be deleted yet. Open another provider, choose Set default on one of its models, then try again.");
|
|
195
|
+
}
|
|
179
196
|
await this.store.update(expected, (text) => patch(text, ["providers", id], undefined));
|
|
180
197
|
await this.onSaved?.();
|
|
181
198
|
return { ok: true, message: "Provider removed. Its credentials remain in auth.json; remove them separately if needed.", state: await this.state() };
|
|
@@ -191,6 +208,29 @@ export class ProviderService {
|
|
|
191
208
|
return { ok: true, state: await this.state() };
|
|
192
209
|
}
|
|
193
210
|
|
|
211
|
+
async setVisionFallback(providerInput: unknown, modelInput: unknown, expected: unknown) {
|
|
212
|
+
if (typeof expected !== "string") throw new AppError("Reload fallback configuration before saving.", 409);
|
|
213
|
+
const disabled = (providerInput === "" || providerInput === null || providerInput === undefined)
|
|
214
|
+
&& (modelInput === "" || modelInput === null || modelInput === undefined);
|
|
215
|
+
let value: { provider: string; model: string } | undefined;
|
|
216
|
+
if (!disabled) {
|
|
217
|
+
if (typeof providerInput !== "string" || !providerInput.trim() || providerInput.length > 100
|
|
218
|
+
|| typeof modelInput !== "string" || !modelInput.trim() || modelInput.length > 300) {
|
|
219
|
+
throw new AppError("Choose a valid fallback vision model.");
|
|
220
|
+
}
|
|
221
|
+
const provider = providerInput.trim(); const modelId = modelInput.trim();
|
|
222
|
+
const runtime = await this.runtime();
|
|
223
|
+
const model = runtime.getModel(provider, modelId);
|
|
224
|
+
const available = await runtime.getAvailable(provider);
|
|
225
|
+
if (!model || !model.input.includes("image") || !available.some((entry) => entry.id === modelId)) {
|
|
226
|
+
throw new AppError("The fallback model must support images and have configured credentials.");
|
|
227
|
+
}
|
|
228
|
+
value = { provider, model: modelId };
|
|
229
|
+
}
|
|
230
|
+
await this.extensionStore.saveVisionFallback(expected, value);
|
|
231
|
+
return { ok: true, state: await this.state() };
|
|
232
|
+
}
|
|
233
|
+
|
|
194
234
|
async setDefault(idInput: unknown, modelId: unknown) {
|
|
195
235
|
const id = validateId(idInput);
|
|
196
236
|
const runtime = await this.runtime();
|
package/src/storage.ts
CHANGED
|
@@ -33,7 +33,7 @@ export async function readText(path: string, fallback = "{}\n"): Promise<string>
|
|
|
33
33
|
catch (error: any) { if (error.code === "ENOENT") return fallback; throw error; }
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
36
|
+
export async function atomicWrite(path: string, content: string): Promise<void> {
|
|
37
37
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
38
38
|
try {
|
|
39
39
|
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
@@ -41,6 +41,29 @@ async function atomicWrite(path: string, content: string): Promise<void> {
|
|
|
41
41
|
} finally { await unlink(temporary).catch(() => {}); }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export class ExtensionConfigStore {
|
|
45
|
+
readonly path: string;
|
|
46
|
+
constructor(readonly dir: string) { this.path = join(dir, "pi-custom-provider.json"); }
|
|
47
|
+
|
|
48
|
+
async read() {
|
|
49
|
+
const text = await readText(this.path, "{}\n");
|
|
50
|
+
return { text, data: parseDocument(text), revision: revision(text) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async saveVisionFallback(expected: string, value: { provider: string; model: string } | undefined) {
|
|
54
|
+
await mkdir(this.dir, { recursive: true });
|
|
55
|
+
const release = await lockfile.lock(this.path, { realpath: false, retries: { retries: 20, minTimeout: 20, maxTimeout: 100 } });
|
|
56
|
+
try {
|
|
57
|
+
const current = await this.read();
|
|
58
|
+
if (current.revision !== expected) throw new AppError("Fallback configuration changed in another window. Reload before saving.", 409);
|
|
59
|
+
const next = patch(current.text, ["visionFallback"], value);
|
|
60
|
+
parseDocument(next);
|
|
61
|
+
if (next !== current.text) await atomicWrite(this.path, next);
|
|
62
|
+
return revision(next);
|
|
63
|
+
} finally { await release(); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
export class ConfigStore {
|
|
45
68
|
readonly path: string;
|
|
46
69
|
constructor(readonly dir: string) { this.path = join(dir, "models.json"); }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ExtensionContext, InputEventResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { parseDocument, readText } from "./storage.ts";
|
|
5
|
+
|
|
6
|
+
export interface VisionFallbackConfig { provider: string; model: string }
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT = `You are a visual inspection component for a coding agent. Describe every supplied image accurately and concretely. Preserve visible text, errors, labels, UI layout, charts, code, file contents, spatial relationships, and details relevant to the user's request. Do not solve the broader task or claim actions. Return only a concise but sufficiently detailed description.`;
|
|
9
|
+
|
|
10
|
+
export async function readVisionFallback(dir: string): Promise<VisionFallbackConfig | undefined> {
|
|
11
|
+
const data = parseDocument(await readText(join(dir, "pi-custom-provider.json")));
|
|
12
|
+
const value = data.visionFallback;
|
|
13
|
+
if (!value || typeof value.provider !== "string" || typeof value.model !== "string") return;
|
|
14
|
+
if (!value.provider.trim() || !value.model.trim()) return;
|
|
15
|
+
return { provider: value.provider, model: value.model };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function textOf(message: AssistantMessage): string {
|
|
19
|
+
return message.content.filter((block): block is TextContent => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sumUsage(items: Usage[]): Usage {
|
|
23
|
+
const total: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
24
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
25
|
+
for (const usage of items) {
|
|
26
|
+
total.input += usage.input; total.output += usage.output; total.cacheRead += usage.cacheRead; total.cacheWrite += usage.cacheWrite;
|
|
27
|
+
total.totalTokens += usage.totalTokens; total.cost.input += usage.cost.input; total.cost.output += usage.cost.output;
|
|
28
|
+
total.cost.cacheRead += usage.cost.cacheRead; total.cost.cacheWrite += usage.cost.cacheWrite; total.cost.total += usage.cost.total;
|
|
29
|
+
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
|
|
30
|
+
if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
|
|
31
|
+
}
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertFallbackModel(ctx: ExtensionContext, config: VisionFallbackConfig): Model<any> {
|
|
36
|
+
const model = ctx.modelRegistry.find(config.provider, config.model);
|
|
37
|
+
if (!model) throw new Error(`Vision fallback model ${config.provider}/${config.model} is unavailable.`);
|
|
38
|
+
if (!model.input.includes("image")) throw new Error(`Vision fallback model ${config.provider}/${config.model} is not configured for image input.`);
|
|
39
|
+
return model;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function describeImages(ctx: ExtensionContext, config: VisionFallbackConfig, images: ImageContent[], request: string, signal?: AbortSignal) {
|
|
43
|
+
const model = assertFallbackModel(ctx, config);
|
|
44
|
+
const timeout = AbortSignal.any([AbortSignal.timeout(60000), ...(signal ? [signal] : [])]);
|
|
45
|
+
const response = await ctx.modelRegistry.complete(model, { systemPrompt: SYSTEM_PROMPT, messages: [{ role: "user", timestamp: Date.now(), content: [
|
|
46
|
+
{ type: "text", text: `User request/context: ${request || "No additional text."}\nDescribe the attached image(s) for another model that cannot see them. Distinguish multiple images by order.` },
|
|
47
|
+
...images,
|
|
48
|
+
] }] }, { signal: timeout, maxTokens: Math.min(model.maxTokens, 2048), cacheRetention: "none", maxRetries: 0, timeoutMs: 60000 });
|
|
49
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") throw new Error("The fallback vision model could not analyze the image.");
|
|
50
|
+
const description = textOf(response);
|
|
51
|
+
if (!description) throw new Error("The fallback vision model returned no image description.");
|
|
52
|
+
return { description, usage: response.usage, model: `${model.provider}/${model.id}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function transformUserImages(dir: string, event: { text: string; images?: ImageContent[] }, ctx: ExtensionContext): Promise<InputEventResult | void> {
|
|
56
|
+
if (!event.images?.length || !ctx.model || ctx.model.input.includes("image")) return;
|
|
57
|
+
const config = await readVisionFallback(dir);
|
|
58
|
+
if (!config) return;
|
|
59
|
+
try {
|
|
60
|
+
ctx.ui.setWorkingMessage(`Analyzing image with ${config.provider}/${config.model}…`);
|
|
61
|
+
const result = await describeImages(ctx, config, event.images, event.text, ctx.signal);
|
|
62
|
+
ctx.ui.notify(`Image analyzed with ${result.model}; continuing with ${ctx.model.provider}/${ctx.model.id}.`, "info");
|
|
63
|
+
return { action: "transform", text: `${event.text}\n\n[Vision fallback analysis from ${result.model}]\n${result.description}`, images: [] };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : "Vision fallback failed."} The prompt was stopped so the image is not silently omitted.`, "error");
|
|
66
|
+
return { action: "handled" };
|
|
67
|
+
} finally { ctx.ui.setWorkingMessage(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function transformToolImages(dir: string, event: { content: (TextContent | ImageContent)[]; usage?: Usage }, ctx: ExtensionContext): Promise<{ content?: (TextContent | ImageContent)[]; usage?: Usage } | void> {
|
|
71
|
+
const images = event.content.filter((block): block is ImageContent => block.type === "image");
|
|
72
|
+
if (!images.length || !ctx.model || ctx.model.input.includes("image")) return;
|
|
73
|
+
const config = await readVisionFallback(dir);
|
|
74
|
+
if (!config) return;
|
|
75
|
+
const originalText = event.content.filter((block): block is TextContent => block.type === "text").map((block) => block.text).join("\n");
|
|
76
|
+
try {
|
|
77
|
+
const result = await describeImages(ctx, config, images, originalText || "Image returned by a tool.", ctx.signal);
|
|
78
|
+
return { content: [
|
|
79
|
+
...event.content.filter((block): block is TextContent => block.type === "text"),
|
|
80
|
+
{ type: "text", text: `[Vision fallback analysis from ${result.model}]\n${result.description}` },
|
|
81
|
+
], usage: sumUsage([...(event.usage ? [event.usage] : []), result.usage]) };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { content: [
|
|
84
|
+
...event.content.filter((block): block is TextContent => block.type === "text"),
|
|
85
|
+
{ type: "text", text: `[Vision fallback failed: ${error instanceof Error ? error.message : "unknown error"}]` },
|
|
86
|
+
] };
|
|
87
|
+
}
|
|
88
|
+
}
|
package/web/app.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
const $ = (id) => document.getElementById(id);
|
|
2
2
|
const translations = {
|
|
3
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",
|
|
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",visionFallback:"Fallback vision",globalSetting:"Pengaturan global",visionFallbackHelp:"Ketika model Pi yang aktif tidak dapat menerima gambar, model ini akan mendeskripsikan gambar dari pengguna dan tool terlebih dahulu. Model aktif tetap dipilih dan melakukan reasoning utama.",visionFallbackModel:"Model vision fallback",visionFallbackOff:"Nonaktif",visionFallbackCredentialHelp:"Hanya model dengan dukungan gambar dan kredensial yang terkonfigurasi yang ditampilkan.",saveVisionFallback:"Simpan fallback",visionFallbackSaved:"Pengaturan vision fallback tersimpan.",connection:"Koneksi",native:"Format Pi",readonly:"Provider ini dikelola Pi atau integrasi lain. Buat provider custom untuk mengedit di sini.",defaultProviderBadge:"Default Pi",defaultProviderNote:"Provider ini adalah default Pi dan belum dapat dihapus. Untuk menghapusnya, buka provider lain lalu pilih ‘Jadikan default’ pada salah satu modelnya.",defaultActionHelp:"‘Jadikan default’ pada Aksi model menetapkan model dan provider tersebut sebagai default Pi untuk pembukaan berikutnya.",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
5
|
},
|
|
6
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",
|
|
7
|
+
visionFallback:"Vision fallback",globalSetting:"Global setting",visionFallbackHelp:"When the active Pi model cannot accept images, this model describes user and tool images first. The active model remains selected and performs the main reasoning.",visionFallbackModel:"Fallback vision model",visionFallbackOff:"Disabled",visionFallbackCredentialHelp:"Only image-capable models with configured credentials are listed.",saveVisionFallback:"Save fallback",visionFallbackSaved:"Vision fallback setting saved.",newProvider:"New provider",selected:"selected",edit:"Edit",test:"Test",setDefault:"Set default",default:"Default",defaultProviderBadge:"Pi default",defaultProviderNote:"This provider is Pi's default and cannot be deleted yet. To delete it, open another provider and choose ‘Set default’ on one of its models.",defaultActionHelp:"‘Set default’ in a model's Actions makes that model and provider Pi's default for new launches.",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
8
|
},
|
|
9
9
|
};
|
|
10
10
|
const original = new Map([...document.querySelectorAll("[data-i18n]")].map((el) => [el, el.textContent]));
|
|
@@ -118,7 +118,7 @@ function translate() {
|
|
|
118
118
|
$("editor-title").textContent = state.id || t("newProvider");
|
|
119
119
|
if (noticeTranslation) $("notice").textContent = t(noticeTranslation);
|
|
120
120
|
if (editingLimits) renderLimitEditor();
|
|
121
|
-
renderProviders(); renderModels();
|
|
121
|
+
renderVisionFallback(); renderProviders(); renderDefaultProviderNote(); renderModels();
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
async function api(path, data, signal) {
|
|
@@ -151,6 +151,7 @@ async function operation(fn, diagnostics = false) {
|
|
|
151
151
|
|
|
152
152
|
function setDisabled() {
|
|
153
153
|
document.querySelectorAll("button").forEach((button) => { button.disabled = state.busy; });
|
|
154
|
+
$("vision-fallback").disabled = state.busy;
|
|
154
155
|
$("connection-fields").disabled = state.busy || state.readOnly;
|
|
155
156
|
$("provider-id").disabled = state.busy || !!state.id || state.readOnly;
|
|
156
157
|
for (const id of ["test-connection", "fetch-models", "add-model", "fill-limits", "save-provider", "delete-provider", "remove-key"]) {
|
|
@@ -163,6 +164,19 @@ function setDisabled() {
|
|
|
163
164
|
if (editingLimits) $("use-model-limits").disabled = !limitFields.some((field) => editingLimits.hints[field]);
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
function renderVisionFallback() {
|
|
168
|
+
const select = $("vision-fallback");
|
|
169
|
+
const selected = state.config?.visionFallback;
|
|
170
|
+
select.replaceChildren();
|
|
171
|
+
const off = document.createElement("option"); off.value = ""; off.textContent = t("visionFallbackOff"); select.append(off);
|
|
172
|
+
for (const model of state.config?.visionModels || []) {
|
|
173
|
+
const option = document.createElement("option"); option.value = `${model.provider}\n${model.id}`;
|
|
174
|
+
option.textContent = `${model.provider} / ${model.id}${model.name && model.name !== model.id ? ` — ${model.name}` : ""}`;
|
|
175
|
+
select.append(option);
|
|
176
|
+
}
|
|
177
|
+
select.value = selected ? `${selected.provider}\n${selected.model}` : "";
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
function renderProviders() {
|
|
167
181
|
const list = $("provider-list"); list.replaceChildren();
|
|
168
182
|
const providers = state.config?.providers || [];
|
|
@@ -173,13 +187,25 @@ function renderProviders() {
|
|
|
173
187
|
button.className = state.id === provider.id ? "active" : "";
|
|
174
188
|
const title = document.createElement("strong"); title.textContent = provider.id;
|
|
175
189
|
const detail = document.createElement("small"); detail.textContent = `${provider.models.length} models · ${provider.api || "Pi managed"}`;
|
|
176
|
-
button.append(title
|
|
190
|
+
button.append(title);
|
|
191
|
+
if (provider.id === state.config?.defaultProvider) {
|
|
192
|
+
const badge = document.createElement("span"); badge.className = "provider-default-badge"; badge.textContent = t("defaultProviderBadge"); button.append(badge);
|
|
193
|
+
}
|
|
194
|
+
button.append(detail);
|
|
177
195
|
button.onclick = () => { if (!state.dirty || confirm(t("unsaved"))) loadProvider(provider.id); };
|
|
178
196
|
list.append(button);
|
|
179
197
|
}
|
|
180
198
|
setDisabled();
|
|
181
199
|
}
|
|
182
200
|
|
|
201
|
+
function renderDefaultProviderNote() {
|
|
202
|
+
const isDefault = !!state.id && state.id === state.config?.defaultProvider;
|
|
203
|
+
const note = $("default-provider-note");
|
|
204
|
+
note.hidden = !isDefault;
|
|
205
|
+
note.textContent = isDefault ? t("defaultProviderNote") : "";
|
|
206
|
+
$("delete-provider").classList.toggle("delete-default-provider", isDefault);
|
|
207
|
+
}
|
|
208
|
+
|
|
183
209
|
function loadProvider(id = null, preserveResults = false) {
|
|
184
210
|
const previous = preserveResults ? new Map(state.models.map((model) => [model.id, model])) : new Map();
|
|
185
211
|
if (!preserveResults) state.results.clear();
|
|
@@ -205,6 +231,7 @@ function loadProvider(id = null, preserveResults = false) {
|
|
|
205
231
|
$("readonly-note").hidden = !state.readOnly;
|
|
206
232
|
$("advanced-note").hidden = !provider?.hasHiddenSettings;
|
|
207
233
|
$("delete-provider").hidden = !provider;
|
|
234
|
+
renderDefaultProviderNote();
|
|
208
235
|
$("model-search").value = "";
|
|
209
236
|
$("diagnostics").hidden = true;
|
|
210
237
|
translate(); preview();
|
|
@@ -556,7 +583,7 @@ async function testModel(model, checks) {
|
|
|
556
583
|
}
|
|
557
584
|
async function setDefault(model) {
|
|
558
585
|
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")); });
|
|
586
|
+
await operation(async () => { const result = await api("default", { id: state.id, modelId: model.id }); state.config = result.state; renderProviders(); renderDefaultProviderNote(); renderModels(); notify(t("defaultSaved")); });
|
|
560
587
|
}
|
|
561
588
|
$("provider-form").onsubmit = (event) => {
|
|
562
589
|
event.preventDefault();
|
|
@@ -566,7 +593,13 @@ $("provider-form").onsubmit = (event) => {
|
|
|
566
593
|
});
|
|
567
594
|
};
|
|
568
595
|
$("delete-provider").onclick = () => {
|
|
569
|
-
if (!state.id
|
|
596
|
+
if (!state.id) return;
|
|
597
|
+
if (state.id === state.config?.defaultProvider) {
|
|
598
|
+
notify(t("defaultProviderNote"), "warning");
|
|
599
|
+
$("default-provider-note").scrollIntoView({ behavior: "smooth", block: "center" });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!confirm(t("confirmDelete"))) return;
|
|
570
603
|
operation(async () => { const result = await api("delete", { id: state.id, revision: state.config.revision }); state.config = result.state; loadProvider(); notify(result.message); });
|
|
571
604
|
};
|
|
572
605
|
$("remove-key").onclick = () => {
|
|
@@ -574,6 +607,13 @@ $("remove-key").onclick = () => {
|
|
|
574
607
|
if (!state.id || !confirm(t("confirmKey"))) return;
|
|
575
608
|
operation(async () => { const result = await api("remove-key", { id: state.id }); state.config = result.state; loadProvider(state.id); notify(t("keyRemoved")); });
|
|
576
609
|
};
|
|
610
|
+
$("save-vision-fallback").onclick = () => {
|
|
611
|
+
const [provider = "", modelId = ""] = $("vision-fallback").value.split("\n");
|
|
612
|
+
operation(async () => {
|
|
613
|
+
const result = await api("vision-fallback", { provider, modelId, revision: state.config.visionFallbackRevision });
|
|
614
|
+
state.config = result.state; renderVisionFallback(); notify(t("visionFallbackSaved"));
|
|
615
|
+
});
|
|
616
|
+
};
|
|
577
617
|
$("refresh-state").onclick = () => {
|
|
578
618
|
if (state.dirty && !confirm(t("unsaved"))) return;
|
|
579
619
|
operation(async () => { state.config = await api("state"); loadProvider(state.id); notify(t("refreshDone")); });
|
|
@@ -584,6 +624,6 @@ translate();
|
|
|
584
624
|
if (!token) notify(t("missingToken"), "error");
|
|
585
625
|
else operation(async () => {
|
|
586
626
|
state.config = await api("state");
|
|
587
|
-
$("config-path").textContent = state.config.dir; $("version").textContent = `Pi ${state.config.piVersion} · Manager
|
|
627
|
+
$("config-path").textContent = state.config.dir; $("version").textContent = `Pi ${state.config.piVersion} · Manager ${state.config.managerVersion}`;
|
|
588
628
|
loadProvider(state.config.providers.find((p) => !p.readOnly)?.id || null);
|
|
589
629
|
});
|
package/web/index.html
CHANGED
|
@@ -23,10 +23,19 @@
|
|
|
23
23
|
<main>
|
|
24
24
|
<div class="page-heading"><div><span class="eyebrow" data-i18n="connections">CONNECTIONS</span><h1 id="editor-title">New provider</h1><p data-i18n="subtitle">Your endpoint. Your models. Native Pi configuration.</p></div><button id="refresh-state" class="quiet" data-i18n="refresh">Reload configuration</button></div>
|
|
25
25
|
<div id="notice" role="status" aria-live="polite" hidden></div>
|
|
26
|
+
<section class="card vision-fallback-card">
|
|
27
|
+
<div class="section-heading"><div><span class="step">00</span><h2 data-i18n="visionFallback">Vision fallback</h2></div><span class="tag" data-i18n="globalSetting">Global setting</span></div>
|
|
28
|
+
<p class="hint" data-i18n="visionFallbackHelp">When the active Pi model cannot accept images, this model describes user and tool images first. The active model remains selected and performs the main reasoning.</p>
|
|
29
|
+
<div class="grid fallback-grid spaced">
|
|
30
|
+
<label><span data-i18n="visionFallbackModel">Fallback vision model</span><select id="vision-fallback"><option value="" data-i18n="visionFallbackOff">Disabled</option></select><small data-i18n="visionFallbackCredentialHelp">Only image-capable models with configured credentials are listed.</small></label>
|
|
31
|
+
<div class="fallback-action"><button id="save-vision-fallback" type="button" class="primary" data-i18n="saveVisionFallback">Save fallback</button></div>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
26
34
|
<form id="provider-form">
|
|
27
35
|
<section class="card">
|
|
28
36
|
<div class="section-heading"><div><span class="step">01</span><h2 data-i18n="connection">Connection</h2></div><span class="tag" data-i18n="native">Pi native</span></div>
|
|
29
37
|
<div id="readonly-note" class="info" hidden data-i18n="readonly">This provider is managed by Pi or another integration. Create a custom provider to edit here.</div>
|
|
38
|
+
<div id="default-provider-note" class="warning" role="status" hidden></div>
|
|
30
39
|
<fieldset id="connection-fields">
|
|
31
40
|
<div class="grid two">
|
|
32
41
|
<label><span data-i18n="providerId">Provider ID</span><input id="provider-id" required maxlength="80" placeholder="my-gateway" autocomplete="off" spellcheck="false"><small data-i18n="idHelp">A unique name used by Pi, e.g. my-gateway.</small></label>
|
|
@@ -48,13 +57,14 @@
|
|
|
48
57
|
<section class="card">
|
|
49
58
|
<div class="section-heading"><div><span class="step">02</span><h2 data-i18n="models">Models</h2><span id="model-count" class="count">0</span></div><div class="action-row"><button id="fill-limits" type="button" class="quiet" data-i18n="fillLimits">Fill missing limits</button><button id="add-model" type="button" class="quiet" data-i18n="addModel">+ Manual model</button></div></div>
|
|
50
59
|
<p class="hint" data-i18n="modelHint">Select models to save. A listed ID does not guarantee chat, vision or tool support.</p>
|
|
60
|
+
<p class="hint" data-i18n="defaultActionHelp">“Set default” in a model's Actions makes that model and provider Pi's default for new launches.</p>
|
|
51
61
|
<p class="hint" data-i18n="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.</p>
|
|
52
62
|
<p class="hint" data-i18n="probeHelp">Test chat sends one short request. Check capabilities runs chat, a two-turn test tool, reasoning and a generated image (up to 5 requests). Uses API tokens: up to 256 output tokens per request, 2,048 for reasoning. Results appear below each model.</p>
|
|
53
63
|
<div class="model-toolbar"><input id="model-search" type="search" placeholder="Search model IDs…" aria-label="Search models"><label class="checkbox-line"><input id="select-all" type="checkbox"><span data-i18n="selectAll">Select visible</span></label><span id="selected-count" class="hint">0 selected</span></div>
|
|
54
64
|
<div class="table-scroll"><table><thead><tr><th class="check-col"></th><th data-i18n="modelId">Model ID</th><th data-i18n="tokenLimits">Token limits</th><th data-i18n="input">Input</th><th data-i18n="actions">Actions</th></tr></thead><tbody id="models-body"></tbody></table></div>
|
|
55
65
|
<div id="empty-models" class="empty"><span class="empty-icon">◇</span><h3 data-i18n="noModels">Connect your first model</h3><p data-i18n="noModelsHint">Fetch models from your endpoint, or add a model ID manually.</p></div>
|
|
56
66
|
</section>
|
|
57
|
-
<div class="save-bar"><div><strong id="save-summary"></strong><small data-i18n="saveHelp">Models → models.json · Key → auth.json</small></div><div class="action-row"><button id="delete-provider" type="button" class="text-danger" hidden data-i18n="deleteProvider">Delete provider</button><button id="save-provider" type="submit" class="primary" data-i18n="save">Save provider</button></div></div>
|
|
67
|
+
<div class="save-bar"><div><strong id="save-summary"></strong><small data-i18n="saveHelp">Models → models.json · Key → auth.json</small></div><div class="action-row"><button id="delete-provider" type="button" class="text-danger" hidden aria-describedby="default-provider-note" data-i18n="deleteProvider">Delete provider</button><button id="save-provider" type="submit" class="primary" data-i18n="save">Save provider</button></div></div>
|
|
58
68
|
</form>
|
|
59
69
|
<p class="footnote" data-i18n="footnote">Configuration is saved locally. Open /model in Pi to select a saved model. “Set default” applies to new Pi launches.</p>
|
|
60
70
|
</main>
|
package/web/style.css
CHANGED
|
@@ -93,6 +93,7 @@ nav button { text-align: left; border-color: transparent; background: transparen
|
|
|
93
93
|
nav button.active { background: var(--soft); color: var(--accent); border-color: #c9e0d8; }
|
|
94
94
|
nav button strong { display: block; overflow-wrap: anywhere; }
|
|
95
95
|
nav button small { display: block; color: var(--muted); margin-top: 6px; overflow-wrap: anywhere; line-height: 1.6; }
|
|
96
|
+
.provider-default-badge { display: inline-block; margin-top: 8px; border-radius: 6px; background: #e4f4eb; color: #21634e; padding: 4px 9px; font-weight: 600; }
|
|
96
97
|
|
|
97
98
|
.sidebar-footer {
|
|
98
99
|
margin-top: auto;
|
|
@@ -162,6 +163,9 @@ label small, .hint { color: var(--muted); font-weight: 400; line-height: 1.65; }
|
|
|
162
163
|
.endpoint-preview b { color: var(--accent); }
|
|
163
164
|
code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1rem; }
|
|
164
165
|
.auth-grid { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
|
|
166
|
+
.fallback-grid { grid-template-columns: minmax(0, 1fr) auto; align-items: end; }
|
|
167
|
+
.fallback-action { padding-bottom: 1px; }
|
|
168
|
+
.fallback-action button { min-width: 160px; }
|
|
165
169
|
.auth-status { display: flex; flex-direction: column; gap: 12px; border-left: 1px solid var(--line); padding: 2px 0 0 24px; align-items: flex-start; overflow-wrap: anywhere; }
|
|
166
170
|
.auth-status button { padding: 4px 0; }
|
|
167
171
|
.checkbox-line { flex-direction: row; align-items: flex-start; gap: 12px; font-weight: 400; line-height: 1.6; }
|
|
@@ -214,7 +218,7 @@ td button { padding: 9px 10px; }
|
|
|
214
218
|
.save-bar small { display: block; color: var(--muted); margin-top: 8px; }
|
|
215
219
|
.footnote { color: var(--muted); line-height: 1.7; margin-top: 24px; }
|
|
216
220
|
|
|
217
|
-
.diagnostics, #notice, .info {
|
|
221
|
+
.diagnostics, #notice, .info, .warning {
|
|
218
222
|
padding: 16px 18px;
|
|
219
223
|
border-radius: 8px;
|
|
220
224
|
line-height: 1.7;
|
|
@@ -226,8 +230,9 @@ td button { padding: 9px 10px; }
|
|
|
226
230
|
}
|
|
227
231
|
|
|
228
232
|
.diagnostics.error, #notice.error { background: #fff1ef; color: #9f3e3e; }
|
|
229
|
-
#notice, .info { margin: 0 0 24px; }
|
|
230
|
-
.diagnostics.warning, #notice.warning { background: #fff6df; color: #
|
|
233
|
+
#notice, .info, .warning { margin: 0 0 24px; }
|
|
234
|
+
.diagnostics.warning, #notice.warning, .warning { background: #fff6df; color: #765718; }
|
|
235
|
+
.delete-default-provider { border: 1px solid #e8caca; padding: 8px 12px; }
|
|
231
236
|
|
|
232
237
|
dialog {
|
|
233
238
|
border: 1px solid var(--line);
|
|
@@ -250,7 +255,8 @@ dialog .hint { margin: 24px 0; }
|
|
|
250
255
|
main { padding: 28px 24px; }
|
|
251
256
|
.page-heading { align-items: flex-start; flex-direction: column; }
|
|
252
257
|
.card { padding: 24px; }
|
|
253
|
-
.two, .auth-grid { grid-template-columns: minmax(0, 1fr); }
|
|
258
|
+
.two, .auth-grid, .fallback-grid { grid-template-columns: minmax(0, 1fr); }
|
|
259
|
+
.fallback-action button { width: 100%; }
|
|
254
260
|
.auth-status { border-left: 0; padding-left: 0; }
|
|
255
261
|
.probe-grid { grid-template-columns: minmax(0, 1fr); }
|
|
256
262
|
}
|