@trim21/personal-pi-extensions 0.0.392 → 0.0.398

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
@@ -16,6 +16,7 @@
16
16
  | [todowrite](#todowrite) | opencode 风格的任务列表工具,完整列表替换语义 |
17
17
  | [question](#question) | opencode 风格的提问工具,阻塞式询问用户选择 |
18
18
  | [talk](#talk) | session 间消息传递,SQLite 邮箱 + 双向 ask 时间戳仲裁 |
19
+ | [openai-cost](#openai-cost) | OpenAI Chat Completions,费用取自响应 `usage.cost` |
19
20
 
20
21
  > **两套工具风格,按预期只启用其中一套**:本包同时提供 opencode 风格
21
22
  > (小写 `read`/`edit`/`write`/`bash`/`todowrite`/`question`)与 Claude Code
@@ -387,6 +388,35 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
387
388
 
388
389
  ---
389
390
 
391
+ ## openai-cost
392
+
393
+ OpenAI Chat Completions 兼容 provider。流式协议复用 pi 内置 `openai-completions`,费用不按模型单价估算,而是读取响应 `usage.cost`(number 或 `{ total }`,也认 Moonshot 的 `choice.usage`)写入 `message.usage.cost.total`。未上报 `usage.cost` 时保留默认 `calculateCost`。
394
+
395
+ 配置文件:`~/.pi/agent/openai-cost.json`。文件缺失或校验失败时扩展不注册 provider。
396
+
397
+ ```jsonc
398
+ {
399
+ "id": "openai-cost", // 可选,默认 openai-cost
400
+ "name": "OpenAI Cost", // 可选
401
+ "baseUrl": "https://api.example.com/v1",
402
+ "apiKeyEnv": "OPENAI_COST_API_KEY", // 可选;也支持 /login
403
+ "models": [
404
+ {
405
+ "id": "my-model",
406
+ "name": "My Model", // 可选,默认用 id
407
+ "reasoning": false,
408
+ "input": ["text"],
409
+ "contextWindow": 128000,
410
+ "maxTokens": 8192,
411
+ },
412
+ ],
413
+ }
414
+ ```
415
+
416
+ 省略 `models` 时启动后会请求 `GET {baseUrl}/models`,默认 `reasoning: false`、`input: ["text"]`、contextWindow 128000、maxTokens 8192。需要视觉 / reasoning / 准确窗口时把模型写进配置。API key 优先 stored credential,否则读 `apiKeyEnv`。
417
+
418
+ ---
419
+
390
420
  ## 安装
391
421
 
392
422
  ### 通过 npm/git 包
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.392",
3
+ "version": "0.0.398",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -82,7 +82,7 @@
82
82
  "prettier --write"
83
83
  ]
84
84
  },
85
- "packageManager": "pnpm@11.23.0",
85
+ "packageManager": "pnpm@11.24.0",
86
86
  "engines": {
87
87
  "node": ">=24"
88
88
  },
@@ -0,0 +1,112 @@
1
+ /**
2
+ * openai-cost 配置:~/.pi/agent/openai-cost.json
3
+ * 用 typebox 校验,解析失败或文件缺失时视为未配置。
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ import { type Static, Type } from "typebox";
10
+ import { Value } from "typebox/value";
11
+
12
+ export const DEFAULT_PROVIDER_ID = "openai-cost";
13
+ export const DEFAULT_PROVIDER_NAME = "OpenAI Cost";
14
+ export const DEFAULT_API_KEY_ENV = "OPENAI_COST_API_KEY";
15
+ export const DEFAULT_CONTEXT_WINDOW = 128000;
16
+ export const DEFAULT_MAX_TOKENS = 8192;
17
+ export const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
18
+
19
+ const modelSchema = Type.Object({
20
+ id: Type.String({ minLength: 1 }),
21
+ name: Type.Optional(Type.String()),
22
+ reasoning: Type.Optional(Type.Boolean()),
23
+ input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
24
+ contextWindow: Type.Optional(Type.Number()),
25
+ maxTokens: Type.Optional(Type.Number()),
26
+ baseUrl: Type.Optional(Type.String()),
27
+ });
28
+
29
+ const configSchema = Type.Object({
30
+ id: Type.Optional(Type.String()),
31
+ name: Type.Optional(Type.String()),
32
+ baseUrl: Type.String({ minLength: 1 }),
33
+ apiKeyEnv: Type.Optional(Type.String()),
34
+ models: Type.Optional(Type.Array(modelSchema)),
35
+ });
36
+
37
+ type RawConfig = Static<typeof configSchema>;
38
+
39
+ export interface OpenaiCostModelConfig {
40
+ id: string;
41
+ name: string;
42
+ reasoning: boolean;
43
+ input: ("text" | "image")[];
44
+ contextWindow: number;
45
+ maxTokens: number;
46
+ baseUrl?: string;
47
+ }
48
+
49
+ export interface OpenaiCostConfig {
50
+ id: string;
51
+ name: string;
52
+ baseUrl: string;
53
+ apiKeyEnv: string;
54
+ models?: OpenaiCostModelConfig[];
55
+ }
56
+
57
+ export function openaiCostConfigPath(): string {
58
+ return join(homedir(), ".pi", "agent", "openai-cost.json");
59
+ }
60
+
61
+ function nonEmpty(value: string | undefined): string | undefined {
62
+ const trimmed = value?.trim();
63
+ return trimmed || undefined;
64
+ }
65
+
66
+ function normalizeModel(model: Static<typeof modelSchema>): OpenaiCostModelConfig | undefined {
67
+ const id = nonEmpty(model.id);
68
+ if (!id) return undefined;
69
+ const input = model.input?.filter((value) => value === "text" || value === "image") ?? [];
70
+ return {
71
+ id,
72
+ name: nonEmpty(model.name) ?? id,
73
+ reasoning: model.reasoning ?? false,
74
+ input: input.length > 0 ? input : ["text"],
75
+ contextWindow:
76
+ model.contextWindow !== undefined && model.contextWindow > 0
77
+ ? model.contextWindow
78
+ : DEFAULT_CONTEXT_WINDOW,
79
+ maxTokens:
80
+ model.maxTokens !== undefined && model.maxTokens > 0 ? model.maxTokens : DEFAULT_MAX_TOKENS,
81
+ baseUrl: nonEmpty(model.baseUrl),
82
+ };
83
+ }
84
+
85
+ function normalize(raw: RawConfig): OpenaiCostConfig {
86
+ const models = raw.models
87
+ ?.map((model) => normalizeModel(model))
88
+ .filter((model): model is OpenaiCostModelConfig => model !== undefined);
89
+ return {
90
+ id: nonEmpty(raw.id) ?? DEFAULT_PROVIDER_ID,
91
+ name: nonEmpty(raw.name) ?? DEFAULT_PROVIDER_NAME,
92
+ baseUrl: raw.baseUrl.trim().replace(/\/+$/, ""),
93
+ apiKeyEnv: nonEmpty(raw.apiKeyEnv) ?? DEFAULT_API_KEY_ENV,
94
+ models: models && models.length > 0 ? models : undefined,
95
+ };
96
+ }
97
+
98
+ export async function loadOpenaiCostConfig(
99
+ path = openaiCostConfigPath(),
100
+ ): Promise<OpenaiCostConfig | undefined> {
101
+ let raw: unknown;
102
+ try {
103
+ raw = JSON.parse(await readFile(path, "utf8"));
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ try {
108
+ return normalize(Value.Parse(configSchema, raw));
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 从 Chat Completions SSE 里截取 usage.cost,写回 AssistantMessage.usage.cost.total。
3
+ * 内置 openai-completions 会用模型单价覆盖费用,必须在 fetch 层把上报值留下来。
4
+ */
5
+ import {
6
+ type AssistantMessage,
7
+ type AssistantMessageEvent,
8
+ type AssistantMessageEventStream,
9
+ createAssistantMessageEventStream,
10
+ type Usage,
11
+ } from "@earendil-works/pi-ai";
12
+ import { Type } from "typebox";
13
+ import { Value } from "typebox/value";
14
+
15
+ export interface ReportedCostCapture {
16
+ fetch: typeof globalThis.fetch;
17
+ wait: () => Promise<number | undefined>;
18
+ }
19
+
20
+ function finiteNumber(value: unknown): number | undefined {
21
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
22
+ }
23
+
24
+ const usageCostSchema = Type.Object(
25
+ {
26
+ cost: Type.Optional(
27
+ Type.Union([
28
+ Type.Number(),
29
+ Type.Object({ total: Type.Optional(Type.Number()) }, { additionalProperties: true }),
30
+ ]),
31
+ ),
32
+ },
33
+ { additionalProperties: true },
34
+ );
35
+
36
+ const chunkSchema = Type.Object(
37
+ {
38
+ usage: Type.Optional(usageCostSchema),
39
+ choices: Type.Optional(
40
+ Type.Array(
41
+ Type.Object({ usage: Type.Optional(usageCostSchema) }, { additionalProperties: true }),
42
+ ),
43
+ ),
44
+ },
45
+ { additionalProperties: true },
46
+ );
47
+
48
+ function costFromUsage(
49
+ usage: { cost?: number | { total?: number } } | undefined,
50
+ ): number | undefined {
51
+ if (!usage) return undefined;
52
+ const asNumber = finiteNumber(usage.cost);
53
+ if (asNumber !== undefined) return asNumber;
54
+ if (usage.cost && typeof usage.cost === "object") return finiteNumber(usage.cost.total);
55
+ return undefined;
56
+ }
57
+
58
+ /** 从 chat completions JSON 块读取上报费用:usage.cost 或 choice.usage.cost。 */
59
+ export function extractReportedCost(value: unknown): number | undefined {
60
+ let chunk;
61
+ try {
62
+ chunk = Value.Parse(chunkSchema, value);
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ const fromUsage = costFromUsage(chunk.usage);
67
+ if (fromUsage !== undefined) return fromUsage;
68
+ for (const choice of chunk.choices ?? []) {
69
+ const fromChoice = costFromUsage(choice.usage);
70
+ if (fromChoice !== undefined) return fromChoice;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ export function costFromSseLine(line: string): number | undefined {
76
+ const trimmed = line.trim();
77
+ if (!trimmed.startsWith("data:")) return undefined;
78
+ const data = trimmed.slice("data:".length).trim();
79
+ if (!data || data === "[DONE]") return undefined;
80
+ try {
81
+ return extractReportedCost(JSON.parse(data) as unknown);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ export async function scanSseCost(body: ReadableStream<Uint8Array>): Promise<number | undefined> {
88
+ const reader = body.getReader();
89
+ const decoder = new TextDecoder();
90
+ let buffer = "";
91
+ let last: number | undefined;
92
+ try {
93
+ while (true) {
94
+ const { done, value } = await reader.read();
95
+ if (done) break;
96
+ buffer += decoder.decode(value, { stream: true });
97
+ const lines = buffer.split("\n");
98
+ buffer = lines.pop() ?? "";
99
+ for (const line of lines) {
100
+ const cost = costFromSseLine(line);
101
+ if (cost !== undefined) last = cost;
102
+ }
103
+ }
104
+ buffer += decoder.decode();
105
+ const cost = costFromSseLine(buffer);
106
+ if (cost !== undefined) last = cost;
107
+ return last;
108
+ } catch {
109
+ return last;
110
+ } finally {
111
+ reader.releaseLock();
112
+ }
113
+ }
114
+
115
+ type FetchInput = Parameters<typeof globalThis.fetch>[0];
116
+
117
+ function requestUrl(input: FetchInput): string {
118
+ if (typeof input === "string") return input;
119
+ if (input instanceof URL) return input.href;
120
+ return input.url;
121
+ }
122
+
123
+ function shouldScan(input: FetchInput, response: Response): boolean {
124
+ if (!response.ok || !response.body) return false;
125
+ if (requestUrl(input).includes("/chat/completions")) return true;
126
+ const contentType = response.headers.get("content-type") ?? "";
127
+ return contentType.includes("text/event-stream");
128
+ }
129
+
130
+ export function createReportedCostCapture(
131
+ innerFetch: typeof globalThis.fetch,
132
+ ): ReportedCostCapture {
133
+ let scan: Promise<number | undefined> | undefined;
134
+ const fetch: typeof globalThis.fetch = async (input, init) => {
135
+ const response = await innerFetch(input, init);
136
+ if (!shouldScan(input, response) || !response.body) return response;
137
+ const [forSdk, forScan] = response.body.tee();
138
+ scan = scanSseCost(forScan);
139
+ return new Response(forSdk, {
140
+ status: response.status,
141
+ statusText: response.statusText,
142
+ headers: response.headers,
143
+ });
144
+ };
145
+ return {
146
+ fetch,
147
+ wait: async () => (scan ? await scan : undefined),
148
+ };
149
+ }
150
+
151
+ export function applyReportedCost(usage: Usage, reported: number): void {
152
+ usage.cost.input = 0;
153
+ usage.cost.output = 0;
154
+ usage.cost.cacheRead = 0;
155
+ usage.cost.cacheWrite = 0;
156
+ usage.cost.total = reported;
157
+ }
158
+
159
+ function messageOf(event: AssistantMessageEvent): AssistantMessage {
160
+ if (event.type === "done") return event.message;
161
+ if (event.type === "error") return event.error;
162
+ return event.partial;
163
+ }
164
+
165
+ export function applyCostToEvent(
166
+ event: AssistantMessageEvent,
167
+ reported: number | undefined,
168
+ ): AssistantMessageEvent {
169
+ if (reported === undefined) return event;
170
+ applyReportedCost(messageOf(event).usage, reported);
171
+ return event;
172
+ }
173
+
174
+ export function wrapStreamWithReportedCost(
175
+ inner: AssistantMessageEventStream,
176
+ waitCost: () => Promise<number | undefined>,
177
+ ): AssistantMessageEventStream {
178
+ const out = createAssistantMessageEventStream();
179
+ void (async () => {
180
+ try {
181
+ for await (const event of inner) {
182
+ let reported: number | undefined;
183
+ if (event.type === "done" || event.type === "error") {
184
+ try {
185
+ reported = await waitCost();
186
+ } catch {
187
+ reported = undefined;
188
+ }
189
+ }
190
+ out.push(applyCostToEvent(event, reported));
191
+ }
192
+ } catch {
193
+ // inner already terminated or failed; result() follows the inner events
194
+ } finally {
195
+ out.end();
196
+ }
197
+ })();
198
+ return out;
199
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * openai-cost —— OpenAI Chat Completions provider,费用取自响应 usage.cost,
3
+ * 不用模型单价估算。配置:~/.pi/agent/openai-cost.json。
4
+ */
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { loadOpenaiCostConfig } from "./config.js";
8
+ import { createOpenaiCostProvider } from "./provider.js";
9
+
10
+ export default async function openaiCostExtension(pi: ExtensionAPI) {
11
+ const config = await loadOpenaiCostConfig();
12
+ if (!config) return;
13
+ pi.registerProvider(createOpenaiCostProvider(config));
14
+ }
@@ -0,0 +1,152 @@
1
+ import process from "node:process";
2
+
3
+ import {
4
+ createProvider,
5
+ envApiKeyAuth,
6
+ type Model,
7
+ type Provider,
8
+ type ProviderStreams,
9
+ type RefreshModelsContext,
10
+ } from "@earendil-works/pi-ai";
11
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
12
+ import { type Static, Type } from "typebox";
13
+ import { Value } from "typebox/value";
14
+
15
+ import {
16
+ DEFAULT_CONTEXT_WINDOW,
17
+ DEFAULT_MAX_TOKENS,
18
+ type OpenaiCostConfig,
19
+ type OpenaiCostModelConfig,
20
+ ZERO_COST,
21
+ } from "./config.js";
22
+ import { createReportedCostCapture, wrapStreamWithReportedCost } from "./cost.js";
23
+
24
+ const remoteModelsSchema = Type.Object(
25
+ {
26
+ data: Type.Array(
27
+ Type.Object(
28
+ {
29
+ id: Type.String(),
30
+ name: Type.Optional(Type.String()),
31
+ context_window: Type.Optional(Type.Number()),
32
+ max_tokens: Type.Optional(Type.Number()),
33
+ },
34
+ { additionalProperties: true },
35
+ ),
36
+ ),
37
+ },
38
+ { additionalProperties: true },
39
+ );
40
+
41
+ type RemoteModels = Static<typeof remoteModelsSchema>;
42
+
43
+ export function toPiModel(
44
+ config: OpenaiCostConfig,
45
+ model: OpenaiCostModelConfig,
46
+ ): Model<"openai-completions"> {
47
+ return {
48
+ id: model.id,
49
+ name: model.name,
50
+ api: "openai-completions",
51
+ provider: config.id,
52
+ baseUrl: model.baseUrl ?? config.baseUrl,
53
+ reasoning: model.reasoning,
54
+ input: model.input,
55
+ cost: { ...ZERO_COST },
56
+ contextWindow: model.contextWindow,
57
+ maxTokens: model.maxTokens,
58
+ };
59
+ }
60
+
61
+ function modelsUrl(baseUrl: string): string {
62
+ return `${baseUrl.replace(/\/+$/, "")}/models`;
63
+ }
64
+
65
+ export function mapRemoteModels(
66
+ config: OpenaiCostConfig,
67
+ payload: RemoteModels,
68
+ ): Model<"openai-completions">[] {
69
+ const models: Model<"openai-completions">[] = [];
70
+ for (const item of payload.data) {
71
+ const id = item.id.trim();
72
+ if (!id) continue;
73
+ models.push(
74
+ toPiModel(config, {
75
+ id,
76
+ name: item.name?.trim() || id,
77
+ reasoning: false,
78
+ input: ["text"],
79
+ contextWindow:
80
+ item.context_window && item.context_window > 0
81
+ ? item.context_window
82
+ : DEFAULT_CONTEXT_WINDOW,
83
+ maxTokens: item.max_tokens && item.max_tokens > 0 ? item.max_tokens : DEFAULT_MAX_TOKENS,
84
+ }),
85
+ );
86
+ }
87
+ return models;
88
+ }
89
+
90
+ export async function fetchRemoteModels(
91
+ config: OpenaiCostConfig,
92
+ options: { apiKey?: string; signal?: AbortSignal; fetch?: typeof globalThis.fetch } = {},
93
+ ): Promise<Model<"openai-completions">[]> {
94
+ const fetchImpl = options.fetch ?? globalThis.fetch;
95
+ const headers: Record<string, string> = { Accept: "application/json" };
96
+ if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`;
97
+ const response = await fetchImpl(modelsUrl(config.baseUrl), { headers, signal: options.signal });
98
+ const raw = await response.text();
99
+ if (!response.ok) {
100
+ throw new Error(`openai-cost /models ${response.status}: ${raw.slice(0, 300)}`);
101
+ }
102
+ return mapRemoteModels(config, Value.Parse(remoteModelsSchema, JSON.parse(raw) as unknown));
103
+ }
104
+
105
+ function withReportedCost(api: ProviderStreams): ProviderStreams {
106
+ const wrap = (
107
+ run: (fetch: typeof globalThis.fetch) => ReturnType<ProviderStreams["stream"]>,
108
+ fetch: typeof globalThis.fetch | undefined,
109
+ ) => {
110
+ const capture = createReportedCostCapture(fetch ?? globalThis.fetch);
111
+ return wrapStreamWithReportedCost(run(capture.fetch), capture.wait);
112
+ };
113
+ return {
114
+ stream: (model, context, options) =>
115
+ wrap((fetch) => api.stream(model, context, { ...options, fetch }), options?.fetch),
116
+ streamSimple: (model, context, options) =>
117
+ wrap((fetch) => api.streamSimple(model, context, { ...options, fetch }), options?.fetch),
118
+ };
119
+ }
120
+
121
+ function apiKeyFromRefresh(context: RefreshModelsContext, apiKeyEnv: string): string | undefined {
122
+ if (context.credential?.type === "api_key" && context.credential.key) {
123
+ return context.credential.key;
124
+ }
125
+ const envValue = process.env[apiKeyEnv];
126
+ return envValue?.trim() || undefined;
127
+ }
128
+
129
+ export function createOpenaiCostProvider(
130
+ config: OpenaiCostConfig,
131
+ options: { api?: ProviderStreams; fetch?: typeof globalThis.fetch } = {},
132
+ ): Provider<"openai-completions"> {
133
+ const api = withReportedCost(options.api ?? openAICompletionsApi());
134
+ const staticModels = config.models?.map((model) => toPiModel(config, model)) ?? [];
135
+ return createProvider({
136
+ id: config.id,
137
+ name: config.name,
138
+ baseUrl: config.baseUrl,
139
+ auth: { apiKey: envApiKeyAuth(`${config.name} API key`, [config.apiKeyEnv]) },
140
+ models: staticModels,
141
+ fetchModels:
142
+ staticModels.length > 0
143
+ ? undefined
144
+ : async (context) =>
145
+ fetchRemoteModels(config, {
146
+ apiKey: apiKeyFromRefresh(context, config.apiKeyEnv),
147
+ signal: context.signal,
148
+ fetch: options.fetch,
149
+ }),
150
+ api,
151
+ });
152
+ }