@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-model-roles",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Model role configuration library for pi extensions — defines named model roles and resolves them to Model instances",
6
6
  "main": "src/index.ts",
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
- config: ModelRolesConfig | undefined;
18
- currentModel: any;
19
- modelRegistry: any;
18
+ config: ModelRolesConfig | undefined;
19
+ currentModel: any;
20
+ modelRegistry: any;
20
21
  }
21
22
 
22
- export function initModelRolesAPI(modelRegistry: any, currentModel: any, cwd?: string): ModelRolesAPI {
23
- const state: APIState = {
24
- config: undefined,
25
- currentModel,
26
- modelRegistry,
27
- };
28
-
29
- function getConfig(): ModelRolesConfig {
30
- if (!state.config) {
31
- state.config = loadRolesConfig(cwd);
32
- }
33
- return state.config;
34
- }
35
-
36
- const api: ModelRolesAPI = {
37
- getRoles(): Record<string, RoleConfig> {
38
- return getConfig().roles;
39
- },
40
-
41
- getRole(name: string): RoleConfig | undefined {
42
- return getConfig().roles[name];
43
- },
44
-
45
- resolveRole(name: string): ResolvedRole {
46
- const roleConfig = getConfig().roles[name];
47
- if (!roleConfig) {
48
- return {
49
- name,
50
- config: { model: null },
51
- model: state.currentModel,
52
- apiKey: undefined,
53
- headers: undefined,
54
- };
55
- }
56
-
57
- const resolved = resolveModelForRole(roleConfig, state.modelRegistry, state.currentModel);
58
- return { name, config: roleConfig, ...resolved };
59
- },
60
-
61
- async resolveRoleAsync(name: string): Promise<ResolvedRole> {
62
- const roleConfig = getConfig().roles[name];
63
- if (!roleConfig) {
64
- if (state.currentModel) {
65
- const auth = await state.modelRegistry.getApiKeyAndHeaders(state.currentModel);
66
- return {
67
- name,
68
- config: { model: null },
69
- model: state.currentModel,
70
- apiKey: auth.ok ? auth.apiKey : undefined,
71
- headers: auth.ok ? auth.headers : undefined,
72
- };
73
- }
74
- return {
75
- name,
76
- config: { model: null },
77
- model: undefined,
78
- apiKey: undefined,
79
- headers: undefined,
80
- };
81
- }
82
-
83
- const resolved = await resolveModelForRoleAsync(roleConfig, state.modelRegistry, state.currentModel);
84
- if (!resolved) {
85
- return {
86
- name,
87
- config: roleConfig,
88
- model: undefined,
89
- apiKey: undefined,
90
- headers: undefined,
91
- };
92
- }
93
-
94
- return { name, config: roleConfig, ...resolved };
95
- },
96
-
97
- getDefaultRole(): string {
98
- return getConfig().defaultRole ?? "default";
99
- },
100
-
101
- getVisibleRoles(): Record<string, RoleConfig> {
102
- const roles = getConfig().roles;
103
- const result: Record<string, RoleConfig> = {};
104
- for (const [name, config] of Object.entries(roles)) {
105
- if (!config.hidden) {
106
- result[name] = config;
107
- }
108
- }
109
- return result;
110
- },
111
-
112
- findRoleByModel(modelId: string): string | undefined {
113
- const roles = getConfig().roles;
114
- for (const [name, config] of Object.entries(roles)) {
115
- if (config.model === modelId) {
116
- return name;
117
- }
118
- }
119
- return undefined;
120
- },
121
-
122
- getCurrentRole(modelId: string): string | undefined {
123
- const roles = getConfig().roles;
124
- // 1. Exact match wins: a role explicitly bound to this model.
125
- const exact = Object.entries(roles).find(([, c]) => c.model === modelId)?.[0];
126
- if (exact) return exact;
127
- // 2. Default role — when model=null it transparently uses the current
128
- // model, so it is the meaningful base in the all-null config.
129
- const defaultName = getConfig().defaultRole ?? "default";
130
- const defaultConfig = roles[defaultName];
131
- if (defaultConfig && !defaultConfig.model) return defaultName;
132
- // 3. First model=null role (reached only when default is bound elsewhere).
133
- const nullRole = Object.entries(roles).find(([, c]) => !c.model)?.[0];
134
- return nullRole;
135
- },
136
- };
137
-
138
- // Store on globalThis — survives module identity mismatches
139
- (globalThis as any)[GLOBAL_KEY] = api;
140
- return api;
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
- const api = (globalThis as any)[GLOBAL_KEY] as ModelRolesAPI | undefined;
148
- if (!api) return;
149
- const state = (api as any).__state as APIState | undefined;
150
- if (state) {
151
- state.currentModel = model;
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
- const api = (globalThis as any)[GLOBAL_KEY] as ModelRolesAPI | undefined;
161
- if (!api) {
162
- throw new Error(
163
- "ModelRolesAPI not initialized. " +
164
- "Ensure @d3ara1n/pi-model-roles extension is loaded and session_start has fired.",
165
- );
166
- }
167
- return api;
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
- const envDir = process.env.PI_AGENT_DIR;
20
- if (envDir) return envDir;
21
- return path.join(os.homedir(), ".pi", "agent");
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
- 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
- }
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
- 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;
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
- // Read global settings
56
- const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
55
+ // Read global settings
56
+ const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
57
57
 
58
- // Read project settings
59
- const projectSettings = cwd
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
- // Merge: project overrides global
64
- const settings = merge(globalSettings, projectSettings);
61
+ // Merge: project overrides global
62
+ const settings = merge(globalSettings, projectSettings);
65
63
 
66
- // Start from built-in defaults
67
- const mergedRoles: Record<string, RoleConfig> = {};
68
- for (const [name, config] of Object.entries(BUILTIN_DEFAULT_ROLES)) {
69
- mergedRoles[name] = { ...config };
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
- // Merge user config over defaults
73
- const userConfig = settings?.modelRoles;
74
- if (userConfig?.roles && typeof userConfig.roles === "object") {
75
- for (const [name, config] of Object.entries(userConfig.roles as Record<string, Partial<RoleConfig>>)) {
76
- if (mergedRoles[name]) {
77
- mergedRoles[name] = { ...mergedRoles[name], ...config };
78
- } else {
79
- mergedRoles[name] = config as RoleConfig;
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
- return {
85
- roles: mergedRoles,
86
- defaultRole: userConfig?.defaultRole ?? DEFAULT_ROLE_NAME,
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: "General development tasks: writing new features, modifying existing code, code review, adding tests, routine debugging, file-level changes",
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: "Tasks requiring deep thinking: cross-file refactoring, architecture design, complex bug debugging, performance optimization, security analysis, database schema changes, multi-module migrations",
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: "Simple deterministic tasks: one-line edits, formatting tweaks, simple Q&A, doc lookups, git operations, confirmation replies",
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: "Lightweight utilities: model routing, commit generation, title/summary, etc. (hidden)",
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 { ModelRolesAPI, RoleConfig, ResolvedRole, ModelRolesConfig, ThinkingLevel } from "./types.ts";
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
- pi.on("session_start", async (_event, ctx) => {
16
- initModelRolesAPI(ctx.modelRegistry, ctx.model, ctx.cwd);
17
- });
18
-
19
- pi.on("model_select", async (event) => {
20
- updateCurrentModel(event.model);
21
- });
22
-
23
- pi.registerCommand("roles", {
24
- description: "Show model role definitions and resolved models",
25
- handler: async (_args, ctx) => {
26
- const api = getModelRolesAPI();
27
- const roles = api.getRoles();
28
- const lines: string[] = ["Model Roles:", ""];
29
-
30
- for (const [name, config] of Object.entries(roles)) {
31
- const resolved = api.resolveRole(name);
32
- const hidden = config.hidden ? " (hidden)" : "";
33
- const modelLabel = resolved.model
34
- ? `${resolved.model.provider}/${resolved.model.id}`
35
- : config.model === null
36
- ? "→ current model"
37
- : `→ NOT FOUND (${config.model})`;
38
- const thinking = config.thinking ? ` thinking:${config.thinking}` : "";
39
- lines.push(` ${name}: ${modelLabel}${thinking}${hidden}`);
40
- }
41
-
42
- lines.push("");
43
- lines.push(`Default role: ${api.getDefaultRole()}`);
44
-
45
- ctx.ui.notify(lines.join("\n"), "info");
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
- getAvailable(): any[];
13
- getApiKeyAndHeaders(model: any): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string> }>;
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
- provider: string;
18
- id: string;
19
+ provider: string;
20
+ id: string;
19
21
  }
20
22
 
21
23
  function parseModelIdentifier(modelRef: string): { provider: string | undefined; modelId: string } {
22
- const parts = modelRef.split("/");
23
- if (parts.length > 1) {
24
- return { provider: parts[0], modelId: parts.slice(1).join("/") };
25
- }
26
- return { provider: undefined, modelId: parts[0] };
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
- const { provider, modelId } = parseModelIdentifier(modelRef);
31
- return available.find((m) => {
32
- if (provider) return m.provider === provider && m.id === modelId;
33
- return m.id === modelId;
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
- roleConfig: RoleConfig,
42
- modelRegistry: ModelRegistryLike,
43
- currentModel: any | undefined,
43
+ roleConfig: RoleConfig,
44
+ modelRegistry: ModelRegistryLike,
45
+ currentModel: any | undefined,
44
46
  ): Pick<ResolvedRole, "model" | "apiKey" | "headers"> {
45
- // model=null: fill with pi's current model
46
- if (!roleConfig.model) {
47
- return { model: currentModel, apiKey: undefined, headers: undefined };
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
- const match = findModel(roleConfig.model, modelRegistry.getAvailable());
51
- return { model: match ?? undefined, apiKey: undefined, headers: undefined };
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
- roleConfig: RoleConfig,
59
- modelRegistry: ModelRegistryLike,
60
- currentModel: any | undefined,
60
+ roleConfig: RoleConfig,
61
+ modelRegistry: ModelRegistryLike,
62
+ currentModel: any | undefined,
61
63
  ): Promise<Pick<ResolvedRole, "model" | "apiKey" | "headers"> | undefined> {
62
- // model=null: fill with pi's current model + its auth
63
- if (!roleConfig.model) {
64
- if (!currentModel) return undefined;
65
- const auth = await modelRegistry.getApiKeyAndHeaders(currentModel);
66
- if (!auth.ok) return undefined;
67
- return { model: currentModel, apiKey: auth.apiKey, headers: auth.headers };
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
- const available = modelRegistry.getAvailable();
71
- const match = findModel(roleConfig.model, available);
72
- if (!match) return undefined;
72
+ const available = modelRegistry.getAvailable();
73
+ const match = findModel(roleConfig.model, available);
74
+ if (!match) return undefined;
73
75
 
74
- const auth = await modelRegistry.getApiKeyAndHeaders(match);
75
- if (!auth.ok) return undefined;
76
+ const auth = await modelRegistry.getApiKeyAndHeaders(match);
77
+ if (!auth.ok) return undefined;
76
78
 
77
- return { model: match, apiKey: auth.apiKey, headers: auth.headers };
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
  }