pi-better-openai 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 +51 -0
- package/extensions/config.ts +232 -0
- package/extensions/format.ts +35 -0
- package/extensions/identity.ts +7 -0
- package/extensions/image.ts +427 -0
- package/extensions/pi-better-openai.ts +623 -0
- package/extensions/usage.ts +174 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# pi-better-openai
|
|
2
|
+
|
|
3
|
+
A pi extension for OpenAI subscription workflows: fast mode, usage visibility, footer polish, and image generation through `openai-codex` auth.
|
|
4
|
+
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+
<!-- Add screenshots here. -->
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Install from a local checkout:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install /path/to/pi-better-openai
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For a project-local install instead of a global install, add `-l`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install -l /path/to/pi-better-openai
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Reload pi after installing:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
/reload
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Sign in to OpenAI Codex subscription auth if you want usage stats or image generation:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
/login openai-codex
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Start pi with fast mode enabled:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi --fast
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Fast mode for supported OpenAI models, toggled with `/fast` or in `/openai-settings`.
|
|
44
|
+
- OpenAI subscription usage display via `/openai-usage` and the footer.
|
|
45
|
+
- Interactive settings picker via `/openai-settings`.
|
|
46
|
+
- Footer customization for model, thinking, fast mode, usage, and token/cost context.
|
|
47
|
+
- OpenAI image generation/editing through the `openai_image` tool.
|
|
48
|
+
- Commands:
|
|
49
|
+
- `/fast` toggles fast mode.
|
|
50
|
+
- `/openai-usage` shows current OpenAI subscription usage.
|
|
51
|
+
- `/openai-settings` opens settings, diagnostics, and config details.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CONFIG_BASENAME, logPrefix } from "./identity.ts";
|
|
5
|
+
|
|
6
|
+
export const FOOTER_MODES = ["replace", "status", "off"] as const;
|
|
7
|
+
export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
|
|
8
|
+
export const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SUPPORTED_MODELS = [
|
|
11
|
+
"openai/gpt-5.4",
|
|
12
|
+
"openai/gpt-5.5",
|
|
13
|
+
"openai-codex/gpt-5.4",
|
|
14
|
+
"openai-codex/gpt-5.5"
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type FooterMode = typeof FOOTER_MODES[number];
|
|
18
|
+
export type ImageSaveMode = typeof IMAGE_SAVE_MODES[number];
|
|
19
|
+
export type ImageOutputFormat = typeof IMAGE_OUTPUT_FORMATS[number];
|
|
20
|
+
|
|
21
|
+
export type UsageConfig = {
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
refreshIntervalMs?: number;
|
|
24
|
+
showOnlyOnSubscriptionModels?: boolean;
|
|
25
|
+
showResetTimes?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type FooterConfig = {
|
|
29
|
+
mode?: FooterMode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ImageConfig = {
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
defaultModel?: string;
|
|
35
|
+
defaultSave?: ImageSaveMode;
|
|
36
|
+
outputFormat?: ImageOutputFormat;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface ConfigFile {
|
|
41
|
+
persistState?: boolean;
|
|
42
|
+
active?: boolean;
|
|
43
|
+
desiredActive?: boolean;
|
|
44
|
+
supportedModels?: string[];
|
|
45
|
+
usage?: UsageConfig;
|
|
46
|
+
footer?: FooterConfig;
|
|
47
|
+
image?: ImageConfig;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SupportedModel {
|
|
51
|
+
provider: string;
|
|
52
|
+
id: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolvedConfig {
|
|
56
|
+
configPath: string;
|
|
57
|
+
projectConfigPath: string;
|
|
58
|
+
globalConfigPath: string;
|
|
59
|
+
projectConfigExists: boolean;
|
|
60
|
+
globalConfigExists: boolean;
|
|
61
|
+
persistState: boolean;
|
|
62
|
+
active: boolean;
|
|
63
|
+
desiredActive: boolean;
|
|
64
|
+
supportedModels: SupportedModel[];
|
|
65
|
+
usage: Required<UsageConfig>;
|
|
66
|
+
footer: Required<FooterConfig>;
|
|
67
|
+
image: Required<ImageConfig>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_USAGE_CONFIG: Required<UsageConfig> = {
|
|
71
|
+
enabled: true,
|
|
72
|
+
refreshIntervalMs: 60_000,
|
|
73
|
+
showOnlyOnSubscriptionModels: true,
|
|
74
|
+
showResetTimes: true
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_FOOTER_CONFIG: Required<FooterConfig> = {
|
|
78
|
+
mode: "replace"
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const DEFAULT_IMAGE_CONFIG: Required<ImageConfig> = {
|
|
82
|
+
enabled: true,
|
|
83
|
+
defaultModel: "gpt-5.5",
|
|
84
|
+
defaultSave: "project",
|
|
85
|
+
outputFormat: "png",
|
|
86
|
+
timeoutMs: 180_000
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_CONFIG: ConfigFile = {
|
|
90
|
+
persistState: true,
|
|
91
|
+
active: false,
|
|
92
|
+
desiredActive: false,
|
|
93
|
+
supportedModels: [...DEFAULT_SUPPORTED_MODELS],
|
|
94
|
+
usage: DEFAULT_USAGE_CONFIG,
|
|
95
|
+
footer: DEFAULT_FOOTER_CONFIG,
|
|
96
|
+
image: DEFAULT_IMAGE_CONFIG
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
100
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function configPaths(cwd: string, home = homedir()) {
|
|
104
|
+
return {
|
|
105
|
+
project: join(cwd, ".pi", "extensions", CONFIG_BASENAME),
|
|
106
|
+
global: join(home, ".pi", "agent", "extensions", CONFIG_BASENAME)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseModelKey(value: string): SupportedModel | undefined {
|
|
111
|
+
const key = value.trim();
|
|
112
|
+
const slash = key.indexOf("/");
|
|
113
|
+
if (slash <= 0 || slash === key.length - 1) return undefined;
|
|
114
|
+
const provider = key.slice(0, slash).trim();
|
|
115
|
+
const id = key.slice(slash + 1).trim();
|
|
116
|
+
return provider && id ? { provider, id } : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function normalizeModelKeys(value: unknown): string[] | undefined {
|
|
120
|
+
if (value === undefined) return undefined;
|
|
121
|
+
if (!Array.isArray(value)) return undefined;
|
|
122
|
+
return value
|
|
123
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
124
|
+
.map((entry) => parseModelKey(entry))
|
|
125
|
+
.filter((entry): entry is SupportedModel => entry !== undefined)
|
|
126
|
+
.map((entry) => `${entry.provider}/${entry.id}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseModels(value: unknown): SupportedModel[] | undefined {
|
|
130
|
+
const keys = normalizeModelKeys(value);
|
|
131
|
+
if (keys === undefined) return undefined;
|
|
132
|
+
return keys.map((key) => parseModelKey(key)).filter((entry): entry is SupportedModel => entry !== undefined);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readRawConfig(path: string): Record<string, unknown> {
|
|
136
|
+
if (!existsSync(path)) return {};
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
139
|
+
return isRecord(parsed) ? parsed : {};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
142
|
+
console.warn(`${logPrefix()} Failed to read ${path}: ${message}`);
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function readConfig(path: string): ConfigFile | undefined {
|
|
148
|
+
if (!existsSync(path)) return undefined;
|
|
149
|
+
const parsed = readRawConfig(path);
|
|
150
|
+
const config: ConfigFile = {};
|
|
151
|
+
if (typeof parsed.persistState === "boolean") config.persistState = parsed.persistState;
|
|
152
|
+
if (typeof parsed.active === "boolean") config.active = parsed.active;
|
|
153
|
+
if (typeof parsed.desiredActive === "boolean") config.desiredActive = parsed.desiredActive;
|
|
154
|
+
const supportedModels = normalizeModelKeys(parsed.supportedModels);
|
|
155
|
+
if (supportedModels !== undefined) config.supportedModels = supportedModels;
|
|
156
|
+
if (isRecord(parsed.usage)) {
|
|
157
|
+
config.usage = {};
|
|
158
|
+
if (typeof parsed.usage.enabled === "boolean") config.usage.enabled = parsed.usage.enabled;
|
|
159
|
+
if (typeof parsed.usage.refreshIntervalMs === "number") config.usage.refreshIntervalMs = parsed.usage.refreshIntervalMs;
|
|
160
|
+
if (typeof parsed.usage.showOnlyOnSubscriptionModels === "boolean") config.usage.showOnlyOnSubscriptionModels = parsed.usage.showOnlyOnSubscriptionModels;
|
|
161
|
+
if (typeof parsed.usage.showResetTimes === "boolean") config.usage.showResetTimes = parsed.usage.showResetTimes;
|
|
162
|
+
}
|
|
163
|
+
if (isRecord(parsed.footer) && typeof parsed.footer.mode === "string" && (FOOTER_MODES as readonly string[]).includes(parsed.footer.mode)) {
|
|
164
|
+
config.footer = { mode: parsed.footer.mode as FooterMode };
|
|
165
|
+
}
|
|
166
|
+
if (isRecord(parsed.image)) {
|
|
167
|
+
config.image = {};
|
|
168
|
+
if (typeof parsed.image.enabled === "boolean") config.image.enabled = parsed.image.enabled;
|
|
169
|
+
if (typeof parsed.image.defaultModel === "string" && parsed.image.defaultModel.trim()) config.image.defaultModel = parsed.image.defaultModel.trim();
|
|
170
|
+
if (typeof parsed.image.defaultSave === "string" && (IMAGE_SAVE_MODES as readonly string[]).includes(parsed.image.defaultSave)) config.image.defaultSave = parsed.image.defaultSave as ImageSaveMode;
|
|
171
|
+
if (typeof parsed.image.outputFormat === "string" && (IMAGE_OUTPUT_FORMATS as readonly string[]).includes(parsed.image.outputFormat)) config.image.outputFormat = parsed.image.outputFormat as ImageOutputFormat;
|
|
172
|
+
if (typeof parsed.image.timeoutMs === "number") config.image.timeoutMs = parsed.image.timeoutMs;
|
|
173
|
+
}
|
|
174
|
+
return config;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function writeConfig(path: string, config: ConfigFile | Record<string, unknown>): void {
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
180
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
183
|
+
console.warn(`${logPrefix()} Failed to write ${path}: ${message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function ensureConfigFile(projectConfigPath: string, globalConfigPath: string): void {
|
|
188
|
+
if (existsSync(projectConfigPath) || existsSync(globalConfigPath)) return;
|
|
189
|
+
writeConfig(globalConfigPath, DEFAULT_CONFIG);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function resolveConfig(cwd: string): ResolvedConfig {
|
|
193
|
+
const paths = configPaths(cwd);
|
|
194
|
+
ensureConfigFile(paths.project, paths.global);
|
|
195
|
+
|
|
196
|
+
const projectConfigExists = existsSync(paths.project);
|
|
197
|
+
const globalConfigExists = existsSync(paths.global);
|
|
198
|
+
const globalConfig = readConfig(paths.global) ?? {};
|
|
199
|
+
const projectConfig = readConfig(paths.project) ?? {};
|
|
200
|
+
const merged = { ...DEFAULT_CONFIG, ...globalConfig, ...projectConfig };
|
|
201
|
+
const selectedPath = projectConfigExists ? paths.project : paths.global;
|
|
202
|
+
const desiredActive = merged.desiredActive ?? merged.active ?? false;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
configPath: selectedPath,
|
|
206
|
+
projectConfigPath: paths.project,
|
|
207
|
+
globalConfigPath: paths.global,
|
|
208
|
+
projectConfigExists,
|
|
209
|
+
globalConfigExists,
|
|
210
|
+
persistState: merged.persistState ?? true,
|
|
211
|
+
active: merged.active ?? desiredActive,
|
|
212
|
+
desiredActive,
|
|
213
|
+
supportedModels: parseModels(merged.supportedModels) ?? parseModels(DEFAULT_SUPPORTED_MODELS) ?? [],
|
|
214
|
+
usage: {
|
|
215
|
+
...DEFAULT_USAGE_CONFIG,
|
|
216
|
+
...(globalConfig.usage ?? {}),
|
|
217
|
+
...(projectConfig.usage ?? {}),
|
|
218
|
+
refreshIntervalMs: Math.max(15_000, Math.min(10 * 60_000, projectConfig.usage?.refreshIntervalMs ?? globalConfig.usage?.refreshIntervalMs ?? DEFAULT_USAGE_CONFIG.refreshIntervalMs))
|
|
219
|
+
},
|
|
220
|
+
footer: {
|
|
221
|
+
...DEFAULT_FOOTER_CONFIG,
|
|
222
|
+
...(globalConfig.footer ?? {}),
|
|
223
|
+
...(projectConfig.footer ?? {})
|
|
224
|
+
},
|
|
225
|
+
image: {
|
|
226
|
+
...DEFAULT_IMAGE_CONFIG,
|
|
227
|
+
...(globalConfig.image ?? {}),
|
|
228
|
+
...(projectConfig.image ?? {}),
|
|
229
|
+
timeoutMs: Math.max(30_000, Math.min(5 * 60_000, projectConfig.image?.timeoutMs ?? globalConfig.image?.timeoutMs ?? DEFAULT_IMAGE_CONFIG.timeoutMs))
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function stripAnsi(value: string): string {
|
|
2
|
+
return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function visibleWidth(value: string): number {
|
|
6
|
+
return stripAnsi(value).length;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function leadingAnsi(value: string): string {
|
|
10
|
+
return value.match(/^(?:\u001b\[[0-?]*[ -/]*[@-~])+/)?.[0] ?? "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function trailingAnsi(value: string): string {
|
|
14
|
+
return value.match(/(?:\u001b\[[0-?]*[ -/]*[@-~])+$/)?.[0] ?? "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function truncateToWidth(value: string, width: number, ellipsis = "..."): string {
|
|
18
|
+
if (visibleWidth(value) <= width) return value;
|
|
19
|
+
if (width <= 0) return "";
|
|
20
|
+
const plain = stripAnsi(value);
|
|
21
|
+
if (width <= ellipsis.length) return ellipsis.slice(0, width);
|
|
22
|
+
return `${leadingAnsi(value)}${plain.slice(0, Math.max(0, width - ellipsis.length))}${ellipsis}${trailingAnsi(value)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatTokens(count: number): string {
|
|
26
|
+
if (count < 1000) return count.toString();
|
|
27
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
28
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
29
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
30
|
+
return `${Math.round(count / 1000000)}M`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeStatusText(text: string): string {
|
|
34
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
35
|
+
}
|