pi-modelscope 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 +114 -0
- package/extensions/modelscope.ts +370 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# pi-modelscope
|
|
2
|
+
|
|
3
|
+
Pi extension for [ModelScope](https://modelscope.cn/)'s OpenAI-compatible inference API. It registers the `modelscope` provider, supports streaming chat completions and multimodal `text` + `image` messages, refreshes the available model catalog from `/v1/models` in the background, and provides commands for inspecting models, capabilities, and session usage.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-modelscope
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For a local checkout:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Set a ModelScope Token before starting pi:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export MODELSCOPE_API_KEY="ms-..."
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **Base URL:** `https://api-inference.modelscope.cn/v1`
|
|
26
|
+
- **Provider id:** `modelscope`
|
|
27
|
+
- **Auth:** `MODELSCOPE_API_KEY` (sent as `Authorization: Bearer <token>`)
|
|
28
|
+
|
|
29
|
+
The token is read from the environment and is not included in this package. Do not commit a real token to source control.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
The documented multimodal seed model is available immediately:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pi --model modelscope/Qwen/Qwen3.8-Flash-Next "你好,介绍一下你自己"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
ModelScope's OpenAI-compatible API also accepts image parts. In pi, attach an image to a message while using a model whose catalog entry advertises image input; the extension passes the resulting OpenAI-compatible message to ModelScope.
|
|
40
|
+
|
|
41
|
+
Equivalent API usage outside pi:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from openai import OpenAI
|
|
45
|
+
|
|
46
|
+
client = OpenAI(
|
|
47
|
+
base_url="https://api-inference.modelscope.cn/v1",
|
|
48
|
+
api_key="ms-your-token",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
response = client.chat.completions.create(
|
|
52
|
+
model="Qwen/Qwen3.8-Flash-Next",
|
|
53
|
+
messages=[{
|
|
54
|
+
"role": "user",
|
|
55
|
+
"content": [
|
|
56
|
+
{"type": "text", "text": "描述这幅图"},
|
|
57
|
+
{
|
|
58
|
+
"type": "image_url",
|
|
59
|
+
"image_url": {
|
|
60
|
+
"url": "https://modelscope.oss-cn-beijing.aliyuncs.com/demo/images/audrey_hepburn.jpg",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}],
|
|
65
|
+
stream=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for chunk in response:
|
|
69
|
+
if chunk.choices:
|
|
70
|
+
print(chunk.choices[0].delta.content or "", end="", flush=True)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
The extension registers the following commands:
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `/modelscope-models [image\|vision\|audio\|video\|reasoning\|tools]` | List ModelScope models with capabilities, context/output limits; an optional filter narrows the table. |
|
|
80
|
+
| `/modelscope-usage` | Show token/cost usage accumulated in the current Pi process. |
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
/modelscope-models
|
|
86
|
+
/modelscope-models vision
|
|
87
|
+
/modelscope-usage
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`/modelscope-usage` is based on `message_end` usage reported by completed assistant messages and is therefore local to the current Pi process. ModelScope's OpenAI-compatible API does not expose a uniform account-level billing/usage endpoint through this provider, so this command is not an account invoice.
|
|
91
|
+
|
|
92
|
+
## Model discovery
|
|
93
|
+
|
|
94
|
+
`Qwen/Qwen3.8-Flash-Next` is registered synchronously as a seed model, so the provider remains usable during startup and when the network is unavailable. Pi subsequently calls `https://api-inference.modelscope.cn/v1/models`; a successful result replaces the seed list and is persisted for later offline starts. Failed or empty discovery falls back to the cached catalog or the seed model.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm test
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The extension uses pi-ai's `openai-completions` API and resolves both the newer lazy subpath and the older bare-package export for compatibility with different pi versions.
|
|
103
|
+
|
|
104
|
+
## Release to npm
|
|
105
|
+
|
|
106
|
+
The GitHub Actions workflow in `.github/workflows/publish.yml` publishes on tags matching `v*` and also supports manual dispatch. It runs `npm ci`, `npm test`, and publishes with npm provenance:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npm version patch
|
|
110
|
+
# or: npm version minor / npm version major
|
|
111
|
+
git push origin main --follow-tags
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Before the first release, configure npm Trusted Publishing for the `pgciq/pi-modelscope` repository and the `Publish to npm` workflow. The workflow uses GitHub OIDC (`id-token: write`) and does not store an npm token in the repository.
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// ModelScope provider (OpenAI-compatible) — https://modelscope.cn/docs
|
|
2
|
+
// Base URL: https://api-inference.modelscope.cn/v1
|
|
3
|
+
// Auth: MODELSCOPE_API_KEY env var (a ModelScope Token)
|
|
4
|
+
//
|
|
5
|
+
// ModelScope exposes an OpenAI-compatible chat completions endpoint. The
|
|
6
|
+
// provider registers a known model immediately, then refreshes the catalog
|
|
7
|
+
// from /v1/models in the background so startup does not depend on the
|
|
8
|
+
// network. The catalog is persisted by pi and used as an offline fallback.
|
|
9
|
+
|
|
10
|
+
// The TUI package is provided by pi. Keep it optional so print/RPC usage and
|
|
11
|
+
// lightweight provider tests do not fail if that package is not installed.
|
|
12
|
+
let Markdown;
|
|
13
|
+
try {
|
|
14
|
+
Markdown = (await import("@earendil-works/pi-tui")).Markdown;
|
|
15
|
+
} catch {
|
|
16
|
+
Markdown = undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The theme passed to custom entry renderers is a general UI theme and does
|
|
20
|
+
// not implement Markdown methods such as `heading()`. Use pi's Markdown theme
|
|
21
|
+
// factory instead of passing that renderer theme directly to Markdown.
|
|
22
|
+
let getMarkdownTheme;
|
|
23
|
+
try {
|
|
24
|
+
getMarkdownTheme = (await import("@earendil-works/pi-coding-agent")).getMarkdownTheme;
|
|
25
|
+
} catch {
|
|
26
|
+
getMarkdownTheme = undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `openAICompletionsApi` moved to a lazy subpath in newer pi-ai builds;
|
|
30
|
+
// resolve both layouts so the extension works with older pi installations too.
|
|
31
|
+
const openAICompletionsApi = await (async () => {
|
|
32
|
+
try {
|
|
33
|
+
return (await import("@earendil-works/pi-ai/api/openai-completions.lazy")).openAICompletionsApi;
|
|
34
|
+
} catch {
|
|
35
|
+
return (await import("@earendil-works/pi-ai")).openAICompletionsApi;
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
const BASE_URL = "https://api-inference.modelscope.cn/v1";
|
|
40
|
+
const API_KEY_ENV = "MODELSCOPE_API_KEY";
|
|
41
|
+
|
|
42
|
+
// This is the model from ModelScope's OpenAI-compatible multimodal example.
|
|
43
|
+
// Keeping it in the seed list makes the provider usable before discovery has
|
|
44
|
+
// completed (or when /v1/models is unavailable).
|
|
45
|
+
const MODELSCOPE_SEED = ["Qwen/Qwen3.8-Flash-Next"];
|
|
46
|
+
|
|
47
|
+
const REASONING_EFFORTS = {
|
|
48
|
+
minimal: null,
|
|
49
|
+
low: "low",
|
|
50
|
+
medium: "medium",
|
|
51
|
+
high: "high",
|
|
52
|
+
xhigh: null,
|
|
53
|
+
max: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function isVisionModel(id, model) {
|
|
57
|
+
const modalities = model?.input_modalities ?? model?.inputModalities ?? model?.input;
|
|
58
|
+
if (Array.isArray(modalities) && modalities.some((item) => String(item).toLowerCase() === "image")) return true;
|
|
59
|
+
if (model?.supports_vision === true || model?.supportsVision === true || model?.multimodal === true) return true;
|
|
60
|
+
|
|
61
|
+
const capabilities = model?.capabilities;
|
|
62
|
+
if (capabilities && !Array.isArray(capabilities) && capabilities.vision === true) return true;
|
|
63
|
+
if (Array.isArray(capabilities) && capabilities.some((item) => /vision|image|multimodal/i.test(String(item)))) return true;
|
|
64
|
+
|
|
65
|
+
// ModelScope's model catalog does not consistently expose capabilities, so
|
|
66
|
+
// retain useful defaults for common vision model naming conventions and the
|
|
67
|
+
// documented multimodal seed model.
|
|
68
|
+
return id === "Qwen/Qwen3.8-Flash-Next" || /(?:-VL|vision|visual|vlm)/i.test(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasImageOutput(model) {
|
|
72
|
+
const modalities = model?.output_modalities ?? model?.outputModalities;
|
|
73
|
+
if (Array.isArray(modalities) && modalities.some((item) => String(item).toLowerCase() === "image")) return true;
|
|
74
|
+
return model?.supports_image_generation === true || model?.supportsImageGeneration === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readNumber(...values) {
|
|
78
|
+
for (const value of values) {
|
|
79
|
+
if (value !== undefined && value !== null && value !== "" && Number.isFinite(Number(value))) {
|
|
80
|
+
return Number(value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function modelCost(model) {
|
|
87
|
+
const source = model?.pricing ?? model?.prices ?? model?.cost ?? {};
|
|
88
|
+
const input = readNumber(
|
|
89
|
+
source.input, source.prompt, source.input_price, source.prompt_price,
|
|
90
|
+
model?.input_price, model?.prompt_price,
|
|
91
|
+
);
|
|
92
|
+
const output = readNumber(
|
|
93
|
+
source.output, source.completion, source.completion_price, source.output_price,
|
|
94
|
+
model?.output_price, model?.completion_price,
|
|
95
|
+
);
|
|
96
|
+
const cacheRead = readNumber(source.cacheRead, source.cache_read, source.cache_read_price) ?? 0;
|
|
97
|
+
const cacheWrite = readNumber(source.cacheWrite, source.cache_write, source.cache_write_price) ?? 0;
|
|
98
|
+
return {
|
|
99
|
+
input: input ?? 0,
|
|
100
|
+
output: output ?? 0,
|
|
101
|
+
cacheRead,
|
|
102
|
+
cacheWrite,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isReasoningModel(id, model) {
|
|
107
|
+
if (model?.reasoning === true || model?.supports_reasoning === true || model?.supportsReasoning === true) return true;
|
|
108
|
+
const capabilities = model?.capabilities;
|
|
109
|
+
if (capabilities && !Array.isArray(capabilities) && capabilities.reasoning === true) return true;
|
|
110
|
+
return /(?:thinking|reasoning|r1|qwq)/i.test(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function numberOr(value, fallback) {
|
|
114
|
+
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function convertModel(model) {
|
|
118
|
+
const id = typeof model?.id === "string" ? model.id : String(model?.id ?? "");
|
|
119
|
+
const vision = isVisionModel(id, model);
|
|
120
|
+
const reasoning = isReasoningModel(id, model);
|
|
121
|
+
const input = vision ? ["text", "image"] : ["text"];
|
|
122
|
+
const pricing = modelCost(model);
|
|
123
|
+
const contextWindow = numberOr(
|
|
124
|
+
model?.context_window ?? model?.contextWindow ?? model?.context_length ?? model?.max_model_len,
|
|
125
|
+
131072,
|
|
126
|
+
);
|
|
127
|
+
const maxTokens = numberOr(model?.max_tokens ?? model?.maxTokens, Math.min(contextWindow, 32768));
|
|
128
|
+
|
|
129
|
+
const converted = {
|
|
130
|
+
id,
|
|
131
|
+
name: typeof model?.name === "string" ? model.name : id,
|
|
132
|
+
reasoning,
|
|
133
|
+
input,
|
|
134
|
+
cost: pricing,
|
|
135
|
+
contextWindow,
|
|
136
|
+
maxTokens,
|
|
137
|
+
capabilities: {
|
|
138
|
+
tools: model?.supports_tools !== false && model?.supportsTools !== false,
|
|
139
|
+
vision,
|
|
140
|
+
image: hasImageOutput(model),
|
|
141
|
+
video: model?.supports_video === true || model?.supportsVideo === true,
|
|
142
|
+
audio: model?.supports_audio === true || model?.supportsAudio === true,
|
|
143
|
+
reasoning,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (reasoning) {
|
|
148
|
+
converted.thinkingLevelMap = REASONING_EFFORTS;
|
|
149
|
+
converted.compat = { supportsReasoningEffort: true, supportsDeveloperRole: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return converted;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function seedModels() {
|
|
156
|
+
return MODELSCOPE_SEED.map((id) => convertModel({ id }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function fetchModels(baseUrl, signal, apiKey) {
|
|
160
|
+
const headers = {};
|
|
161
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
162
|
+
|
|
163
|
+
const response = await fetch(`${baseUrl}/models`, {
|
|
164
|
+
headers,
|
|
165
|
+
redirect: "follow",
|
|
166
|
+
signal,
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
169
|
+
|
|
170
|
+
const payload = await response.json();
|
|
171
|
+
const data = Array.isArray(payload?.data)
|
|
172
|
+
? payload.data
|
|
173
|
+
: Array.isArray(payload)
|
|
174
|
+
? payload
|
|
175
|
+
: [];
|
|
176
|
+
return data.filter((model) => model && model.id).map(convertModel);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export default function (pi) {
|
|
180
|
+
const discovery = { models: seedModels() };
|
|
181
|
+
|
|
182
|
+
pi.registerProvider("modelscope", {
|
|
183
|
+
name: "ModelScope",
|
|
184
|
+
baseUrl: BASE_URL,
|
|
185
|
+
// An env reference prevents pi from treating a missing key as a literal
|
|
186
|
+
// credential while still allowing pi to report the provider as configured
|
|
187
|
+
// when MODELSCOPE_API_KEY is set.
|
|
188
|
+
apiKey: `$${API_KEY_ENV}`,
|
|
189
|
+
api: "openai-completions",
|
|
190
|
+
streamSimple: (model, context, options) =>
|
|
191
|
+
openAICompletionsApi().streamSimple(model, context, options),
|
|
192
|
+
models: discovery.models,
|
|
193
|
+
|
|
194
|
+
async refreshModels({ signal, stored, publish, allowNetwork, credential }) {
|
|
195
|
+
const cachedModels = Array.isArray(stored?.models) ? stored.models : undefined;
|
|
196
|
+
const fallback = cachedModels?.length ? cachedModels : seedModels();
|
|
197
|
+
|
|
198
|
+
// Pi first restores its persisted catalog without network access.
|
|
199
|
+
if (allowNetwork === false || signal?.aborted) return fallback;
|
|
200
|
+
|
|
201
|
+
const apiKey = credential?.key ?? process.env[API_KEY_ENV];
|
|
202
|
+
try {
|
|
203
|
+
const models = await fetchModels(BASE_URL, signal, apiKey);
|
|
204
|
+
if (models.length > 0) {
|
|
205
|
+
discovery.models = models;
|
|
206
|
+
await publish({ persist: { provider: "modelscope", models } });
|
|
207
|
+
return models;
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Discovery is optional. Keep pi usable offline, during an API outage,
|
|
211
|
+
// or before the user has configured a token.
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
discovery.models = fallback;
|
|
215
|
+
return fallback;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
registerModelCommands(pi, discovery);
|
|
220
|
+
installUsageTracker(pi);
|
|
221
|
+
registerUsageCommand(pi);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Commands
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
const CAPABILITY_FLAGS = {
|
|
229
|
+
reasoning: "reasoning",
|
|
230
|
+
vision: "vision",
|
|
231
|
+
image: "image",
|
|
232
|
+
video: "video",
|
|
233
|
+
audio: "audio",
|
|
234
|
+
tools: "tools",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function showMarkdown(pi, ctx, key, markdown) {
|
|
238
|
+
if (ctx?.mode === "tui") pi.appendEntry(key, { markdown });
|
|
239
|
+
else if (ctx?.hasUI) ctx.ui.notify(markdown, "info");
|
|
240
|
+
else console.log(markdown);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function registerMarkdownRenderer(pi, key) {
|
|
244
|
+
if (typeof Markdown !== "function" || typeof getMarkdownTheme !== "function") return;
|
|
245
|
+
pi.registerEntryRenderer?.(key, (entry) =>
|
|
246
|
+
new Markdown(entry.data?.markdown ?? "", 1, 0, getMarkdownTheme()),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getModelCatalog(ctx, discovery) {
|
|
251
|
+
const registered = ctx?.modelRegistry?.getAll?.() ?? [];
|
|
252
|
+
const models = registered.filter((model) => model.provider === "modelscope");
|
|
253
|
+
return models.length > 0 ? models : discovery.models;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatSize(value) {
|
|
257
|
+
const n = Number(value);
|
|
258
|
+
if (!Number.isFinite(n) || n <= 0) return "—";
|
|
259
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
260
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
|
261
|
+
return String(n);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function registerModelCommands(pi, discovery) {
|
|
265
|
+
if (typeof pi.registerCommand !== "function") return;
|
|
266
|
+
|
|
267
|
+
pi.registerCommand("modelscope-models", {
|
|
268
|
+
description: "List ModelScope models with capabilities and limits; optional filter: image, vision, tools, reasoning, audio, video.",
|
|
269
|
+
handler: async (args, ctx) => {
|
|
270
|
+
const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
|
|
271
|
+
const filter = tokens.find((token) => token in CAPABILITY_FLAGS);
|
|
272
|
+
const mark = (value) => value ? "✓" : "—";
|
|
273
|
+
const rows = getModelCatalog(ctx, discovery)
|
|
274
|
+
.map((model) => {
|
|
275
|
+
const caps = model.capabilities ?? {};
|
|
276
|
+
return {
|
|
277
|
+
model,
|
|
278
|
+
reasoning: caps.reasoning || model.reasoning,
|
|
279
|
+
vision: caps.vision || model.input?.includes("image"),
|
|
280
|
+
image: !!caps.image,
|
|
281
|
+
video: !!caps.video,
|
|
282
|
+
audio: !!caps.audio,
|
|
283
|
+
tools: caps.tools !== false,
|
|
284
|
+
};
|
|
285
|
+
})
|
|
286
|
+
.filter((row) => !filter || row[CAPABILITY_FLAGS[filter]])
|
|
287
|
+
.sort((a, b) => a.model.id.localeCompare(b.model.id));
|
|
288
|
+
const markdown = [
|
|
289
|
+
`# ModelScope models${filter ? ` (filter: ${filter})` : ""}`,
|
|
290
|
+
"",
|
|
291
|
+
"| Model | Display Name | Reasoning | Vision | Image | Video | Audio | Tools | Context | Max Output |",
|
|
292
|
+
"|---|---|:---:|:---:|:---:|:---:|:---:|:---:|---:|---:|",
|
|
293
|
+
...rows.map((row) => `| \`${row.model.id}\` | ${row.model.name || row.model.id} | ${mark(row.reasoning)} | ${mark(row.vision)} | ${mark(row.image)} | ${mark(row.video)} | ${mark(row.audio)} | ${mark(row.tools)} | ${formatSize(row.model.contextWindow)} | ${formatSize(row.model.maxTokens)} |`),
|
|
294
|
+
"",
|
|
295
|
+
"_Capabilities come from ModelScope metadata when available; `—` means the capability was not advertised._",
|
|
296
|
+
rows.length ? "" : "_No models match the filter._",
|
|
297
|
+
].join("\n");
|
|
298
|
+
showMarkdown(pi, ctx, "modelscope-models", markdown);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
registerMarkdownRenderer(pi, "modelscope-models");
|
|
302
|
+
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ModelScope does not provide a billing/usage endpoint in its OpenAI-compatible
|
|
306
|
+
// API. Track the usage reported by completed assistant messages instead. This
|
|
307
|
+
// is process-local Pi usage, not an account-level ModelScope invoice.
|
|
308
|
+
const MODELSCOPE_SESSION_USAGE = new Map();
|
|
309
|
+
let usageHookInstalled = false;
|
|
310
|
+
|
|
311
|
+
function installUsageTracker(pi) {
|
|
312
|
+
if (usageHookInstalled || typeof pi.on !== "function") return;
|
|
313
|
+
usageHookInstalled = true;
|
|
314
|
+
pi.on("message_end", (event) => {
|
|
315
|
+
const message = event?.message;
|
|
316
|
+
if (message?.role !== "assistant" || message.provider !== "modelscope") return;
|
|
317
|
+
|
|
318
|
+
const key = `${message.provider}/${message.model}`;
|
|
319
|
+
const row = MODELSCOPE_SESSION_USAGE.get(key) ?? {
|
|
320
|
+
provider: message.provider,
|
|
321
|
+
model: message.model,
|
|
322
|
+
turns: 0,
|
|
323
|
+
input: 0,
|
|
324
|
+
output: 0,
|
|
325
|
+
total: 0,
|
|
326
|
+
cost: 0,
|
|
327
|
+
};
|
|
328
|
+
const usage = message.usage ?? {};
|
|
329
|
+
const cost = usage.cost ?? {};
|
|
330
|
+
const input = Number(usage.input) || 0;
|
|
331
|
+
const output = Number(usage.output) || 0;
|
|
332
|
+
const total = Number(usage.totalTokens) || input + output;
|
|
333
|
+
const turnCost = Number(cost.total);
|
|
334
|
+
|
|
335
|
+
row.turns += 1;
|
|
336
|
+
row.input += input;
|
|
337
|
+
row.output += output;
|
|
338
|
+
row.total += total;
|
|
339
|
+
row.cost += Number.isFinite(turnCost) ? turnCost : 0;
|
|
340
|
+
MODELSCOPE_SESSION_USAGE.set(key, row);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function registerUsageCommand(pi) {
|
|
345
|
+
if (typeof pi.registerCommand !== "function") return;
|
|
346
|
+
|
|
347
|
+
pi.registerCommand("modelscope-usage", {
|
|
348
|
+
description: "Show ModelScope token/cost usage accumulated in the current Pi process.",
|
|
349
|
+
handler: async (_args, ctx) => {
|
|
350
|
+
const rows = [...MODELSCOPE_SESSION_USAGE.values()]
|
|
351
|
+
.sort((a, b) => a.model.localeCompare(b.model));
|
|
352
|
+
const totalCost = rows.reduce((sum, row) => sum + row.cost, 0);
|
|
353
|
+
const markdown = [
|
|
354
|
+
"# ModelScope session usage",
|
|
355
|
+
"",
|
|
356
|
+
"_This is usage reported by completed assistant messages in the current Pi process, not a ModelScope account billing dashboard._",
|
|
357
|
+
"",
|
|
358
|
+
"| Model | Turns | Input Tokens | Output Tokens | Total Tokens | Cost |",
|
|
359
|
+
"|---|---:|---:|---:|---:|---:|",
|
|
360
|
+
...rows.map((row) => `| ${row.model} | ${row.turns} | ${row.input.toLocaleString()} | ${row.output.toLocaleString()} | ${row.total.toLocaleString()} | $${row.cost.toFixed(6)} |`),
|
|
361
|
+
"",
|
|
362
|
+
rows.length
|
|
363
|
+
? `**Session total:** $${totalCost.toFixed(6)}`
|
|
364
|
+
: "_No ModelScope assistant usage recorded in this Pi process yet._",
|
|
365
|
+
].join("\n");
|
|
366
|
+
showMarkdown(pi, ctx, "modelscope-usage", markdown);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
registerMarkdownRenderer(pi, "modelscope-usage");
|
|
370
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-modelscope",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for the ModelScope OpenAI-compatible provider",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"modelscope",
|
|
8
|
+
"qwen",
|
|
9
|
+
"openai-compatible"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/pgciq/pi-modelscope"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"registry": "https://registry.npmjs.org/"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-ai": "*",
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|