pi-llama-cpp 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -13
- package/package.json +3 -3
- package/src/api/client.ts +59 -22
- package/src/constants.ts +5 -0
- package/src/enums/mode.ts +1 -0
- package/src/index.ts +2 -1
- package/src/interfaces/endpoints/models.ts +6 -0
- package/src/interfaces/endpoints/props.ts +14 -0
- package/src/interfaces/settings.ts +6 -0
- package/src/managers/command/models.ts +12 -6
- package/src/managers/command.ts +6 -1
- package/src/managers/server.ts +30 -38
- package/src/managers/settings.ts +23 -3
- package/src/models/legacyModel.ts +3 -3
- package/src/models/llamaSwapModel.ts +90 -0
- package/src/models/routerModel.ts +2 -2
- package/src/models/singleModel.ts +2 -2
- package/src/server.ts +42 -15
- package/src/sse/manager.ts +2 -2
- package/src/ui/dialog/confirm.ts +1 -1
- package/src/ui/dialog/input.ts +1 -1
- package/src/ui/editors/editorOptions.ts +8 -1
- package/src/ui/editors/itemBuilder.ts +2 -6
- package/src/ui/editors/listEditor.ts +24 -15
- package/src/ui/editors/override/entryEditor.ts +105 -23
- package/src/ui/editors/override/fields/base.ts +53 -0
- package/src/ui/editors/override/fields/capabilities.ts +36 -0
- package/src/ui/editors/override/fields/cost.ts +64 -0
- package/src/ui/editors/override/fields/index.ts +68 -0
- package/src/ui/editors/override/fields/numeric.ts +55 -0
- package/src/ui/editors/override/fields/pattern.ts +24 -0
- package/src/ui/editors/override/fields/reasoning.ts +33 -0
- package/src/ui/editors/override/itemBuilder.ts +4 -4
- package/src/ui/editors/server/fields.ts +2 -2
- package/src/ui/editors/server/itemBuilder.ts +2 -3
- package/src/ui/editors/server/serverEditor.ts +58 -22
- package/src/ui/editors/server/utils.ts +50 -0
- package/src/ui/settings/index.ts +11 -0
- package/src/ui/strings.ts +17 -0
- package/src/utils/credentialResolver.ts +110 -0
- package/src/utils/health.ts +2 -2
- package/src/utils/urlResolver.ts +1 -1
- package/tests/{commandManager.test.ts → command/commandManager.test.ts} +80 -9
- package/tests/{events.test.ts → events/events.test.ts} +6 -6
- package/tests/mocks.ts +4 -0
- package/tests/{legacyModel.test.ts → models/legacyModel.test.ts} +5 -5
- package/tests/models/llamaSwapModel.test.ts +327 -0
- package/tests/{routerModel.test.ts → models/routerModel.test.ts} +5 -5
- package/tests/{singleModel.test.ts → models/singleModel.test.ts} +5 -5
- package/tests/{health.test.ts → server/health.test.ts} +4 -4
- package/tests/{server.test.ts → server/server.test.ts} +57 -7
- package/tests/{serverManager.test.ts → server/serverManager.test.ts} +4 -4
- package/tests/{settings.test.ts → settings/settings.test.ts} +198 -355
- package/tests/{settingsStore.test.ts → settings/settingsStore.test.ts} +1 -1
- package/tests/{sseManager.test.ts → sse/sseManager.test.ts} +2 -2
- package/tests/{dialog.test.ts → ui/dialog.test.ts} +5 -4
- package/tests/{overrides.test.ts → ui/overrides.test.ts} +9 -42
- package/tests/utils/credentialResolver.test.ts +66 -0
- package/tests/utils/urlResolver.test.ts +116 -0
- package/src/ui/editors/override/fields.ts +0 -294
- package/src/ui/editors/override/handlers.ts +0 -118
- package/src/ui/editors/server/handlers.ts +0 -32
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ModelOverride } from "../../../../interfaces/settings";
|
|
2
|
+
import { FIELDS } from "../../../strings";
|
|
3
|
+
import { OverrideEntry } from "../entry";
|
|
4
|
+
import type { InputValidator } from "./base";
|
|
5
|
+
import { OverrideField } from "./base";
|
|
6
|
+
|
|
7
|
+
/** The override's `capabilities` list (finite: fixed label options). */
|
|
8
|
+
export class CapabilitiesField extends OverrideField {
|
|
9
|
+
readonly id = "capabilities";
|
|
10
|
+
readonly type = "finite";
|
|
11
|
+
readonly field = FIELDS.capabilities;
|
|
12
|
+
override readonly options = ["text", "text | image"];
|
|
13
|
+
|
|
14
|
+
displayValue(entry: OverrideEntry): string {
|
|
15
|
+
return entry.override.capabilities?.join(" | ") ?? "text";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
readonly validate: InputValidator = (raw) =>
|
|
19
|
+
this.options?.includes(raw) ? raw : null;
|
|
20
|
+
|
|
21
|
+
apply(override: ModelOverride, value: string): void {
|
|
22
|
+
override.capabilities =
|
|
23
|
+
value === "text | image"
|
|
24
|
+
? ["text", "image"]
|
|
25
|
+
: value === "text"
|
|
26
|
+
? ["text"]
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Appears in the summary only when non-empty. */
|
|
31
|
+
override summaryPart(override: ModelOverride): string | null {
|
|
32
|
+
return override.capabilities?.length
|
|
33
|
+
? `capabilities: ${override.capabilities.join(",")}`
|
|
34
|
+
: null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ModelCost, ModelCostRates } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelOverride } from "../../../../interfaces/settings";
|
|
3
|
+
import type { Field } from "../../../strings";
|
|
4
|
+
import { OverrideEntry } from "../entry";
|
|
5
|
+
import type { InputValidator } from "./base";
|
|
6
|
+
import { OverrideField } from "./base";
|
|
7
|
+
import { parseNonNegative } from "./numeric";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One `ModelCostRates` key of the override's `cost` object (absorbs the
|
|
11
|
+
* former `CostFieldDefFactory` and the standalone `parseCostValue` /
|
|
12
|
+
* `applyCostFieldValue` helpers): owns the cost parse and immutable
|
|
13
|
+
* apply in the same place as its display format.
|
|
14
|
+
*/
|
|
15
|
+
export class CostField extends OverrideField {
|
|
16
|
+
readonly type = "input";
|
|
17
|
+
override readonly field: Field;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
readonly id: string,
|
|
21
|
+
private readonly key: keyof ModelCostRates,
|
|
22
|
+
field: Field,
|
|
23
|
+
/** Short term used in the entry-row summary (e.g. `cache read`). */
|
|
24
|
+
private readonly summaryTerm: string,
|
|
25
|
+
) {
|
|
26
|
+
super();
|
|
27
|
+
this.field = field;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
displayValue(entry: OverrideEntry): string {
|
|
31
|
+
return String(entry.override.cost?.[this.key] ?? 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
readonly validate: InputValidator = (raw) => {
|
|
35
|
+
const parsed = parseNonNegative(raw);
|
|
36
|
+
return parsed === null ? null : String(parsed);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
apply(override: ModelOverride, value: string): void {
|
|
40
|
+
const nextCost = this.applyCost(override.cost, Number(value));
|
|
41
|
+
if (nextCost) override.cost = nextCost;
|
|
42
|
+
else delete override.cost;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns a copy of `cost` with this field set to `value`. A `0` (or
|
|
46
|
+
* non-finite) value removes the field — unset cost fields default to
|
|
47
|
+
* zero at read time — and when no fields remain, returns `undefined`
|
|
48
|
+
* so the whole `cost` object can be dropped. Immutable. */
|
|
49
|
+
private applyCost(
|
|
50
|
+
cost: ModelOverride["cost"],
|
|
51
|
+
value: number,
|
|
52
|
+
): Partial<ModelCost> | undefined {
|
|
53
|
+
const next = { ...cost };
|
|
54
|
+
if (isFinite(value) && value > 0) next[this.key] = value;
|
|
55
|
+
else delete next[this.key];
|
|
56
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Appears in the summary only when a non-zero cost is set. */
|
|
60
|
+
override summaryPart(override: ModelOverride): string | null {
|
|
61
|
+
const value = override.cost?.[this.key];
|
|
62
|
+
return value ? `${this.summaryTerm}: $${value}` : null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ModelCostRates } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelOverride } from "../../../../interfaces/settings";
|
|
3
|
+
import { FIELDS } from "../../../strings";
|
|
4
|
+
import { OverrideField } from "./base";
|
|
5
|
+
import { CapabilitiesField } from "./capabilities";
|
|
6
|
+
import { CostField } from "./cost";
|
|
7
|
+
import { NumericField } from "./numeric";
|
|
8
|
+
import { PatternField } from "./pattern";
|
|
9
|
+
import { ReasoningField } from "./reasoning";
|
|
10
|
+
|
|
11
|
+
/** Cost field specs: [ModelCostRates key, FIELDS entry, summary term] */
|
|
12
|
+
const COST_FIELDS: [
|
|
13
|
+
keyof ModelCostRates,
|
|
14
|
+
(
|
|
15
|
+
| typeof FIELDS.inputCost
|
|
16
|
+
| typeof FIELDS.outputCost
|
|
17
|
+
| typeof FIELDS.cacheReadCost
|
|
18
|
+
| typeof FIELDS.cacheWriteCost
|
|
19
|
+
),
|
|
20
|
+
string,
|
|
21
|
+
][] = [
|
|
22
|
+
["input", FIELDS.inputCost, "input"],
|
|
23
|
+
["output", FIELDS.outputCost, "output"],
|
|
24
|
+
["cacheRead", FIELDS.cacheReadCost, "cache read"],
|
|
25
|
+
["cacheWrite", FIELDS.cacheWriteCost, "cache write"],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The field registry for model override entries: the complete list of
|
|
30
|
+
* editable fields (order defines the row order in the settings list)
|
|
31
|
+
* plus id-based lookup. Throws on an unknown id — the registry is
|
|
32
|
+
* static, so a miss is a programming error.
|
|
33
|
+
*/
|
|
34
|
+
export class OverrideFields {
|
|
35
|
+
static readonly all: OverrideField[] = [
|
|
36
|
+
new PatternField(),
|
|
37
|
+
...COST_FIELDS.map(
|
|
38
|
+
([key, field, term]) => new CostField(`cost.${key}`, key, field, term),
|
|
39
|
+
),
|
|
40
|
+
new CapabilitiesField(),
|
|
41
|
+
new ReasoningField(),
|
|
42
|
+
new NumericField("contextSize", FIELDS.contextSize),
|
|
43
|
+
new NumericField("maxTokens", FIELDS.maxTokens),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
static byId(id: string): OverrideField {
|
|
47
|
+
const field = this.all.find((f) => f.id === id);
|
|
48
|
+
if (!field) throw new Error(`Unknown override field: ${id}`);
|
|
49
|
+
return field;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds the entry-row summary shown in the override entry list
|
|
55
|
+
* (`input: $0.2, output: $0.6, cache read: $0.01, …`), composed from the
|
|
56
|
+
* {@link OverrideFields} registry — adding a field to the registry makes
|
|
57
|
+
* it show up here too, so the summary can't lag the edit menu. Fields
|
|
58
|
+
* with unset/zero values are omitted for brevity (each field decides via
|
|
59
|
+
* {@link OverrideField.summaryPart}); `—` when nothing is set.
|
|
60
|
+
*/
|
|
61
|
+
export class OverrideSummary {
|
|
62
|
+
static of(override: ModelOverride): string {
|
|
63
|
+
const parts = OverrideFields.all
|
|
64
|
+
.map((field) => field.summaryPart(override))
|
|
65
|
+
.filter((part): part is string => part !== null);
|
|
66
|
+
return parts.length > 0 ? parts.join(", ") : "—";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ModelOverride } from "../../../../interfaces/settings";
|
|
2
|
+
import type { Field } from "../../../strings";
|
|
3
|
+
import { OverrideEntry } from "../entry";
|
|
4
|
+
import type { InputValidator } from "./base";
|
|
5
|
+
import { OverrideField } from "./base";
|
|
6
|
+
|
|
7
|
+
/** Parses a non-negative number; blank input means zero (unset fields
|
|
8
|
+
* default to zero at read time). Returns `null` when invalid. */
|
|
9
|
+
export function parseNonNegative(raw: string): number | null {
|
|
10
|
+
const n = Number(raw.trim());
|
|
11
|
+
return isFinite(n) && n >= 0 ? n : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Non-negative numeric field (`contextSize`, `maxTokens`): keeps the
|
|
16
|
+
* display format, validator and apply logic in a single place so the
|
|
17
|
+
* three can't drift (e.g. the validator accepting `0` while the apply
|
|
18
|
+
* drops it — unset fields default to zero at read time, so all render
|
|
19
|
+
* as `0`).
|
|
20
|
+
*/
|
|
21
|
+
export class NumericField extends OverrideField {
|
|
22
|
+
readonly type = "input";
|
|
23
|
+
override readonly field: Field;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
readonly id: "maxTokens" | "contextSize",
|
|
27
|
+
field: Field,
|
|
28
|
+
) {
|
|
29
|
+
super();
|
|
30
|
+
this.field = field;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
displayValue(entry: OverrideEntry): string {
|
|
34
|
+
return String(entry.override[this.id] ?? 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Accepts finite, non-negative numbers (blank means zero). */
|
|
38
|
+
readonly validate: InputValidator = (raw) => {
|
|
39
|
+
const n = parseNonNegative(raw);
|
|
40
|
+
return n === null ? null : String(n);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Applies the committed value; non-positive values remove the key. */
|
|
44
|
+
apply(override: ModelOverride, value: string): void {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (isFinite(n) && n > 0) override[this.id] = n;
|
|
47
|
+
else delete override[this.id];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Appears in the summary only when set. */
|
|
51
|
+
override summaryPart(override: ModelOverride): string | null {
|
|
52
|
+
const value = override[this.id];
|
|
53
|
+
return value === undefined ? null : `${this.id}: ${value}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FIELDS } from "../../../strings";
|
|
2
|
+
import { OverrideEntry } from "../entry";
|
|
3
|
+
import type { InputValidator } from "./base";
|
|
4
|
+
import { OverrideField } from "./base";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The entry's pattern key. Renames are handled by the editor
|
|
8
|
+
* (`OverrideEntryListEditor.applyFieldChange`), so {@link apply}
|
|
9
|
+
* inherits the base no-op.
|
|
10
|
+
*/
|
|
11
|
+
export class PatternField extends OverrideField {
|
|
12
|
+
readonly id = "pattern";
|
|
13
|
+
readonly type = "input";
|
|
14
|
+
readonly field = FIELDS.pattern;
|
|
15
|
+
|
|
16
|
+
displayValue(entry: OverrideEntry): string {
|
|
17
|
+
return entry.pattern;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
readonly validate: InputValidator = (raw) => {
|
|
21
|
+
const trimmed = raw.trim();
|
|
22
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ModelOverride } from "../../../../interfaces/settings";
|
|
2
|
+
import { FIELDS } from "../../../strings";
|
|
3
|
+
import { OverrideEntry } from "../entry";
|
|
4
|
+
import type { InputValidator } from "./base";
|
|
5
|
+
import { OverrideField } from "./base";
|
|
6
|
+
|
|
7
|
+
/** The override's `reasoning` flag (finite: true/false, default true). */
|
|
8
|
+
export class ReasoningField extends OverrideField {
|
|
9
|
+
readonly id = "reasoning";
|
|
10
|
+
readonly type = "finite";
|
|
11
|
+
readonly field = FIELDS.reasoning;
|
|
12
|
+
override readonly options = ["true", "false"];
|
|
13
|
+
|
|
14
|
+
displayValue(entry: OverrideEntry): string {
|
|
15
|
+
return entry.override.reasoning === false ? "false" : "true";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
readonly validate: InputValidator = (raw) =>
|
|
19
|
+
this.options?.includes(raw) ? raw : null;
|
|
20
|
+
|
|
21
|
+
apply(override: ModelOverride, value: string): void {
|
|
22
|
+
if (value === "true") override.reasoning = true;
|
|
23
|
+
else if (value === "false") override.reasoning = false;
|
|
24
|
+
else delete override.reasoning;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Appears in the summary only when explicitly set. */
|
|
28
|
+
override summaryPart(override: ModelOverride): string | null {
|
|
29
|
+
return override.reasoning === undefined
|
|
30
|
+
? null
|
|
31
|
+
: `reasoning: ${override.reasoning}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -2,7 +2,8 @@ import type { SettingItem } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import { FieldMessages, TITLES } from "../../strings";
|
|
3
3
|
import { ItemBuilder } from "../itemBuilder";
|
|
4
4
|
import { OverrideEntry } from "./entry";
|
|
5
|
-
import {
|
|
5
|
+
import type { OverrideField } from "./fields/base";
|
|
6
|
+
import { OverrideFields } from "./fields/index";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Builds `SettingItem` objects for override entry field submenus.
|
|
@@ -11,7 +12,7 @@ export class OverrideItemBuilder extends ItemBuilder<
|
|
|
11
12
|
OverrideEntry,
|
|
12
13
|
OverrideField
|
|
13
14
|
> {
|
|
14
|
-
protected get fields(): readonly OverrideField[] {
|
|
15
|
+
protected override get fields(): readonly OverrideField[] {
|
|
15
16
|
return OverrideFields.all;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -20,9 +21,8 @@ export class OverrideItemBuilder extends ItemBuilder<
|
|
|
20
21
|
* an `InputDialog` submenu (validating against the field definition)
|
|
21
22
|
* for "input" fields.
|
|
22
23
|
*/
|
|
23
|
-
protected decorate(
|
|
24
|
+
protected override decorate(
|
|
24
25
|
def: OverrideField,
|
|
25
|
-
entry: OverrideEntry,
|
|
26
26
|
base: SettingItem,
|
|
27
27
|
): SettingItem {
|
|
28
28
|
if (def.type === "finite") {
|
|
@@ -26,7 +26,7 @@ class UrlField extends ServerField {
|
|
|
26
26
|
readonly field = FIELDS.serverUrl;
|
|
27
27
|
readonly validate = ServerUrl.parse;
|
|
28
28
|
|
|
29
|
-
currentValue(server: LlamaServer): string {
|
|
29
|
+
override currentValue(server: LlamaServer): string {
|
|
30
30
|
return server.url;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -51,7 +51,7 @@ class OptionalTextField extends ServerField {
|
|
|
51
51
|
this.field = field;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
currentValue(server: LlamaServer): string {
|
|
54
|
+
override currentValue(server: LlamaServer): string {
|
|
55
55
|
return server[this.id] ?? "";
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -10,7 +10,7 @@ import { ServerDisplay } from "./utils";
|
|
|
10
10
|
* Builds `SettingItem` objects for server rows and their field-edit submenus.
|
|
11
11
|
*/
|
|
12
12
|
export class ServerItemBuilder extends ItemBuilder<LlamaServer, ServerField> {
|
|
13
|
-
protected get fields(): readonly ServerField[] {
|
|
13
|
+
protected override get fields(): readonly ServerField[] {
|
|
14
14
|
return ServerFields.all;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -18,9 +18,8 @@ export class ServerItemBuilder extends ItemBuilder<LlamaServer, ServerField> {
|
|
|
18
18
|
* Adds the field-edit submenu: an `InputDialog` validating against the
|
|
19
19
|
* field definition.
|
|
20
20
|
*/
|
|
21
|
-
protected decorate(
|
|
21
|
+
protected override decorate(
|
|
22
22
|
def: ServerField,
|
|
23
|
-
server: LlamaServer,
|
|
24
23
|
base: SettingItem,
|
|
25
24
|
): SettingItem {
|
|
26
25
|
return {
|
|
@@ -3,11 +3,12 @@ import type { SettingItem, SettingsList } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { SERVER_TIMEOUT } from "../../../constants";
|
|
4
4
|
import type { LlamaServer } from "../../../interfaces/settings";
|
|
5
5
|
import type { LlamaSettingsManager } from "../../../managers/settings";
|
|
6
|
-
import {
|
|
6
|
+
import { ServerIds } from "../../../utils/serverIds";
|
|
7
|
+
import { TITLES, authRequiredMessage } from "../../strings";
|
|
7
8
|
import type { ServerSettingsListOptions } from "../editorOptions";
|
|
8
9
|
import { ListEditor } from "../listEditor";
|
|
9
10
|
import { SettingsListFactory } from "../settingsListFactory";
|
|
10
|
-
import {
|
|
11
|
+
import { ServerFields } from "./fields";
|
|
11
12
|
import { ServerItemBuilder } from "./itemBuilder";
|
|
12
13
|
import { ServerDisplay } from "./utils";
|
|
13
14
|
import { ServerWizard } from "./wizard";
|
|
@@ -58,26 +59,42 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
58
59
|
done: () => done(undefined),
|
|
59
60
|
onError: (message) => ui.notify(message, "error"),
|
|
60
61
|
serverTimeout,
|
|
62
|
+
authResolver: async (server) => {
|
|
63
|
+
// Mirror Server.getApiKey(): use ServerIds.resolve to get
|
|
64
|
+
// the provider ID (customId or llama-server=<url>), then
|
|
65
|
+
// resolve the key from the credential store.
|
|
66
|
+
return settings.resolveApiKey(
|
|
67
|
+
ServerIds.resolve(server.url, server.id),
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
ui,
|
|
61
71
|
}),
|
|
62
72
|
);
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
// -- abstract hooks -------------------------------------------------------
|
|
66
76
|
|
|
67
|
-
protected async buildSettingsList(): Promise<SettingsList> {
|
|
77
|
+
protected override async buildSettingsList(): Promise<SettingsList> {
|
|
68
78
|
const builder = new ServerItemBuilder(this.dialogs);
|
|
69
79
|
const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
80
|
+
// Run auth + health probes in parallel; ⛔ wins if auth fails,
|
|
81
|
+
// otherwise fall back to the health emoji.
|
|
82
|
+
const probes = await Promise.all(
|
|
83
|
+
this.options.servers.map(async (server) => {
|
|
84
|
+
const apiKey = (await this.options.authResolver?.(server)) ?? "";
|
|
85
|
+
const [authEmoji, healthEmoji] = await Promise.all([
|
|
86
|
+
ServerDisplay.authEmoji(server.url, apiKey, serverTimeout),
|
|
87
|
+
ServerDisplay.healthEmoji(server.url, serverTimeout),
|
|
88
|
+
]);
|
|
89
|
+
return authEmoji || healthEmoji;
|
|
90
|
+
}),
|
|
74
91
|
);
|
|
75
92
|
const items: SettingItem[] = this.options.servers.map((server, i) =>
|
|
76
93
|
builder.buildRow(
|
|
77
94
|
server,
|
|
78
95
|
i,
|
|
79
96
|
(field, value) => this.handleFieldChange(field, value),
|
|
80
|
-
|
|
97
|
+
probes[i],
|
|
81
98
|
// Opening delegates input to the submenu; closing flushes a
|
|
82
99
|
// rebuild deferred by a field commit (see commitFieldChange)
|
|
83
100
|
(open) => this.trackSubmenu(open),
|
|
@@ -92,10 +109,13 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
92
109
|
/** Handle a field commit from a server row's submenu. */
|
|
93
110
|
private handleFieldChange(field: string, value: string): void {
|
|
94
111
|
const idx = this.selectedIndex;
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
const server = this.options.servers[idx];
|
|
113
|
+
const updated = server
|
|
114
|
+
? ServerFields.byId(field).apply(server, value)
|
|
115
|
+
: server;
|
|
116
|
+
const next = server
|
|
117
|
+
? this.options.servers.map((s, i) => (i === idx ? updated : s))
|
|
118
|
+
: this.options.servers;
|
|
99
119
|
|
|
100
120
|
this.commitFieldChange(next, idx, () => {
|
|
101
121
|
// Refresh the row's suffix in place; the full rebuild (row labels
|
|
@@ -111,7 +131,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
111
131
|
});
|
|
112
132
|
}
|
|
113
133
|
|
|
114
|
-
protected beginAdd(): void {
|
|
134
|
+
protected override beginAdd(): void {
|
|
115
135
|
const wizard = new ServerWizard(this.dialogs, (dialog) =>
|
|
116
136
|
this.openDialog(dialog),
|
|
117
137
|
);
|
|
@@ -124,7 +144,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
124
144
|
);
|
|
125
145
|
}
|
|
126
146
|
|
|
127
|
-
protected deleteSelected(): void {
|
|
147
|
+
protected override deleteSelected(): void {
|
|
128
148
|
const idx = this.selectedIndex;
|
|
129
149
|
const next = this.options.servers.filter((_, i) => i !== idx);
|
|
130
150
|
void this.persistSnapshot(next, () => {
|
|
@@ -132,21 +152,17 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
132
152
|
});
|
|
133
153
|
}
|
|
134
154
|
|
|
135
|
-
protected readonly emptyHintKey = "emptyServers" as const;
|
|
136
|
-
|
|
137
|
-
protected getCurrentCount(): number {
|
|
138
|
-
return this.options.servers.length;
|
|
139
|
-
}
|
|
155
|
+
protected override readonly emptyHintKey = "emptyServers" as const;
|
|
140
156
|
|
|
141
|
-
protected getRowId(index: number): string {
|
|
157
|
+
protected override getRowId(index: number): string {
|
|
142
158
|
return `server-${index}`;
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
protected getRowLabel(index: number): string {
|
|
161
|
+
protected override getRowLabel(index: number): string {
|
|
146
162
|
return this.options.servers[index]?.url ?? "";
|
|
147
163
|
}
|
|
148
164
|
|
|
149
|
-
protected get deleteTitle(): string {
|
|
165
|
+
protected override get deleteTitle(): string {
|
|
150
166
|
return TITLES.deleteServer;
|
|
151
167
|
}
|
|
152
168
|
|
|
@@ -157,5 +173,25 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
157
173
|
await this.persistSnapshot(next, () => {
|
|
158
174
|
void this.rebuildList(next.length - 1);
|
|
159
175
|
});
|
|
176
|
+
|
|
177
|
+
// Warn the user if the newly added server requires an API key
|
|
178
|
+
await this.warnIfAuthRequired(server);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Probes the server to check if it requires an API key. If so, notifies
|
|
183
|
+
* the user so they know to configure one via `/login` or `auth.json`.
|
|
184
|
+
*/
|
|
185
|
+
private async warnIfAuthRequired(server: LlamaServer): Promise<void> {
|
|
186
|
+
const ui = this.options.ui;
|
|
187
|
+
if (!ui) return;
|
|
188
|
+
|
|
189
|
+
const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
|
|
190
|
+
const apiKey = (await this.options.authResolver?.(server)) ?? "";
|
|
191
|
+
|
|
192
|
+
if (await ServerDisplay.requiresApiKey(server.url, apiKey, serverTimeout)) {
|
|
193
|
+
const providerId = ServerIds.resolve(server.url, server.id);
|
|
194
|
+
ui.notify(authRequiredMessage(server.url, providerId), "warning");
|
|
195
|
+
}
|
|
160
196
|
}
|
|
161
197
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { ENDPOINT_PREFIX } from "../../../constants";
|
|
1
2
|
import { ServerStatus } from "../../../enums/serverStatus";
|
|
2
3
|
import type { LlamaServer } from "../../../interfaces/settings";
|
|
3
4
|
import { checkServerHealth } from "../../../utils/health";
|
|
4
5
|
import { ServerIds } from "../../../utils/serverIds";
|
|
5
6
|
|
|
7
|
+
/** Emoji shown when a server requires an API key (401 on /v1/models). */
|
|
8
|
+
const UNAUTHORIZED_EMOJI = "⛔";
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* Display concerns for a server row in the top-level list: the dim
|
|
8
12
|
* `(...)` suffix after the URL and the health-status icon.
|
|
@@ -33,6 +37,52 @@ export class ServerDisplay {
|
|
|
33
37
|
static async healthEmoji(url: string, timeout: number): Promise<string> {
|
|
34
38
|
return SERVER_STATUS_ICONS[await checkServerHealth(url, timeout)];
|
|
35
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks if the server requires an API key by probing `/v1/models`.
|
|
43
|
+
*
|
|
44
|
+
* @param url - The server URL to check
|
|
45
|
+
* @param apiKey - The API key to send (may be a placeholder)
|
|
46
|
+
* @param timeout - Maximum time (ms) to wait for the request
|
|
47
|
+
* @returns `true` if the server responded with 401, `false` otherwise
|
|
48
|
+
*/
|
|
49
|
+
static async requiresApiKey(
|
|
50
|
+
url: string,
|
|
51
|
+
apiKey: string,
|
|
52
|
+
timeout: number,
|
|
53
|
+
): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(`${url}${ENDPOINT_PREFIX}/models`, {
|
|
56
|
+
signal: AbortSignal.timeout(timeout),
|
|
57
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
58
|
+
});
|
|
59
|
+
return response.status === 401;
|
|
60
|
+
} catch {
|
|
61
|
+
// Timeout or network error — the server is unreachable, so no auth
|
|
62
|
+
// indicator is needed (the health emoji will cover that).
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Probes the server's `/v1/models` endpoint to check if authentication
|
|
69
|
+
* is required. Returns ⛔ when the server responds with 401, empty string
|
|
70
|
+
* otherwise.
|
|
71
|
+
*
|
|
72
|
+
* @param url - The server URL to check
|
|
73
|
+
* @param apiKey - The API key to send (may be a placeholder)
|
|
74
|
+
* @param timeout - Maximum time (ms) to wait for the request
|
|
75
|
+
* @returns The emoji indicator (⛔ if 401, "" otherwise)
|
|
76
|
+
*/
|
|
77
|
+
static async authEmoji(
|
|
78
|
+
url: string,
|
|
79
|
+
apiKey: string,
|
|
80
|
+
timeout: number,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
return (await this.requiresApiKey(url, apiKey, timeout))
|
|
83
|
+
? UNAUTHORIZED_EMOJI
|
|
84
|
+
: "";
|
|
85
|
+
}
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
/**
|
package/src/ui/settings/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export enum Options {
|
|
|
26
26
|
SORT_BY = "sortBy",
|
|
27
27
|
POLLING_TIMEOUT = "pollingTimeout",
|
|
28
28
|
SERVER_TIMEOUT = "serverTimeout",
|
|
29
|
+
SHOW_SERVER_URLS = "showServerUrls",
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
@@ -110,6 +111,13 @@ export const buildSettingsItems = async (
|
|
|
110
111
|
currentValue: formatMs(serverTimeout),
|
|
111
112
|
values: SERVER_PRESETS.map(formatMs),
|
|
112
113
|
},
|
|
114
|
+
{
|
|
115
|
+
id: Options.SHOW_SERVER_URLS,
|
|
116
|
+
label: "Show server URLs",
|
|
117
|
+
description: "Show [Server: <url>] in /models (next open)",
|
|
118
|
+
currentValue: (await settings.resolveShowServerUrls()) ? "on" : "off",
|
|
119
|
+
values: ["on", "off"],
|
|
120
|
+
},
|
|
113
121
|
];
|
|
114
122
|
};
|
|
115
123
|
|
|
@@ -139,6 +147,9 @@ export const applySettingChange = async (
|
|
|
139
147
|
case Options.SERVER_TIMEOUT:
|
|
140
148
|
await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
|
|
141
149
|
return;
|
|
150
|
+
case Options.SHOW_SERVER_URLS:
|
|
151
|
+
await settings.setLlamaSetting("showServerUrls", newValue === "on");
|
|
152
|
+
return;
|
|
142
153
|
}
|
|
143
154
|
};
|
|
144
155
|
|
package/src/ui/strings.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
1
3
|
import { LLAMA_SERVER_URL } from "../constants";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -129,3 +131,18 @@ export const HINTS = {
|
|
|
129
131
|
/** Row description for a server in the overrides editor */
|
|
130
132
|
overrideServerRow: "Enter: edit this server's override entries",
|
|
131
133
|
} as const;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Generates a warning message for servers that require an API key.
|
|
137
|
+
* Centralized so both the runtime manager and the editor wizard use
|
|
138
|
+
* identical wording.
|
|
139
|
+
*/
|
|
140
|
+
export const authRequiredMessage = (
|
|
141
|
+
baseUrl: string,
|
|
142
|
+
providerId: string,
|
|
143
|
+
): string =>
|
|
144
|
+
[
|
|
145
|
+
"[pi-llama-cpp]",
|
|
146
|
+
`Server at '${baseUrl}' requires a valid API key.`,
|
|
147
|
+
`Configure the key via '/login ${providerId}' or in '${getAgentDir()}/auth.json'.`,
|
|
148
|
+
].join("\n");
|