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 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
- **v0.1.0 local prototype.** npm publication and package scope are not configured yet. `private: true` is intentional during development.
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
- ## Try the prototype
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; other versions/platforms need verification before release.
23
+ Requires Node **22.19+** and Pi **0.85.1+**. Compatibility is currently tested with 0.85.1 on Windows/Edge.
23
24
 
24
- From this project directory:
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 publication plan. Future distribution will be `pi install npm:@your-scope/package-name`, with a `pi` manifest and the `pi-package` keyword for the Pi catalog.
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: local prototype. Pi compatibility baseline: 0.85.1. Package name/scope and public repository have not been chosen. `private: true` prevents accidental publication while retaining local/git installation and tarball inspection.
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
- Choose an available npm scope/name and repository; add repository metadata and changelog; verify Windows and CI on Linux/macOS; run tests and inspect npm pack; remove private only for an intentional public release. Include the prebuilt web assets (this prototype needs no frontend build). Gallery indexing uses npm's pi-package keyword; listing timing is outside this project's control.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-custom-provider-model",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A local browser-based custom provider and API-key manager for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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>) { this.store = new ConfigStore(dir); }
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) throw new AppError("Choose another default provider before deleting this one.");
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, detail);
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 || !confirm(t("confirmDelete"))) return;
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 0.1.0`;
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: #816522; }
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
  }