@wuyaos/pi-model-config 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/extensions/model-config.ts +27 -0
- package/model-config.schema.json +269 -0
- package/package.json +52 -4
- package/src/command.ts +1053 -0
- package/src/config.ts +438 -0
- package/src/context-probe.ts +90 -0
- package/src/discovery.ts +427 -0
- package/src/i18n.ts +824 -0
- package/src/model-editor.ts +326 -0
- package/src/model-test.ts +212 -0
- package/src/panel.ts +477 -0
- package/src/runtime.ts +298 -0
- package/src/selector.ts +433 -0
- package/src/thinking-defaults.ts +39 -0
- package/src/thinking-probe.ts +84 -0
- package/src/transport.ts +94 -0
- package/src/types.ts +150 -0
- package/src/ui-config.ts +44 -0
- package/src/ui.ts +179 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { AnyModel } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const MODEL_TYPES = ["chat", "classifier", "image"] as const;
|
|
4
|
+
export type ManagedModelType = (typeof MODEL_TYPES)[number];
|
|
5
|
+
export type ModelIdentity = `${string}\u0000${ManagedModelType}\u0000${string}`;
|
|
6
|
+
|
|
7
|
+
export type AuthMode = "models-json" | "reuse-pi" | "anonymous";
|
|
8
|
+
export type DiscoveryAuthMode = AuthMode | "temporary";
|
|
9
|
+
export type ThinkingMetadataSource = "existing" | "remote" | "catalog" | "protocol" | "user" | "probed";
|
|
10
|
+
export type ThinkingLevelMap = Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
|
|
11
|
+
export type ModelMetadataSource = "existing" | "remote" | "catalog" | "user" | "compat" | "probed";
|
|
12
|
+
|
|
13
|
+
export interface ModelMetadataEntry {
|
|
14
|
+
type: ManagedModelType;
|
|
15
|
+
id: string;
|
|
16
|
+
contextSource?: ModelMetadataSource;
|
|
17
|
+
maxTokensSource?: ModelMetadataSource;
|
|
18
|
+
verifiedAt?: string;
|
|
19
|
+
probeLower?: number;
|
|
20
|
+
probeUpper?: number;
|
|
21
|
+
probeEstimated?: boolean;
|
|
22
|
+
thinkingSource?: ThinkingMetadataSource;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DiscoveryAuthHeader = "auto" | "bearer" | "x-api-key" | "api-key" | "x-goog-api-key" | "anthropic" | "none";
|
|
26
|
+
|
|
27
|
+
export interface ManagedModelBase {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
api: string;
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
input: ("text" | "image")[];
|
|
33
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManagedChatModel extends ManagedModelBase {
|
|
38
|
+
type: "chat";
|
|
39
|
+
reasoning: boolean;
|
|
40
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
41
|
+
contextWindow: number;
|
|
42
|
+
maxTokens: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ManagedClassifierModel extends ManagedModelBase {
|
|
46
|
+
type: "classifier";
|
|
47
|
+
contextWindow: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ManagedImageModel extends ManagedModelBase {
|
|
51
|
+
type: "image";
|
|
52
|
+
output: ("text" | "image")[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ManagedModel = ManagedChatModel | ManagedClassifierModel | ManagedImageModel;
|
|
56
|
+
|
|
57
|
+
export interface ProviderConnectionConfig {
|
|
58
|
+
name?: string;
|
|
59
|
+
owned?: boolean;
|
|
60
|
+
enabled?: boolean;
|
|
61
|
+
discoveryUrl?: string;
|
|
62
|
+
authMode: AuthMode;
|
|
63
|
+
discoveryAuthHeader?: DiscoveryAuthHeader;
|
|
64
|
+
proxyUrl?: string;
|
|
65
|
+
chatApi?: string;
|
|
66
|
+
classifierApi?: string;
|
|
67
|
+
imageApi?: string;
|
|
68
|
+
defaultType?: ManagedModelType;
|
|
69
|
+
defaultContextWindow?: number;
|
|
70
|
+
defaultMaxTokens?: number;
|
|
71
|
+
managedChatIds?: string[];
|
|
72
|
+
models?: Array<ManagedClassifierModel | ManagedImageModel>;
|
|
73
|
+
modelMetadata?: ModelMetadataEntry[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ModelConfigFile {
|
|
77
|
+
$schema?: string;
|
|
78
|
+
version: 1;
|
|
79
|
+
providers: Record<string, ProviderConnectionConfig>;
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ModelsJsonProvider {
|
|
84
|
+
name?: string;
|
|
85
|
+
baseUrl?: string;
|
|
86
|
+
apiKey?: string;
|
|
87
|
+
api?: string;
|
|
88
|
+
authHeader?: boolean;
|
|
89
|
+
headers?: Record<string, string>;
|
|
90
|
+
models?: Array<Record<string, unknown>>;
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ModelsJsonFile {
|
|
95
|
+
$schema?: string;
|
|
96
|
+
providers: Record<string, ModelsJsonProvider>;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ProviderDraft {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
owned: boolean;
|
|
104
|
+
enabled: boolean;
|
|
105
|
+
baseUrl?: string;
|
|
106
|
+
discoveryUrl?: string;
|
|
107
|
+
authMode: DiscoveryAuthMode;
|
|
108
|
+
authHeader: DiscoveryAuthHeader;
|
|
109
|
+
apiKeyRaw?: string;
|
|
110
|
+
temporaryKey?: string;
|
|
111
|
+
proxyUrl?: string;
|
|
112
|
+
chatApi: string;
|
|
113
|
+
classifierApi: string;
|
|
114
|
+
imageApi: string;
|
|
115
|
+
defaultType: ManagedModelType;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type CandidateModel = ManagedModel & {
|
|
119
|
+
source: "managed" | "external" | "installed" | "remote";
|
|
120
|
+
mutableType: boolean;
|
|
121
|
+
/** Read-only entries are visible but cannot be toggled until an explicit supported ownership action. */
|
|
122
|
+
readOnly?: boolean;
|
|
123
|
+
readOnlyReason?: "external-config" | "runtime-catalog";
|
|
124
|
+
contextSource?: ModelMetadataSource;
|
|
125
|
+
maxTokensSource?: ModelMetadataSource;
|
|
126
|
+
thinkingSource?: ThinkingMetadataSource;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export interface SelectionResult {
|
|
130
|
+
action: "preview" | "fetch" | "edit" | "probe" | "probeThinking" | "back" | "language";
|
|
131
|
+
cancelled: boolean;
|
|
132
|
+
selected: ManagedModel[];
|
|
133
|
+
metadata: ModelMetadataEntry[];
|
|
134
|
+
target?: Pick<CandidateModel, "id" | "type" | "source" | "readOnlyReason">;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ConfigSnapshot {
|
|
138
|
+
path: string;
|
|
139
|
+
exists: boolean;
|
|
140
|
+
text?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function identityOf(model: Pick<ManagedModel, "id" | "type"> & { provider?: string }, provider?: string): ModelIdentity {
|
|
144
|
+
const p = provider ?? model.provider ?? "";
|
|
145
|
+
return `${p}\u0000${model.type}\u0000${model.id}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function modelTypeOf(model: AnyModel | Record<string, unknown>): ManagedModelType {
|
|
149
|
+
return model.type === "classifier" || model.type === "image" ? model.type : "chat";
|
|
150
|
+
}
|
package/src/ui-config.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, fsyncSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { normalizeUiLanguage, type UiLanguage } from "./i18n.ts";
|
|
4
|
+
|
|
5
|
+
export interface ModelConfigUiPreferences {
|
|
6
|
+
version: 1;
|
|
7
|
+
language: UiLanguage;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function uiPreferencesPath(agentDir: string): string {
|
|
11
|
+
return join(agentDir, "model-config-ui.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadUiLanguage(agentDir: string): UiLanguage {
|
|
15
|
+
try {
|
|
16
|
+
const value: unknown = JSON.parse(readFileSync(uiPreferencesPath(agentDir), "utf8"));
|
|
17
|
+
if (typeof value === "object" && value !== null && (value as { version?: unknown }).version === 1) {
|
|
18
|
+
return normalizeUiLanguage((value as { language?: unknown }).language);
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Missing or invalid UI preferences never block model configuration.
|
|
22
|
+
}
|
|
23
|
+
return "zh-CN";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Only call after an explicit user language selection. */
|
|
27
|
+
export function saveUiLanguage(agentDir: string, language: UiLanguage): void {
|
|
28
|
+
const path = uiPreferencesPath(agentDir);
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
30
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
31
|
+
let fd: number | undefined;
|
|
32
|
+
try {
|
|
33
|
+
fd = openSync(temp, "wx", 0o600);
|
|
34
|
+
const value: ModelConfigUiPreferences = { version: 1, language };
|
|
35
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
36
|
+
fsyncSync(fd);
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
fd = undefined;
|
|
39
|
+
renameSync(temp, path);
|
|
40
|
+
} finally {
|
|
41
|
+
if (fd !== undefined) closeSync(fd);
|
|
42
|
+
rmSync(temp, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CancellableLoader,
|
|
4
|
+
CURSOR_MARKER,
|
|
5
|
+
Key,
|
|
6
|
+
decodeKittyPrintable,
|
|
7
|
+
matchesKey,
|
|
8
|
+
renderFakeCursor,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
type Component,
|
|
12
|
+
type Focusable,
|
|
13
|
+
type TUI,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
16
|
+
|
|
17
|
+
const PASTE_START = "\x1b[200~";
|
|
18
|
+
const PASTE_END = "\x1b[201~";
|
|
19
|
+
|
|
20
|
+
function stripPastedControls(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/gu, "")
|
|
23
|
+
.replace(/\x1b[P_X^][\s\S]*?\x1b\\/gu, "")
|
|
24
|
+
.replace(/\x1b(?:\]|[P_X^])[\s\S]*$/gu, "")
|
|
25
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, "")
|
|
26
|
+
.replace(/\x1bO./gu, "")
|
|
27
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function trailingMarkerPrefixLength(value: string, marker: string): number {
|
|
31
|
+
const max = Math.min(value.length, marker.length - 1);
|
|
32
|
+
for (let length = max; length > 0; length--) if (marker.startsWith(value.slice(-length))) return length;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class MaskedInput implements Component, Focusable {
|
|
37
|
+
private value = "";
|
|
38
|
+
private inputBuffer = "";
|
|
39
|
+
private isInPaste = false;
|
|
40
|
+
private pasteBuffer = "";
|
|
41
|
+
private _focused = false;
|
|
42
|
+
private finished = false;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly tui: TUI,
|
|
46
|
+
private readonly theme: Theme,
|
|
47
|
+
private readonly language: UiLanguage,
|
|
48
|
+
private readonly title: string,
|
|
49
|
+
private readonly done: (value: string | undefined) => void,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
get focused(): boolean { return this._focused; }
|
|
53
|
+
set focused(value: boolean) { this._focused = value; }
|
|
54
|
+
|
|
55
|
+
private finish(value: string | undefined): void {
|
|
56
|
+
if (this.finished) return;
|
|
57
|
+
this.finished = true;
|
|
58
|
+
this.done(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private append(value: string): void {
|
|
62
|
+
if (value) this.value += value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private handleOrdinaryInput(data: string): void {
|
|
66
|
+
if (!data) return;
|
|
67
|
+
if (matchesKey(data, Key.enter)) { this.finish(this.value); return; }
|
|
68
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { this.finish(undefined); return; }
|
|
69
|
+
if (matchesKey(data, Key.backspace)) {
|
|
70
|
+
this.value = [...this.value].slice(0, -1).join("");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (matchesKey(data, Key.ctrl("u"))) {
|
|
74
|
+
this.value = "";
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const kittyPrintable = decodeKittyPrintable(data);
|
|
78
|
+
if (kittyPrintable !== undefined) {
|
|
79
|
+
this.append(kittyPrintable);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const hasControl = [...data].some((char) => {
|
|
83
|
+
const code = char.charCodeAt(0);
|
|
84
|
+
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
|
85
|
+
});
|
|
86
|
+
if (!hasControl) this.append(data);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private drainInputBuffer(): void {
|
|
90
|
+
while (this.inputBuffer) {
|
|
91
|
+
if (this.isInPaste) {
|
|
92
|
+
const end = this.inputBuffer.indexOf(PASTE_END);
|
|
93
|
+
if (end < 0) {
|
|
94
|
+
const keep = trailingMarkerPrefixLength(this.inputBuffer, PASTE_END);
|
|
95
|
+
this.pasteBuffer += this.inputBuffer.slice(0, this.inputBuffer.length - keep);
|
|
96
|
+
this.inputBuffer = this.inputBuffer.slice(this.inputBuffer.length - keep);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.pasteBuffer += this.inputBuffer.slice(0, end);
|
|
100
|
+
this.append(stripPastedControls(this.pasteBuffer));
|
|
101
|
+
this.pasteBuffer = "";
|
|
102
|
+
this.isInPaste = false;
|
|
103
|
+
this.inputBuffer = this.inputBuffer.slice(end + PASTE_END.length);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const start = this.inputBuffer.indexOf(PASTE_START);
|
|
108
|
+
if (start >= 0) {
|
|
109
|
+
this.handleOrdinaryInput(this.inputBuffer.slice(0, start));
|
|
110
|
+
if (this.finished) return;
|
|
111
|
+
this.inputBuffer = this.inputBuffer.slice(start + PASTE_START.length);
|
|
112
|
+
this.isInPaste = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const keep = trailingMarkerPrefixLength(this.inputBuffer, PASTE_START);
|
|
116
|
+
const ordinary = this.inputBuffer.slice(0, this.inputBuffer.length - keep);
|
|
117
|
+
this.inputBuffer = this.inputBuffer.slice(this.inputBuffer.length - keep);
|
|
118
|
+
this.handleOrdinaryInput(ordinary);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
handleInput(data: string): void {
|
|
124
|
+
// A standalone Esc is an actual cancel key, not a fragmented paste marker.
|
|
125
|
+
if (!this.isInPaste && !this.inputBuffer && (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")))) {
|
|
126
|
+
this.finish(undefined);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.inputBuffer += data;
|
|
130
|
+
this.drainInputBuffer();
|
|
131
|
+
this.tui.requestRender();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(width: number): string[] {
|
|
135
|
+
const safeWidth = Math.max(1, width);
|
|
136
|
+
const label = safeWidth >= 8 ? this.theme.fg("muted", t(this.language, "maskedLabel")) : "";
|
|
137
|
+
const cursor = this._focused ? `${CURSOR_MARKER}${renderFakeCursor(" ")}` : "";
|
|
138
|
+
const available = Math.max(0, safeWidth - visibleWidth(label) - 1);
|
|
139
|
+
const count = [...this.value].length;
|
|
140
|
+
const hidden = count > available;
|
|
141
|
+
const showEllipsis = hidden && available > 0;
|
|
142
|
+
const visibleCount = Math.max(0, available - (showEllipsis ? 1 : 0));
|
|
143
|
+
const mask = `${showEllipsis ? "…" : ""}${"•".repeat(Math.min(count, visibleCount))}`;
|
|
144
|
+
return [
|
|
145
|
+
truncateToWidth(this.theme.fg("accent", this.theme.bold(this.title)), safeWidth, ""),
|
|
146
|
+
truncateToWidth(`${label}${mask}${cursor}`, safeWidth, ""),
|
|
147
|
+
truncateToWidth(this.theme.fg("muted", t(this.language, "maskedHelp")), safeWidth, ""),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
invalidate(): void {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Secret input never delegates to ordinary ctx.ui.input, so plaintext is never rendered. */
|
|
155
|
+
export async function secretInput(ctx: ExtensionCommandContext, language: UiLanguage, title: string): Promise<string | undefined> {
|
|
156
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => new MaskedInput(tui, theme, language, title, done));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function withCancellableLoader<T>(
|
|
160
|
+
ctx: ExtensionCommandContext,
|
|
161
|
+
message: string,
|
|
162
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
163
|
+
): Promise<{ cancelled: true } | { cancelled: false; value?: T; error?: unknown }> {
|
|
164
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
165
|
+
const loader = new CancellableLoader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), message);
|
|
166
|
+
let settled = false;
|
|
167
|
+
const finish = (result: { cancelled: true } | { cancelled: false; value?: T; error?: unknown }) => {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
settled = true;
|
|
170
|
+
done(result);
|
|
171
|
+
};
|
|
172
|
+
loader.onAbort = () => finish({ cancelled: true });
|
|
173
|
+
void work(loader.signal).then((value) => finish({ cancelled: false, value })).catch((error) => {
|
|
174
|
+
if (loader.signal.aborted) finish({ cancelled: true });
|
|
175
|
+
else finish({ cancelled: false, error });
|
|
176
|
+
});
|
|
177
|
+
return loader;
|
|
178
|
+
});
|
|
179
|
+
}
|