@d3ara1n/pi-model-roles 0.1.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 +100 -0
- package/package.json +35 -0
- package/src/api.ts +153 -0
- package/src/config.ts +90 -0
- package/src/defaults.ts +29 -0
- package/src/index.ts +48 -0
- package/src/resolver.ts +78 -0
- package/src/types.ts +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @d3ara1n/pi-model-roles
|
|
2
|
+
|
|
3
|
+
Model role configuration library for [pi](https://github.com/earendil-works/pi) extensions.
|
|
4
|
+
|
|
5
|
+
Defines named model roles (e.g. "heavy", "fast", "side") and resolves them to pi `Model` instances with API key and headers.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Reads role definitions from `~/.pi/agent/settings.json` → `modelRoles` field
|
|
10
|
+
- Resolves role names to `Model<Api>` instances via pi's `ModelRegistry`
|
|
11
|
+
- Exposes a `ModelRolesAPI` singleton for other extensions to consume via direct import
|
|
12
|
+
- **Pure library**: no tools, no commands, no event hooks
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi extension add @d3ara1n/pi-model-roles
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Default Roles
|
|
21
|
+
|
|
22
|
+
Works out of the box — no configuration required.
|
|
23
|
+
Built-in defaults use `model: null` (use pi's current model, don't switch):
|
|
24
|
+
|
|
25
|
+
| Role | model | thinking | Description |
|
|
26
|
+
|------|-------|----------|-------------|
|
|
27
|
+
| `default` | null | medium | Daily coding |
|
|
28
|
+
| `heavy` | null | high | Architecture, deep debugging |
|
|
29
|
+
| `fast` | null | off | Quick edits, simple Q&A |
|
|
30
|
+
|
|
31
|
+
`model: null` means "keep using whatever model pi currently has".
|
|
32
|
+
Only `thinking` level differs between roles by default.
|
|
33
|
+
|
|
34
|
+
Custom roles (e.g. `side` for scout) can be added freely — any role name works:
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Override specific roles in `~/.pi/agent/settings.json`:
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
{
|
|
42
|
+
"modelRoles": {
|
|
43
|
+
"roles": {
|
|
44
|
+
"heavy": {
|
|
45
|
+
"model": "anthropic/claude-opus-4"
|
|
46
|
+
},
|
|
47
|
+
"fast": {
|
|
48
|
+
"model": "google/gemini-2.5-flash",
|
|
49
|
+
"thinking": "off"
|
|
50
|
+
},
|
|
51
|
+
// Custom role for scout's side agent
|
|
52
|
+
"side": {
|
|
53
|
+
"model": "deepseek/deepseek-v4-flash",
|
|
54
|
+
"thinking": "off",
|
|
55
|
+
"hidden": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"defaultRole": "default"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
User settings **merge** with built-in defaults: only override roles you want to change.
|
|
64
|
+
You can also add entirely new roles.
|
|
65
|
+
|
|
66
|
+
### Role fields
|
|
67
|
+
|
|
68
|
+
| Field | Type | Default | Description |
|
|
69
|
+
|-------|------|---------|-------------|
|
|
70
|
+
| `model` | `string \| null` | `null` | `"provider/model-id"` or `null` = use current model |
|
|
71
|
+
| `thinking` | `string` | | `"off"` `"minimal"` `"low"` `"medium"` `"high"` `"xhigh"` |
|
|
72
|
+
| `description` | `string` | | Human-readable description |
|
|
73
|
+
| `tools` | `string` | | Comma-separated default tool list |
|
|
74
|
+
| `hidden` | `boolean` | `false` | Hide from user-facing listings |
|
|
75
|
+
| `systemPromptAppend` | `string` | | Extra system prompt content |
|
|
76
|
+
|
|
77
|
+
## API (for extension authors)
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
81
|
+
import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
82
|
+
|
|
83
|
+
const roles: ModelRolesAPI = getModelRolesAPI();
|
|
84
|
+
|
|
85
|
+
// Resolve a role — always returns a real model or undefined
|
|
86
|
+
const resolved = await roles.resolveRoleAsync("heavy");
|
|
87
|
+
if (resolved.model) {
|
|
88
|
+
// Use resolved.model, resolved.apiKey, resolved.headers
|
|
89
|
+
// model=null in config is transparently resolved to pi's current model
|
|
90
|
+
} else {
|
|
91
|
+
// Model not available
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Reverse lookup
|
|
95
|
+
roles.findRoleByModel("anthropic/claude-opus-4"); // "heavy"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@d3ara1n/pi-model-roles",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model role configuration library for pi extensions — defines named model roles and resolves them to Model instances",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/types.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi"
|
|
10
|
+
],
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@earendil-works/pi-ai": "*",
|
|
13
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
14
|
+
},
|
|
15
|
+
"peerDependenciesMeta": {
|
|
16
|
+
"@earendil-works/pi-ai": {
|
|
17
|
+
"optional": true
|
|
18
|
+
},
|
|
19
|
+
"@earendil-works/pi-coding-agent": {
|
|
20
|
+
"optional": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/d3ara1n/pi-extensions",
|
|
32
|
+
"directory": "packages/pi-model-roles"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelRolesAPI implementation.
|
|
3
|
+
*
|
|
4
|
+
* State stored on globalThis to survive module identity mismatches
|
|
5
|
+
* (extension loaded by absolute path vs import via workspace symlink).
|
|
6
|
+
* Exported functions provide type-safe access — consumers never touch globalThis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ModelRolesAPI, ModelRolesConfig, RoleConfig, ResolvedRole } from "./types.ts";
|
|
10
|
+
import { loadRolesConfig } from "./config.ts";
|
|
11
|
+
import { resolveModelForRole, resolveModelForRoleAsync } from "./resolver.ts";
|
|
12
|
+
|
|
13
|
+
const GLOBAL_KEY = "__piModelRoles";
|
|
14
|
+
|
|
15
|
+
/** Mutable state. */
|
|
16
|
+
interface APIState {
|
|
17
|
+
config: ModelRolesConfig | undefined;
|
|
18
|
+
currentModel: any;
|
|
19
|
+
modelRegistry: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
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
|
+
|
|
123
|
+
// Store on globalThis — survives module identity mismatches
|
|
124
|
+
(globalThis as any)[GLOBAL_KEY] = api;
|
|
125
|
+
return api;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Update the tracked current model.
|
|
130
|
+
*/
|
|
131
|
+
export function updateCurrentModel(model: any): void {
|
|
132
|
+
const api = (globalThis as any)[GLOBAL_KEY] as ModelRolesAPI | undefined;
|
|
133
|
+
if (!api) return;
|
|
134
|
+
const state = (api as any).__state as APIState | undefined;
|
|
135
|
+
if (state) {
|
|
136
|
+
state.currentModel = model;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get the initialized ModelRolesAPI.
|
|
142
|
+
* Throws if initModelRolesAPI() has not been called yet.
|
|
143
|
+
*/
|
|
144
|
+
export function getModelRolesAPI(): ModelRolesAPI {
|
|
145
|
+
const api = (globalThis as any)[GLOBAL_KEY] as ModelRolesAPI | undefined;
|
|
146
|
+
if (!api) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"ModelRolesAPI not initialized. " +
|
|
149
|
+
"Ensure @d3ara1n/pi-model-roles extension is loaded and session_start has fired.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return api;
|
|
153
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read role configuration from settings files.
|
|
3
|
+
*
|
|
4
|
+
* Reads global (~/.pi/agent/settings.json) and project-level (.pi/settings.json)
|
|
5
|
+
* settings, merges them with project overriding global.
|
|
6
|
+
* Built-in defaults form the base.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import type { ModelRolesConfig, RoleConfig } from "./types.ts";
|
|
13
|
+
import { BUILTIN_DEFAULT_ROLES } from "./defaults.ts";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ROLE_NAME = "default";
|
|
16
|
+
|
|
17
|
+
/** Get the pi agent directory path. */
|
|
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");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read and parse a settings.json file. Returns parsed object or empty. */
|
|
25
|
+
function readSettingsFile(filePath: string): any {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(filePath)) return {};
|
|
28
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
29
|
+
// Strip JSONC comments
|
|
30
|
+
const stripped = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
31
|
+
return JSON.parse(stripped);
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Deep merge source into target (source wins on conflict). Only handles plain objects. */
|
|
38
|
+
function merge(target: any, source: any): any {
|
|
39
|
+
if (!source || typeof source !== "object") return target;
|
|
40
|
+
if (!target || typeof target !== "object") return source;
|
|
41
|
+
const result = { ...target };
|
|
42
|
+
for (const key of Object.keys(source)) {
|
|
43
|
+
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
44
|
+
result[key] = merge(result[key], source[key]);
|
|
45
|
+
} else {
|
|
46
|
+
result[key] = source[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load modelRoles config, merged from global + project settings.
|
|
54
|
+
* @param cwd - Project working directory (for .pi/settings.json lookup)
|
|
55
|
+
*/
|
|
56
|
+
export function loadRolesConfig(cwd?: string): ModelRolesConfig {
|
|
57
|
+
// Read global settings
|
|
58
|
+
const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
|
|
59
|
+
|
|
60
|
+
// Read project settings
|
|
61
|
+
const projectSettings = cwd
|
|
62
|
+
? readSettingsFile(path.join(cwd, ".pi", "settings.json"))
|
|
63
|
+
: {};
|
|
64
|
+
|
|
65
|
+
// Merge: project overrides global
|
|
66
|
+
const settings = merge(globalSettings, projectSettings);
|
|
67
|
+
|
|
68
|
+
// Start from built-in defaults
|
|
69
|
+
const mergedRoles: Record<string, RoleConfig> = {};
|
|
70
|
+
for (const [name, config] of Object.entries(BUILTIN_DEFAULT_ROLES)) {
|
|
71
|
+
mergedRoles[name] = { ...config };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Merge user config over defaults
|
|
75
|
+
const userConfig = settings?.modelRoles;
|
|
76
|
+
if (userConfig?.roles && typeof userConfig.roles === "object") {
|
|
77
|
+
for (const [name, config] of Object.entries(userConfig.roles as Record<string, Partial<RoleConfig>>)) {
|
|
78
|
+
if (mergedRoles[name]) {
|
|
79
|
+
mergedRoles[name] = { ...mergedRoles[name], ...config };
|
|
80
|
+
} else {
|
|
81
|
+
mergedRoles[name] = config as RoleConfig;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
roles: mergedRoles,
|
|
88
|
+
defaultRole: userConfig?.defaultRole ?? DEFAULT_ROLE_NAME,
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in default role definitions.
|
|
3
|
+
*
|
|
4
|
+
* Only universal roles are built-in. Scout/subagent-specific roles
|
|
5
|
+
* (like "side") are left for users to define — modelRoles accepts
|
|
6
|
+
* any custom role name.
|
|
7
|
+
*
|
|
8
|
+
* model=null means "use pi's current model, don't switch".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RoleConfig } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export const BUILTIN_DEFAULT_ROLES: Record<string, RoleConfig> = {
|
|
14
|
+
default: {
|
|
15
|
+
model: null,
|
|
16
|
+
description: "日常编码,速度和质量平衡",
|
|
17
|
+
thinking: "medium",
|
|
18
|
+
},
|
|
19
|
+
heavy: {
|
|
20
|
+
model: null,
|
|
21
|
+
description: "架构设计、深度调试、复杂迁移",
|
|
22
|
+
thinking: "high",
|
|
23
|
+
},
|
|
24
|
+
fast: {
|
|
25
|
+
model: null,
|
|
26
|
+
description: "快速修改、简单问答",
|
|
27
|
+
thinking: "off",
|
|
28
|
+
},
|
|
29
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-model-roles — Extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Dependency library: provides ModelRolesAPI singleton.
|
|
5
|
+
* Registers /roles command for inspection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { initModelRolesAPI, getModelRolesAPI, updateCurrentModel } from "./api.ts";
|
|
10
|
+
|
|
11
|
+
export { getModelRolesAPI } from "./api.ts";
|
|
12
|
+
export type { ModelRolesAPI, RoleConfig, ResolvedRole, ModelRolesConfig, ThinkingLevel } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
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
|
+
});
|
|
48
|
+
}
|
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve role names to Model instances using pi's ModelRegistry.
|
|
3
|
+
*
|
|
4
|
+
* model=null in RoleConfig is transparently resolved to the current model.
|
|
5
|
+
* Callers never see null — they get a real model or undefined.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RoleConfig, ResolvedRole } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** Minimal interface from ModelRegistry. */
|
|
11
|
+
interface ModelRegistryLike {
|
|
12
|
+
getAvailable(): any[];
|
|
13
|
+
getApiKeyAndHeaders(model: any): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string> }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ModelLike {
|
|
17
|
+
provider: string;
|
|
18
|
+
id: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
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] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
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
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sync resolve. model=null → uses currentModel if provided, else undefined.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveModelForRole(
|
|
41
|
+
roleConfig: RoleConfig,
|
|
42
|
+
modelRegistry: ModelRegistryLike,
|
|
43
|
+
currentModel: any | undefined,
|
|
44
|
+
): 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
|
+
}
|
|
49
|
+
|
|
50
|
+
const match = findModel(roleConfig.model, modelRegistry.getAvailable());
|
|
51
|
+
return { model: match ?? undefined, apiKey: undefined, headers: undefined };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Async resolve with auth. model=null → resolves currentModel's auth too.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveModelForRoleAsync(
|
|
58
|
+
roleConfig: RoleConfig,
|
|
59
|
+
modelRegistry: ModelRegistryLike,
|
|
60
|
+
currentModel: any | undefined,
|
|
61
|
+
): 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
|
+
}
|
|
69
|
+
|
|
70
|
+
const available = modelRegistry.getAvailable();
|
|
71
|
+
const match = findModel(roleConfig.model, available);
|
|
72
|
+
if (!match) return undefined;
|
|
73
|
+
|
|
74
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(match);
|
|
75
|
+
if (!auth.ok) return undefined;
|
|
76
|
+
|
|
77
|
+
return { model: match, apiKey: auth.apiKey, headers: auth.headers };
|
|
78
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for pi-model-roles.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Thinking level configuration for a role. */
|
|
6
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
7
|
+
|
|
8
|
+
/** Configuration for a single model role. */
|
|
9
|
+
export interface RoleConfig {
|
|
10
|
+
/**
|
|
11
|
+
* Model identifier, format: "provider/model-id".
|
|
12
|
+
* null = use pi's current model (resolved internally, never exposed to consumers).
|
|
13
|
+
*/
|
|
14
|
+
model: string | null;
|
|
15
|
+
/** Thinking level for this role */
|
|
16
|
+
thinking?: ThinkingLevel;
|
|
17
|
+
/** Human-readable description of when to use this role */
|
|
18
|
+
description?: string;
|
|
19
|
+
/** Comma-separated list of tools available to this role */
|
|
20
|
+
tools?: string;
|
|
21
|
+
/** If true, hide this role from user-facing listings */
|
|
22
|
+
hidden?: boolean;
|
|
23
|
+
/** Additional system prompt content appended when this role is active */
|
|
24
|
+
systemPromptAppend?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Top-level modelRoles configuration stored in pi settings. */
|
|
28
|
+
export interface ModelRolesConfig {
|
|
29
|
+
/** Map of role name → role configuration */
|
|
30
|
+
roles: Record<string, RoleConfig>;
|
|
31
|
+
/** Fallback role name when a requested role doesn't exist */
|
|
32
|
+
defaultRole?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A fully resolved role — consumers never see null. */
|
|
36
|
+
export interface ResolvedRole {
|
|
37
|
+
/** The role name */
|
|
38
|
+
name: string;
|
|
39
|
+
/** The original role configuration */
|
|
40
|
+
config: RoleConfig;
|
|
41
|
+
/** Resolved Model instance (always a real model, or undefined if unavailable) */
|
|
42
|
+
model: any | undefined; // Model<Api>
|
|
43
|
+
/** API key for this model */
|
|
44
|
+
apiKey: string | undefined;
|
|
45
|
+
/** Custom headers for API requests */
|
|
46
|
+
headers: Record<string, string> | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Public API exposed via getModelRolesAPI(). */
|
|
50
|
+
export interface ModelRolesAPI {
|
|
51
|
+
/** Read all role configurations. */
|
|
52
|
+
getRoles(): Record<string, RoleConfig>;
|
|
53
|
+
/** Get a single role configuration by name. */
|
|
54
|
+
getRole(name: string): RoleConfig | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a role name to a model instance (sync, no auth).
|
|
57
|
+
* model=null is transparently resolved to pi's current model.
|
|
58
|
+
* Returns model=undefined only if the model is truly unavailable.
|
|
59
|
+
*/
|
|
60
|
+
resolveRole(name: string): ResolvedRole;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a role name to a model instance with auth info (async).
|
|
63
|
+
* model=null is transparently resolved to pi's current model.
|
|
64
|
+
* Returns model=undefined only if the model is truly unavailable.
|
|
65
|
+
*/
|
|
66
|
+
resolveRoleAsync(name: string): Promise<ResolvedRole>;
|
|
67
|
+
/** Get the default role name. */
|
|
68
|
+
getDefaultRole(): string;
|
|
69
|
+
/** Get all non-hidden roles (for displaying to users). */
|
|
70
|
+
getVisibleRoles(): Record<string, RoleConfig>;
|
|
71
|
+
/**
|
|
72
|
+
* Given a model identifier (e.g. "anthropic/claude-sonnet-4"),
|
|
73
|
+
* find the first role name that uses that model.
|
|
74
|
+
* Skips roles with model=null.
|
|
75
|
+
*/
|
|
76
|
+
findRoleByModel(modelId: string): string | undefined;
|
|
77
|
+
}
|