@d3ara1n/pi-model-roles 0.3.1 → 0.5.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 +4 -0
- package/package.json +1 -1
- package/src/api.ts +193 -136
- package/src/config.ts +49 -49
- package/src/defaults.ts +8 -4
- package/src/index.ts +66 -34
- package/src/resolver.ts +41 -39
- package/src/types.ts +54 -0
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ Defines named model roles (e.g. "heavy", "fast", "utility") and resolves them to
|
|
|
11
11
|
- Exposes a `ModelRolesAPI` singleton for other extensions to consume via direct import
|
|
12
12
|
- **Pure library**: no tools, no commands, no event hooks
|
|
13
13
|
|
|
14
|
+
## Dependencies
|
|
15
|
+
|
|
16
|
+
None.
|
|
17
|
+
|
|
14
18
|
## Installation
|
|
15
19
|
|
|
16
20
|
```bash
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Exported functions provide type-safe access — consumers never touch globalThis.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { complete as piAiComplete, streamSimple as piAiStreamSimple } from "@earendil-works/pi-ai";
|
|
9
10
|
import type { ModelRolesAPI, ModelRolesConfig, RoleConfig, ResolvedRole } from "./types.ts";
|
|
10
11
|
import { loadRolesConfig } from "./config.ts";
|
|
11
12
|
import { resolveModelForRole, resolveModelForRoleAsync } from "./resolver.ts";
|
|
@@ -14,142 +15,198 @@ const GLOBAL_KEY = "__piModelRoles";
|
|
|
14
15
|
|
|
15
16
|
/** Mutable state. */
|
|
16
17
|
interface APIState {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
config: ModelRolesConfig | undefined;
|
|
19
|
+
currentModel: any;
|
|
20
|
+
modelRegistry: any;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function initModelRolesAPI(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
23
|
+
export function initModelRolesAPI(
|
|
24
|
+
modelRegistry: any,
|
|
25
|
+
currentModel: any,
|
|
26
|
+
cwd?: string,
|
|
27
|
+
): ModelRolesAPI {
|
|
28
|
+
const state: APIState = {
|
|
29
|
+
config: undefined,
|
|
30
|
+
currentModel,
|
|
31
|
+
modelRegistry,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function getConfig(): ModelRolesConfig {
|
|
35
|
+
if (!state.config) {
|
|
36
|
+
state.config = loadRolesConfig(cwd);
|
|
37
|
+
}
|
|
38
|
+
return state.config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const api: ModelRolesAPI = {
|
|
42
|
+
getRoles(): Record<string, RoleConfig> {
|
|
43
|
+
return getConfig().roles;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
getRole(name: string): RoleConfig | undefined {
|
|
47
|
+
return getConfig().roles[name];
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
resolveRole(name: string): ResolvedRole {
|
|
51
|
+
const roleConfig = getConfig().roles[name];
|
|
52
|
+
if (!roleConfig) {
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
config: { model: null },
|
|
56
|
+
model: state.currentModel,
|
|
57
|
+
apiKey: undefined,
|
|
58
|
+
headers: undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resolved = resolveModelForRole(roleConfig, state.modelRegistry, state.currentModel);
|
|
63
|
+
return { name, config: roleConfig, ...resolved };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async resolveRoleAsync(name: string): Promise<ResolvedRole> {
|
|
67
|
+
const roleConfig = getConfig().roles[name];
|
|
68
|
+
if (!roleConfig) {
|
|
69
|
+
if (state.currentModel) {
|
|
70
|
+
const auth = await state.modelRegistry.getApiKeyAndHeaders(state.currentModel);
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
config: { model: null },
|
|
74
|
+
model: state.currentModel,
|
|
75
|
+
apiKey: auth.ok ? auth.apiKey : undefined,
|
|
76
|
+
headers: auth.ok ? auth.headers : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
config: { model: null },
|
|
82
|
+
model: undefined,
|
|
83
|
+
apiKey: undefined,
|
|
84
|
+
headers: undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const resolved = await resolveModelForRoleAsync(
|
|
89
|
+
roleConfig,
|
|
90
|
+
state.modelRegistry,
|
|
91
|
+
state.currentModel,
|
|
92
|
+
);
|
|
93
|
+
if (!resolved) {
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
config: roleConfig,
|
|
97
|
+
model: undefined,
|
|
98
|
+
apiKey: undefined,
|
|
99
|
+
headers: undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { name, config: roleConfig, ...resolved };
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
getDefaultRole(): string {
|
|
107
|
+
return getConfig().defaultRole ?? "default";
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
getVisibleRoles(): Record<string, RoleConfig> {
|
|
111
|
+
const roles = getConfig().roles;
|
|
112
|
+
const result: Record<string, RoleConfig> = {};
|
|
113
|
+
for (const [name, config] of Object.entries(roles)) {
|
|
114
|
+
if (!config.hidden) {
|
|
115
|
+
result[name] = config;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
findRoleByModel(modelId: string): string | undefined {
|
|
122
|
+
const roles = getConfig().roles;
|
|
123
|
+
for (const [name, config] of Object.entries(roles)) {
|
|
124
|
+
if (config.model === modelId) {
|
|
125
|
+
return name;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
getCurrentRole(modelId: string): string | undefined {
|
|
132
|
+
const roles = getConfig().roles;
|
|
133
|
+
// 1. Exact match wins: a role explicitly bound to this model.
|
|
134
|
+
const exact = Object.entries(roles).find(([, c]) => c.model === modelId)?.[0];
|
|
135
|
+
if (exact) return exact;
|
|
136
|
+
// 2. Default role — when model=null it transparently uses the current
|
|
137
|
+
// model, so it is the meaningful base in the all-null config.
|
|
138
|
+
const defaultName = getConfig().defaultRole ?? "default";
|
|
139
|
+
const defaultConfig = roles[defaultName];
|
|
140
|
+
if (defaultConfig && !defaultConfig.model) return defaultName;
|
|
141
|
+
// 3. First model=null role (reached only when default is bound elsewhere).
|
|
142
|
+
const nullRole = Object.entries(roles).find(([, c]) => !c.model)?.[0];
|
|
143
|
+
return nullRole;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
listModels(): string[] {
|
|
147
|
+
return state.modelRegistry
|
|
148
|
+
.getAvailable()
|
|
149
|
+
.map((m: { provider: string; id: string }) => `${m.provider}/${m.id}`);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async complete(roleName: string, context: any, options?: any): Promise<any> {
|
|
153
|
+
// Resolve model: explicit override wins, else the role's declared model
|
|
154
|
+
// (model=null transparently uses pi's current model).
|
|
155
|
+
const roleConfig = getConfig().roles[roleName] ?? { model: null };
|
|
156
|
+
const model =
|
|
157
|
+
options?.model ??
|
|
158
|
+
resolveModelForRole(roleConfig, state.modelRegistry, state.currentModel).model;
|
|
159
|
+
if (!model) {
|
|
160
|
+
throw new Error(`complete: role "${roleName}" has no available model`);
|
|
161
|
+
}
|
|
162
|
+
// Resolve auth for the model actually used (refreshes OAuth tokens).
|
|
163
|
+
const auth = await state.modelRegistry.getApiKeyAndHeaders(model);
|
|
164
|
+
if (!auth.ok) {
|
|
165
|
+
throw new Error(`complete: auth failed for ${model.provider}/${model.id}: ${auth.error}`);
|
|
166
|
+
}
|
|
167
|
+
// Forward everything except `model` to pi-ai's complete().
|
|
168
|
+
const { model: _omitModel, ...streamOptions } = options ?? {};
|
|
169
|
+
if (auth.apiKey) streamOptions.apiKey = auth.apiKey;
|
|
170
|
+
if (auth.headers) streamOptions.headers = auth.headers;
|
|
171
|
+
return piAiComplete(model, context, streamOptions);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async streamWithRole(roleName: string, context: any, options?: any): Promise<any> {
|
|
175
|
+
const roleConfig = getConfig().roles[roleName] ?? { model: null };
|
|
176
|
+
const model =
|
|
177
|
+
options?.model ??
|
|
178
|
+
resolveModelForRole(roleConfig, state.modelRegistry, state.currentModel).model;
|
|
179
|
+
if (!model) {
|
|
180
|
+
throw new Error(`streamWithRole: role "${roleName}" has no available model`);
|
|
181
|
+
}
|
|
182
|
+
const auth = await state.modelRegistry.getApiKeyAndHeaders(model);
|
|
183
|
+
if (!auth.ok) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`streamWithRole: auth failed for ${model.provider}/${model.id}: ${auth.error}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const { model: _omitModel, ...streamOptions } = options ?? {};
|
|
189
|
+
if (auth.apiKey) streamOptions.apiKey = auth.apiKey;
|
|
190
|
+
if (auth.headers) streamOptions.headers = auth.headers;
|
|
191
|
+
return piAiStreamSimple(model, context, streamOptions);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Store on globalThis — survives module identity mismatches
|
|
196
|
+
(globalThis as any)[GLOBAL_KEY] = api;
|
|
197
|
+
return api;
|
|
141
198
|
}
|
|
142
199
|
|
|
143
200
|
/**
|
|
144
201
|
* Update the tracked current model.
|
|
145
202
|
*/
|
|
146
203
|
export function updateCurrentModel(model: any): void {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
const api = (globalThis as any)[GLOBAL_KEY] as ModelRolesAPI | undefined;
|
|
205
|
+
if (!api) return;
|
|
206
|
+
const state = (api as any).__state as APIState | undefined;
|
|
207
|
+
if (state) {
|
|
208
|
+
state.currentModel = model;
|
|
209
|
+
}
|
|
153
210
|
}
|
|
154
211
|
|
|
155
212
|
/**
|
|
@@ -157,12 +214,12 @@ export function updateCurrentModel(model: any): void {
|
|
|
157
214
|
* Throws if initModelRolesAPI() has not been called yet.
|
|
158
215
|
*/
|
|
159
216
|
export function getModelRolesAPI(): ModelRolesAPI {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
217
|
+
const api = (globalThis as any)[GLOBAL_KEY] as ModelRolesAPI | undefined;
|
|
218
|
+
if (!api) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
"ModelRolesAPI not initialized. " +
|
|
221
|
+
"Ensure @d3ara1n/pi-model-roles extension is loaded and session_start has fired.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return api;
|
|
168
225
|
}
|
package/src/config.ts
CHANGED
|
@@ -16,35 +16,35 @@ const DEFAULT_ROLE_NAME = "default";
|
|
|
16
16
|
|
|
17
17
|
/** Get the pi agent directory path. */
|
|
18
18
|
function getAgentDir(): string {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const envDir = process.env.PI_AGENT_DIR;
|
|
20
|
+
if (envDir) return envDir;
|
|
21
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Read and parse a settings.json file. Returns parsed object or empty. */
|
|
25
25
|
function readSettingsFile(filePath: string): any {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(filePath)) return {};
|
|
28
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
29
|
+
return JSON.parse(content);
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Deep merge source into target (source wins on conflict). Only handles plain objects. */
|
|
36
36
|
function merge(target: any, source: any): any {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
if (!source || typeof source !== "object") return target;
|
|
38
|
+
if (!target || typeof target !== "object") return source;
|
|
39
|
+
const result = { ...target };
|
|
40
|
+
for (const key of Object.keys(source)) {
|
|
41
|
+
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
42
|
+
result[key] = merge(result[key], source[key]);
|
|
43
|
+
} else {
|
|
44
|
+
result[key] = source[key];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -52,37 +52,37 @@ function merge(target: any, source: any): any {
|
|
|
52
52
|
* @param cwd - Project working directory (for .pi/settings.json lookup)
|
|
53
53
|
*/
|
|
54
54
|
export function loadRolesConfig(cwd?: string): ModelRolesConfig {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// Read global settings
|
|
56
|
+
const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
? readSettingsFile(path.join(cwd, ".pi", "settings.json"))
|
|
61
|
-
: {};
|
|
58
|
+
// Read project settings
|
|
59
|
+
const projectSettings = cwd ? readSettingsFile(path.join(cwd, ".pi", "settings.json")) : {};
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Merge: project overrides global
|
|
62
|
+
const settings = merge(globalSettings, projectSettings);
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
// Start from built-in defaults
|
|
65
|
+
const mergedRoles: Record<string, RoleConfig> = {};
|
|
66
|
+
for (const [name, config] of Object.entries(BUILTIN_DEFAULT_ROLES)) {
|
|
67
|
+
mergedRoles[name] = { ...config };
|
|
68
|
+
}
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
// Merge user config over defaults
|
|
71
|
+
const userConfig = settings?.modelRoles;
|
|
72
|
+
if (userConfig?.roles && typeof userConfig.roles === "object") {
|
|
73
|
+
for (const [name, config] of Object.entries(
|
|
74
|
+
userConfig.roles as Record<string, Partial<RoleConfig>>,
|
|
75
|
+
)) {
|
|
76
|
+
if (mergedRoles[name]) {
|
|
77
|
+
mergedRoles[name] = { ...mergedRoles[name], ...config };
|
|
78
|
+
} else {
|
|
79
|
+
mergedRoles[name] = config as RoleConfig;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
return {
|
|
85
|
+
roles: mergedRoles,
|
|
86
|
+
defaultRole: userConfig?.defaultRole ?? DEFAULT_ROLE_NAME,
|
|
87
|
+
};
|
|
88
88
|
}
|
package/src/defaults.ts
CHANGED
|
@@ -13,22 +13,26 @@ import type { RoleConfig } from "./types.ts";
|
|
|
13
13
|
export const BUILTIN_DEFAULT_ROLES: Record<string, RoleConfig> = {
|
|
14
14
|
default: {
|
|
15
15
|
model: null,
|
|
16
|
-
description:
|
|
16
|
+
description:
|
|
17
|
+
"General development tasks: writing new features, modifying existing code, code review, adding tests, routine debugging, file-level changes",
|
|
17
18
|
thinking: "medium",
|
|
18
19
|
},
|
|
19
20
|
heavy: {
|
|
20
21
|
model: null,
|
|
21
|
-
description:
|
|
22
|
+
description:
|
|
23
|
+
"Tasks requiring deep thinking: cross-file refactoring, architecture design, complex bug debugging, performance optimization, security analysis, database schema changes, multi-module migrations",
|
|
22
24
|
thinking: "high",
|
|
23
25
|
},
|
|
24
26
|
fast: {
|
|
25
27
|
model: null,
|
|
26
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
"Simple deterministic tasks: one-line edits, formatting tweaks, simple Q&A, doc lookups, git operations, confirmation replies",
|
|
27
30
|
thinking: "low",
|
|
28
31
|
},
|
|
29
32
|
utility: {
|
|
30
33
|
model: null,
|
|
31
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
"Lightweight utilities: model routing, commit generation, title/summary, etc. (hidden)",
|
|
32
36
|
thinking: "off",
|
|
33
37
|
hidden: true,
|
|
34
38
|
},
|
package/src/index.ts
CHANGED
|
@@ -6,43 +6,75 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Type } from "typebox";
|
|
9
10
|
import { initModelRolesAPI, getModelRolesAPI, updateCurrentModel } from "./api.ts";
|
|
10
11
|
|
|
11
12
|
export { getModelRolesAPI } from "./api.ts";
|
|
12
|
-
export type {
|
|
13
|
+
export type {
|
|
14
|
+
ModelRolesAPI,
|
|
15
|
+
RoleConfig,
|
|
16
|
+
ResolvedRole,
|
|
17
|
+
ModelRolesConfig,
|
|
18
|
+
ThinkingLevel,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
// Re-export pi-ai types so consumers depend on model-roles alone, not pi-ai.
|
|
21
|
+
export type {
|
|
22
|
+
Api,
|
|
23
|
+
AssistantMessage,
|
|
24
|
+
AssistantMessageEventStream,
|
|
25
|
+
Context,
|
|
26
|
+
Model,
|
|
27
|
+
ProviderStreamOptions,
|
|
28
|
+
SimpleStreamOptions,
|
|
29
|
+
} from "@earendil-works/pi-ai";
|
|
13
30
|
|
|
14
31
|
export default function registerModelRolesExtension(pi: ExtensionAPI): void {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
33
|
+
initModelRolesAPI(ctx.modelRegistry, ctx.model, ctx.cwd);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("model_select", async (event) => {
|
|
37
|
+
updateCurrentModel(event.model);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.registerTool({
|
|
41
|
+
name: "list_models",
|
|
42
|
+
label: "List available models",
|
|
43
|
+
description: "List all available models from pi's model registry. Returns provider/model-id strings (e.g. 'anthropic/claude-sonnet-4'). Useful for confirming model IDs before referencing a model by name.",
|
|
44
|
+
parameters: Type.Object({}),
|
|
45
|
+
async execute() {
|
|
46
|
+
const api = getModelRolesAPI();
|
|
47
|
+
const models = api.listModels();
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: "text", text: models.join("\n") }],
|
|
50
|
+
details: undefined as any,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
pi.registerCommand("roles", {
|
|
56
|
+
description: "Show model role definitions and resolved models",
|
|
57
|
+
handler: async (_args, ctx) => {
|
|
58
|
+
const api = getModelRolesAPI();
|
|
59
|
+
const roles = api.getRoles();
|
|
60
|
+
const lines: string[] = ["Model Roles:", ""];
|
|
61
|
+
|
|
62
|
+
for (const [name, config] of Object.entries(roles)) {
|
|
63
|
+
const resolved = api.resolveRole(name);
|
|
64
|
+
const hidden = config.hidden ? " (hidden)" : "";
|
|
65
|
+
const modelLabel = resolved.model
|
|
66
|
+
? `${resolved.model.provider}/${resolved.model.id}`
|
|
67
|
+
: config.model === null
|
|
68
|
+
? "→ current model"
|
|
69
|
+
: `→ NOT FOUND (${config.model})`;
|
|
70
|
+
const thinking = config.thinking ? ` thinking:${config.thinking}` : "";
|
|
71
|
+
lines.push(` ${name}: ${modelLabel}${thinking}${hidden}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push(`Default role: ${api.getDefaultRole()}`);
|
|
76
|
+
|
|
77
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
78
|
+
},
|
|
79
|
+
});
|
|
48
80
|
}
|
package/src/resolver.ts
CHANGED
|
@@ -9,70 +9,72 @@ import type { RoleConfig, ResolvedRole } from "./types.ts";
|
|
|
9
9
|
|
|
10
10
|
/** Minimal interface from ModelRegistry. */
|
|
11
11
|
interface ModelRegistryLike {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
getAvailable(): any[];
|
|
13
|
+
getApiKeyAndHeaders(
|
|
14
|
+
model: any,
|
|
15
|
+
): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string> }>;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface ModelLike {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
provider: string;
|
|
20
|
+
id: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function parseModelIdentifier(modelRef: string): { provider: string | undefined; modelId: string } {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const parts = modelRef.split("/");
|
|
25
|
+
if (parts.length > 1) {
|
|
26
|
+
return { provider: parts[0], modelId: parts.slice(1).join("/") };
|
|
27
|
+
}
|
|
28
|
+
return { provider: undefined, modelId: parts[0] };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function findModel(modelRef: string, available: ModelLike[]): ModelLike | undefined {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const { provider, modelId } = parseModelIdentifier(modelRef);
|
|
33
|
+
return available.find((m) => {
|
|
34
|
+
if (provider) return m.provider === provider && m.id === modelId;
|
|
35
|
+
return m.id === modelId;
|
|
36
|
+
});
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* Sync resolve. model=null → uses currentModel if provided, else undefined.
|
|
39
41
|
*/
|
|
40
42
|
export function resolveModelForRole(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
roleConfig: RoleConfig,
|
|
44
|
+
modelRegistry: ModelRegistryLike,
|
|
45
|
+
currentModel: any | undefined,
|
|
44
46
|
): Pick<ResolvedRole, "model" | "apiKey" | "headers"> {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// model=null: fill with pi's current model
|
|
48
|
+
if (!roleConfig.model) {
|
|
49
|
+
return { model: currentModel, apiKey: undefined, headers: undefined };
|
|
50
|
+
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
const match = findModel(roleConfig.model, modelRegistry.getAvailable());
|
|
53
|
+
return { model: match ?? undefined, apiKey: undefined, headers: undefined };
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Async resolve with auth. model=null → resolves currentModel's auth too.
|
|
56
58
|
*/
|
|
57
59
|
export async function resolveModelForRoleAsync(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
roleConfig: RoleConfig,
|
|
61
|
+
modelRegistry: ModelRegistryLike,
|
|
62
|
+
currentModel: any | undefined,
|
|
61
63
|
): Promise<Pick<ResolvedRole, "model" | "apiKey" | "headers"> | undefined> {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
// model=null: fill with pi's current model + its auth
|
|
65
|
+
if (!roleConfig.model) {
|
|
66
|
+
if (!currentModel) return undefined;
|
|
67
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(currentModel);
|
|
68
|
+
if (!auth.ok) return undefined;
|
|
69
|
+
return { model: currentModel, apiKey: auth.apiKey, headers: auth.headers };
|
|
70
|
+
}
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
const available = modelRegistry.getAvailable();
|
|
73
|
+
const match = findModel(roleConfig.model, available);
|
|
74
|
+
if (!match) return undefined;
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(match);
|
|
77
|
+
if (!auth.ok) return undefined;
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
return { model: match, apiKey: auth.apiKey, headers: auth.headers };
|
|
78
80
|
}
|
package/src/types.ts
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
* Shared types for pi-model-roles.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type {
|
|
6
|
+
Api,
|
|
7
|
+
AssistantMessage,
|
|
8
|
+
AssistantMessageEventStream,
|
|
9
|
+
Context,
|
|
10
|
+
Model,
|
|
11
|
+
ProviderStreamOptions,
|
|
12
|
+
SimpleStreamOptions,
|
|
13
|
+
} from "@earendil-works/pi-ai";
|
|
14
|
+
|
|
5
15
|
/** Thinking level configuration for a role. */
|
|
6
16
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
7
17
|
|
|
@@ -95,4 +105,48 @@ export interface ModelRolesAPI {
|
|
|
95
105
|
* Returns undefined only if no role matches by any rule.
|
|
96
106
|
*/
|
|
97
107
|
getCurrentRole(modelId: string): string | undefined;
|
|
108
|
+
/** List all available models from pi's model registry. Returns provider/id strings (e.g. "anthropic/claude-sonnet-4"). */
|
|
109
|
+
listModels(): string[];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Call pi-ai's complete() with auth resolved internally from the role's model.
|
|
113
|
+
*
|
|
114
|
+
* Auth (including OAuth token refresh) is resolved for the model actually
|
|
115
|
+
* used, so callers never handle API keys or headers. The underlying call is
|
|
116
|
+
* pi-ai's complete() verbatim — no fallback, no retry, no error swallowing;
|
|
117
|
+
* throws if the role has no available model or auth resolution fails.
|
|
118
|
+
*
|
|
119
|
+
* @param roleName - Role whose model + auth to use
|
|
120
|
+
* @param context - Conversation context (systemPrompt + messages)
|
|
121
|
+
* @param options - Stream options forwarded to pi-ai's complete(). Pass
|
|
122
|
+
* `model` to override the role's model (auth is then resolved for that
|
|
123
|
+
* model); all other fields pass through unchanged.
|
|
124
|
+
*/
|
|
125
|
+
complete<TApi extends Api = Api>(
|
|
126
|
+
roleName: string,
|
|
127
|
+
context: Context,
|
|
128
|
+
options?: ProviderStreamOptions & { model?: Model<TApi> },
|
|
129
|
+
): Promise<AssistantMessage>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Call pi-ai's streamSimple() with auth resolved internally from the role's model.
|
|
133
|
+
*
|
|
134
|
+
* Streaming counterpart to {@link complete}. Same auth resolution (including
|
|
135
|
+
* OAuth token refresh) and same error semantics (throws on no model / auth
|
|
136
|
+
* failure). Returns the pi-ai event stream verbatim — iterate it for events,
|
|
137
|
+
* call `.result()` for the final AssistantMessage.
|
|
138
|
+
*
|
|
139
|
+
* Note: unlike pi-ai's synchronous `streamSimple`, this is async because auth
|
|
140
|
+
* resolution (OAuth token refresh) must complete before the stream starts.
|
|
141
|
+
*
|
|
142
|
+
* @param roleName - Role whose model + auth to use
|
|
143
|
+
* @param context - Conversation context (systemPrompt + messages)
|
|
144
|
+
* @param options - Stream options forwarded to pi-ai's streamSimple(). Pass
|
|
145
|
+
* `model` to override the role's model; all other fields pass through.
|
|
146
|
+
*/
|
|
147
|
+
streamWithRole<TApi extends Api = Api>(
|
|
148
|
+
roleName: string,
|
|
149
|
+
context: Context,
|
|
150
|
+
options?: SimpleStreamOptions & { model?: Model<TApi> },
|
|
151
|
+
): Promise<AssistantMessageEventStream>;
|
|
98
152
|
}
|