pi-custom-provider-model 0.1.2 → 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 +9 -0
- 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 +36 -2
- package/src/storage.ts +24 -1
- package/src/vision-fallback.ts +88 -0
- package/web/app.js +24 -3
- package/web/index.html +8 -0
- package/web/style.css +5 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Published on npm as [`pi-custom-provider-model`](https://www.npmjs.com/package/p
|
|
|
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
|
|
|
@@ -93,6 +94,12 @@ The extension uses Pi's `getAgentDir()`, including `PI_CODING_AGENT_DIR`. It doe
|
|
|
93
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.
|
|
94
95
|
7. Click **Save provider**, then select it in Pi's `/model` picker. **Set default** applies to future Pi launches.
|
|
95
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
|
+
|
|
96
103
|
### Base URL examples
|
|
97
104
|
|
|
98
105
|
| Protocol | Base URL | Model list | Chat |
|
|
@@ -163,6 +170,8 @@ Provider-scoped API keys use Pi's public `ModelRuntime.login()` / `logout()` and
|
|
|
163
170
|
|
|
164
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.
|
|
165
172
|
|
|
173
|
+
Vision fallback selection lives in `<agentDir>/pi-custom-provider.json`. The file contains only provider/model IDs, never credentials.
|
|
174
|
+
|
|
166
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.
|
|
167
176
|
|
|
168
177
|
## Development and verification
|
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
|
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { ModelRuntime, readStoredCredential, SettingsManager, VERSION } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { ConfigStore, parseDocument, patch, readText } from "./storage.ts";
|
|
5
|
+
import { ConfigStore, ExtensionConfigStore, parseDocument, patch, readText } from "./storage.ts";
|
|
6
6
|
import { discover, endpointUrls, requestHeaders } from "./discovery.ts";
|
|
7
7
|
import { CAPABILITIES, probeModel, type Capability } from "./probes.ts";
|
|
8
8
|
import { LimitCatalog, LIMIT_FIELDS } from "./limits.ts";
|
|
@@ -24,9 +24,13 @@ function visibleModel(model: JsonObject): ModelInput {
|
|
|
24
24
|
|
|
25
25
|
export class ProviderService {
|
|
26
26
|
readonly store: ConfigStore;
|
|
27
|
+
readonly extensionStore: ExtensionConfigStore;
|
|
27
28
|
private reserved: Set<string> = new Set();
|
|
28
29
|
private limitCatalog = new LimitCatalog();
|
|
29
|
-
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
|
+
}
|
|
30
34
|
|
|
31
35
|
private async runtime() {
|
|
32
36
|
return ModelRuntime.create({ authPath: join(this.dir, "auth.json"), modelsPath: this.store.path, refreshOnCreate: false });
|
|
@@ -39,7 +43,13 @@ export class ProviderService {
|
|
|
39
43
|
|
|
40
44
|
async state() {
|
|
41
45
|
const current = await this.store.read();
|
|
46
|
+
const extension = await this.extensionStore.read();
|
|
42
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}`));
|
|
43
53
|
const providers = Object.entries(current.data.providers).map(([id, entry]) => {
|
|
44
54
|
const p = entry as JsonObject;
|
|
45
55
|
const credential = readStoredCredential(id, join(this.dir, "auth.json"));
|
|
@@ -56,6 +66,7 @@ export class ProviderService {
|
|
|
56
66
|
return {
|
|
57
67
|
dir: this.dir, piVersion: VERSION, managerVersion: MANAGER_VERSION, revision: current.revision, providers,
|
|
58
68
|
defaultProvider: settings.defaultProvider ?? "", defaultModel: settings.defaultModel ?? "",
|
|
69
|
+
visionFallback: extension.data.visionFallback ?? null, visionFallbackRevision: extension.revision, visionModels,
|
|
59
70
|
reservedIds: [...this.reserved],
|
|
60
71
|
};
|
|
61
72
|
}
|
|
@@ -197,6 +208,29 @@ export class ProviderService {
|
|
|
197
208
|
return { ok: true, state: await this.state() };
|
|
198
209
|
}
|
|
199
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
|
+
|
|
200
234
|
async setDefault(idInput: unknown, modelId: unknown) {
|
|
201
235
|
const id = validateId(idInput);
|
|
202
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.",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",
|
|
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",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",
|
|
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(); renderDefaultProviderNote(); 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 || [];
|
|
@@ -593,6 +607,13 @@ $("remove-key").onclick = () => {
|
|
|
593
607
|
if (!state.id || !confirm(t("confirmKey"))) return;
|
|
594
608
|
operation(async () => { const result = await api("remove-key", { id: state.id }); state.config = result.state; loadProvider(state.id); notify(t("keyRemoved")); });
|
|
595
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
|
+
};
|
|
596
617
|
$("refresh-state").onclick = () => {
|
|
597
618
|
if (state.dirty && !confirm(t("unsaved"))) return;
|
|
598
619
|
operation(async () => { state.config = await api("state"); loadProvider(state.id); notify(t("refreshDone")); });
|
package/web/index.html
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
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>
|
package/web/style.css
CHANGED
|
@@ -163,6 +163,9 @@ label small, .hint { color: var(--muted); font-weight: 400; line-height: 1.65; }
|
|
|
163
163
|
.endpoint-preview b { color: var(--accent); }
|
|
164
164
|
code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1rem; }
|
|
165
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; }
|
|
166
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; }
|
|
167
170
|
.auth-status button { padding: 4px 0; }
|
|
168
171
|
.checkbox-line { flex-direction: row; align-items: flex-start; gap: 12px; font-weight: 400; line-height: 1.6; }
|
|
@@ -252,7 +255,8 @@ dialog .hint { margin: 24px 0; }
|
|
|
252
255
|
main { padding: 28px 24px; }
|
|
253
256
|
.page-heading { align-items: flex-start; flex-direction: column; }
|
|
254
257
|
.card { padding: 24px; }
|
|
255
|
-
.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%; }
|
|
256
260
|
.auth-status { border-left: 0; padding-left: 0; }
|
|
257
261
|
.probe-grid { grid-template-columns: minmax(0, 1fr); }
|
|
258
262
|
}
|