pi-provider-swiss-ai-platform 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/LICENSE +21 -0
- package/README.md +82 -0
- package/index.ts +460 -0
- package/package.json +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (C) 2026 Daniel Roethlisberger <daniel@roe.ch>
|
|
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,82 @@
|
|
|
1
|
+
# Pi provider extension for Swiss AI Platform
|
|
2
|
+
Copyright (C) 2026, [Daniel Roethlisberger](//daniel.roe.ch/).
|
|
3
|
+
https://github.com/droe/pi-provider-swiss-ai-platform
|
|
4
|
+
|
|
5
|
+
Experimental Pi extension that registers a model provider for [Swiss AI
|
|
6
|
+
Platform][1] by Swisscom in partnership with NVIDIA, offered via Swisscom's API
|
|
7
|
+
platform [Digital Marketplace][2].
|
|
8
|
+
|
|
9
|
+
[1]: https://www.swisscom.ch/en/business/enterprise/offer/platforms-applications/data-driven-business/swiss-ai-platform.html
|
|
10
|
+
[2]: https://digital.swisscom.com/products/swiss-ai-platform/info
|
|
11
|
+
|
|
12
|
+
## Models
|
|
13
|
+
|
|
14
|
+
As of August 2026, the following models offered as part of Swiss AI Platform
|
|
15
|
+
work with Pi:
|
|
16
|
+
|
|
17
|
+
- `swiss-ai/Apertus-v1.5-70B`
|
|
18
|
+
- `google/gemma-4-31b-it`
|
|
19
|
+
- `qwen/qwen3.5-397b-a17b`
|
|
20
|
+
- `qwen/qwen3.6-35b-a3b`
|
|
21
|
+
- `mistralai/mistral-small-4-119b-2603` (partially)
|
|
22
|
+
|
|
23
|
+
See `MODEL_METADATA` in `index.ts` for metadata on all models, including hidden
|
|
24
|
+
models and why they have been hidden. Feedback or patches to improve model
|
|
25
|
+
compatibility very welcome.
|
|
26
|
+
|
|
27
|
+
Models added more recently, i.e. models not included in `MODEL_METADATA` yet,
|
|
28
|
+
may or may not work, and likely need manual configuration in
|
|
29
|
+
`~/.pi/agent/models.json`.
|
|
30
|
+
|
|
31
|
+
## Prerequisites
|
|
32
|
+
|
|
33
|
+
You need a Swiss AI Platform subscription and matching Client ID and Client
|
|
34
|
+
Secret.
|
|
35
|
+
|
|
36
|
+
In your subscription on Digital Marketplace, select Documentation and check
|
|
37
|
+
«Production Url». The last path component is the subscription name, e.g.
|
|
38
|
+
`all-models` or `apertus-1.5-70b`. Note that for model-specific subscriptions,
|
|
39
|
+
the subscription name is different from the model identifier.
|
|
40
|
+
|
|
41
|
+
The credentials are more straightforward. From your subscription, select
|
|
42
|
+
«Credentials» and check the «OAuth 2.0 Credentials» section.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pi install npm:pi-provider-swiss-ai-platform
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Interactive configuration as usual: `/login` -> «Swiss AI Platform» prompts for
|
|
53
|
+
the subscription name, the Client ID and the Client Secret, verifies them
|
|
54
|
+
against the gateway and stores them in `~/.pi/agent/auth.json`. Access tokens
|
|
55
|
+
are minted on demand and cached in memory until they expire.
|
|
56
|
+
|
|
57
|
+
Multiple configured subscriptions are not currently implemented. Switching
|
|
58
|
+
subscription is another `/login`.
|
|
59
|
+
|
|
60
|
+
For headless use, the following environment variables can be set:
|
|
61
|
+
|
|
62
|
+
- `SWISS_AI_PLATFORM_SUBSCRIPTION`
|
|
63
|
+
- `SWISS_AI_PLATFORM_CLIENT_ID`
|
|
64
|
+
- `SWISS_AI_PLATFORM_CLIENT_SECRET`
|
|
65
|
+
|
|
66
|
+
## Implementation Details
|
|
67
|
+
|
|
68
|
+
Models are served over the OpenAI-compatible `/v1/chat/completions` API; only
|
|
69
|
+
some of them also support `/v1/responses`, so completions is the default and
|
|
70
|
+
`MODEL_METADATA.api` opts a model into the Responses API. Authentication is an
|
|
71
|
+
OAuth 2.0 client-credentials grant (HTTP Basic client authentication).
|
|
72
|
+
|
|
73
|
+
The model catalog is pulled from `/v1/models` on model refresh and cached by Pi
|
|
74
|
+
in `~/.pi/agent/models-store.json`. The endpoint returns ids only, so model
|
|
75
|
+
capabilities come from the curated `MODEL_METADATA` table in `index.ts`.
|
|
76
|
+
Unknown ids fall back to `DEFAULT_METADATA`; entries flagged `hideModel: true`
|
|
77
|
+
are dropped from the catalog. Per-model overrides are still possible in
|
|
78
|
+
`~/.pi/agent/models.json`.
|
|
79
|
+
|
|
80
|
+
## Disclaimer
|
|
81
|
+
|
|
82
|
+
This Pi extension is inofficial, neither provided by nor supported by Swisscom.
|
package/index.ts
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pi provider extension for Swiss AI Platform by Swisscom.
|
|
3
|
+
* Copyright (C) 2026 Daniel Roethlisberger <daniel@roe.ch>
|
|
4
|
+
* https://github.com/droe/pi-provider-swiss-ai-platform
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ApiKeyAuth,
|
|
9
|
+
type ApiKeyCredential,
|
|
10
|
+
type AuthInteraction,
|
|
11
|
+
createProvider,
|
|
12
|
+
type Model,
|
|
13
|
+
type RefreshModelsContext,
|
|
14
|
+
} from "@earendil-works/pi-ai";
|
|
15
|
+
import { openAICompletionsApi, openAIResponsesApi } from "@earendil-works/pi-ai/compat";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Configuration
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
const PROVIDER_ID = "swiss-ai-platform";
|
|
23
|
+
const PROVIDER_NAME = "Swiss AI Platform";
|
|
24
|
+
const PRODUCT_URL = "https://api.swisscom.com/products/swiss-ai-platform";
|
|
25
|
+
const TOKEN_URL = "https://api.swisscom.com/products/oauth2/token";
|
|
26
|
+
|
|
27
|
+
const ENV_SUBSCRIPTION = "SWISS_AI_PLATFORM_SUBSCRIPTION";
|
|
28
|
+
const ENV_CLIENT_ID = "SWISS_AI_PLATFORM_CLIENT_ID";
|
|
29
|
+
const ENV_CLIENT_SECRET = "SWISS_AI_PLATFORM_CLIENT_SECRET";
|
|
30
|
+
const ENV_ACCESS_TOKEN = "SWISS_AI_PLATFORM_ACCESS_TOKEN";
|
|
31
|
+
|
|
32
|
+
/** Safety margin subtracted from the token lifetime reported by the gateway. */
|
|
33
|
+
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/** e.g. subscription "all-models" -> ".../swiss-ai-platform/all-models/v1" */
|
|
36
|
+
const baseUrlFor = (subscription: string) => `${PRODUCT_URL}/${encodeURIComponent(subscription)}/v1`;
|
|
37
|
+
|
|
38
|
+
type SwissApi = "openai-completions" | "openai-responses";
|
|
39
|
+
type SwissModel = Model<SwissApi>;
|
|
40
|
+
|
|
41
|
+
/** Only some models support the Responses API, so chat completions is the default. */
|
|
42
|
+
const DEFAULT_API: SwissApi = "openai-completions";
|
|
43
|
+
|
|
44
|
+
interface ModelMetadata {
|
|
45
|
+
name?: string;
|
|
46
|
+
/** Streaming API for this model. Default: DEFAULT_API ("openai-completions"). */
|
|
47
|
+
api?: SwissApi;
|
|
48
|
+
/**
|
|
49
|
+
* Keep the model out of pi entirely. Use it for models that cannot serve a
|
|
50
|
+
* coding agent, e.g. text-to-speech, speech-to-text, embeddings or moderation.
|
|
51
|
+
*/
|
|
52
|
+
hideModel?: boolean;
|
|
53
|
+
reasoning?: boolean;
|
|
54
|
+
input?: ("text" | "image")[];
|
|
55
|
+
/** USD per million tokens. */
|
|
56
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
57
|
+
contextWindow?: number;
|
|
58
|
+
maxTokens?: number;
|
|
59
|
+
thinkingLevelMap?: SwissModel["thinkingLevelMap"];
|
|
60
|
+
compat?: SwissModel["compat"];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Curated capabilities per model id, keyed exactly as returned by /v1/models.
|
|
65
|
+
* Model documentation by Swisscom:
|
|
66
|
+
* https://docs.cloud.swisscom.ch/guide/cloud-services/aip/use/inference-endpoints/
|
|
67
|
+
*
|
|
68
|
+
* Configuration may not be perfect, please submit issues or PRs with improvements.
|
|
69
|
+
*/
|
|
70
|
+
const MODEL_METADATA: Record<string, ModelMetadata> = {
|
|
71
|
+
"google/gemma-4-31b-it": {
|
|
72
|
+
name: "Gemma 4 31b",
|
|
73
|
+
api: "openai-responses",
|
|
74
|
+
reasoning: true,
|
|
75
|
+
input: ["text", "image"],
|
|
76
|
+
contextWindow: 256000,
|
|
77
|
+
},
|
|
78
|
+
"magpie-tts-multilingual": {
|
|
79
|
+
// Disabled because Pi does not do text-to-speech.
|
|
80
|
+
hideModel: true,
|
|
81
|
+
name: "Magpie TTS Multilingual",
|
|
82
|
+
},
|
|
83
|
+
"meta/llama-3.1-8b-instruct": {
|
|
84
|
+
// Disabled because the model does not seem to work with Pi.
|
|
85
|
+
hideModel: true,
|
|
86
|
+
name: "Llama 3.1 8b instruct",
|
|
87
|
+
},
|
|
88
|
+
"meta/llama-4-scout-17b-16e-instruct": {
|
|
89
|
+
// Disabled because tool calling does not seem to work.
|
|
90
|
+
hideModel: true,
|
|
91
|
+
name: "Llama 4 Scout 17b 16e instruct",
|
|
92
|
+
input: ["text", "image"],
|
|
93
|
+
contextWindow: 131072,
|
|
94
|
+
},
|
|
95
|
+
"mistralai/mistral-small-4-119b-2603": {
|
|
96
|
+
// FIXME Docs state that this model supports the responses API, but
|
|
97
|
+
// responses API results in error 400 "'input_text' is not a valid
|
|
98
|
+
// ChunkTypes". Use completions until someone figures this one out.
|
|
99
|
+
// FIXME [THINK]...[/THINK] tags are not recognised by Pi.
|
|
100
|
+
name: "Mistral Small 4 119b 2603",
|
|
101
|
+
//api: "openai-responses",
|
|
102
|
+
reasoning: true,
|
|
103
|
+
input: ["text", "image"],
|
|
104
|
+
contextWindow: 256144,
|
|
105
|
+
thinkingLevelMap: {
|
|
106
|
+
off: "none",
|
|
107
|
+
minimal: null,
|
|
108
|
+
low: null,
|
|
109
|
+
medium: null,
|
|
110
|
+
high: "high",
|
|
111
|
+
xhigh: null,
|
|
112
|
+
max: null,
|
|
113
|
+
},
|
|
114
|
+
compat: {
|
|
115
|
+
supportsDeveloperRole: false,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
"nvidia/llama-3.1-nemoguard-8b-content-safety": {
|
|
119
|
+
// Disabled because Pi does not do content safety.
|
|
120
|
+
hideModel: true,
|
|
121
|
+
name: "Llama 3.1 Nemoguard 8b content safety",
|
|
122
|
+
},
|
|
123
|
+
"nvidia/llama-3.2-nv-embedqa-1b-v2": {
|
|
124
|
+
// Disabled because Pi does not do text-to-embeddings.
|
|
125
|
+
hideModel: true,
|
|
126
|
+
name: "Llama 3.2 NV embedqa 1b v2",
|
|
127
|
+
},
|
|
128
|
+
"openai/gpt-oss-20b": {
|
|
129
|
+
// FIXME Works, but performs very poorly with tool calling.
|
|
130
|
+
// Should investigate root cause.
|
|
131
|
+
hideModel: true,
|
|
132
|
+
name: "GPT OSS 20b",
|
|
133
|
+
input: ["text"],
|
|
134
|
+
contextWindow: 131072,
|
|
135
|
+
},
|
|
136
|
+
"openai/gpt-oss-120b": {
|
|
137
|
+
// FIXME Works, but performs very poorly with tool calling.
|
|
138
|
+
// Should investigate root cause.
|
|
139
|
+
hideModel: true,
|
|
140
|
+
name: "GPT OSS 120b",
|
|
141
|
+
input: ["text"],
|
|
142
|
+
contextWindow: 131072,
|
|
143
|
+
},
|
|
144
|
+
"openai/whisper-large-v3-turbo": {
|
|
145
|
+
// Disabled because Pi does not do speech-to-text.
|
|
146
|
+
hideModel: true,
|
|
147
|
+
name: "Whisper Large V3 Turbo",
|
|
148
|
+
},
|
|
149
|
+
"qwen/qwen3.5-397b-a17b": {
|
|
150
|
+
name: "Qwen 3.5 397b A17B",
|
|
151
|
+
reasoning: true,
|
|
152
|
+
input: ["text"],
|
|
153
|
+
contextWindow: 262144,
|
|
154
|
+
thinkingLevelMap: {
|
|
155
|
+
off: "none",
|
|
156
|
+
minimal: null,
|
|
157
|
+
low: "low",
|
|
158
|
+
medium: "medium",
|
|
159
|
+
high: "high",
|
|
160
|
+
xhigh: null,
|
|
161
|
+
max: null,
|
|
162
|
+
},
|
|
163
|
+
compat: {
|
|
164
|
+
thinkingFormat: "qwen",
|
|
165
|
+
supportsDeveloperRole: false,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"qwen/qwen3.6-35b-a3b": {
|
|
169
|
+
// FIXME Docs state that this model supports the responses API, but
|
|
170
|
+
// responses API results in "Error: OpenAI API error (429): 429
|
|
171
|
+
// status code (no body)". Use completions until someone figures
|
|
172
|
+
// this one out.
|
|
173
|
+
name: "Qwen 3.6 35b A3B",
|
|
174
|
+
//api: "openai-responses",
|
|
175
|
+
reasoning: true,
|
|
176
|
+
input: ["text"],
|
|
177
|
+
contextWindow: 262144,
|
|
178
|
+
compat: {
|
|
179
|
+
thinkingFormat: "qwen",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"rednote-hilab/dots.ocr": {
|
|
183
|
+
// Disabled because Pi does not do OCR.
|
|
184
|
+
hideModel: true,
|
|
185
|
+
name: "Rednote Hilab Dots OCR",
|
|
186
|
+
},
|
|
187
|
+
"swiss-ai/Apertus-v1.5-70B": {
|
|
188
|
+
name: "Apertus v1.5 70b",
|
|
189
|
+
api: "openai-responses",
|
|
190
|
+
input: ["text", "image"],
|
|
191
|
+
contextWindow: 262144,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Applied to models returned by /v1/models that have no MODEL_METADATA entry. */
|
|
196
|
+
const DEFAULT_METADATA: Required<Pick<ModelMetadata, "reasoning" | "input" | "cost" | "contextWindow" | "maxTokens">> = {
|
|
197
|
+
reasoning: false,
|
|
198
|
+
input: ["text"],
|
|
199
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
200
|
+
contextWindow: 131_072,
|
|
201
|
+
maxTokens: 16_384,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Models flagged with `hideModel` are never offered by this provider. */
|
|
205
|
+
const isHidden = (id: string) => MODEL_METADATA[id]?.hideModel === true;
|
|
206
|
+
|
|
207
|
+
// =============================================================================
|
|
208
|
+
// Gateway requests
|
|
209
|
+
// =============================================================================
|
|
210
|
+
|
|
211
|
+
interface AccessToken {
|
|
212
|
+
access: string;
|
|
213
|
+
/** Absolute expiry in ms, already reduced by TOKEN_EXPIRY_SKEW_MS. */
|
|
214
|
+
expires: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function errorDetail(response: Response): Promise<string> {
|
|
218
|
+
const body = await response.text().catch(() => "");
|
|
219
|
+
try {
|
|
220
|
+
const json = JSON.parse(body) as { message?: string; error_description?: string; error?: string };
|
|
221
|
+
return json.message ?? json.error_description ?? json.error ?? body;
|
|
222
|
+
} catch {
|
|
223
|
+
return body;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** OAuth 2.0 client-credentials grant with HTTP Basic client authentication. */
|
|
228
|
+
async function requestAccessToken(clientId: string, clientSecret: string, signal?: AbortSignal): Promise<AccessToken> {
|
|
229
|
+
const response = await fetch(TOKEN_URL, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`, "utf8").toString("base64")}`,
|
|
233
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
234
|
+
Accept: "application/json",
|
|
235
|
+
},
|
|
236
|
+
body: new URLSearchParams({ grant_type: "client_credentials" }).toString(),
|
|
237
|
+
signal,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new Error(`${PROVIDER_NAME} token request failed (${response.status}): ${await errorDetail(response)}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = (await response.json()) as { access_token?: string; expires_in?: number };
|
|
245
|
+
if (!data.access_token) throw new Error(`${PROVIDER_NAME} token response contained no access_token`);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
access: data.access_token,
|
|
249
|
+
expires: Date.now() + (data.expires_in ?? 3600) * 1000 - TOKEN_EXPIRY_SKEW_MS,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** In-process token cache; promises are cached so concurrent requests share one grant. */
|
|
254
|
+
const tokenCache = new Map<string, Promise<AccessToken>>();
|
|
255
|
+
|
|
256
|
+
async function cachedAccessToken(clientId: string, clientSecret: string, signal?: AbortSignal): Promise<string> {
|
|
257
|
+
const key = `${clientId}:${clientSecret}`;
|
|
258
|
+
const pending = tokenCache.get(key);
|
|
259
|
+
|
|
260
|
+
if (pending) {
|
|
261
|
+
const token = await pending.catch(() => undefined);
|
|
262
|
+
if (token && token.expires > Date.now()) return token.access;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const request = requestAccessToken(clientId, clientSecret, signal);
|
|
266
|
+
tokenCache.set(key, request);
|
|
267
|
+
try {
|
|
268
|
+
return (await request).access;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
tokenCache.delete(key);
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Model ids offered by one subscription. Doubles as the subscription check during login. */
|
|
276
|
+
async function fetchModelIds(subscription: string, token: string, signal?: AbortSignal): Promise<string[]> {
|
|
277
|
+
const response = await fetch(`${baseUrlFor(subscription)}/models`, {
|
|
278
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
279
|
+
signal,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`${PROVIDER_NAME} model listing for subscription "${subscription}" failed (${response.status}): ${await errorDetail(response)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const payload = (await response.json()) as { data?: { id?: string }[] };
|
|
289
|
+
return (payload.data ?? []).map((entry) => entry?.id).filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// =============================================================================
|
|
293
|
+
// Auth
|
|
294
|
+
// =============================================================================
|
|
295
|
+
|
|
296
|
+
/** Everything needed to reach one subscription: endpoint plus a way to get a token. */
|
|
297
|
+
interface Settings {
|
|
298
|
+
subscription: string;
|
|
299
|
+
token?: string;
|
|
300
|
+
clientId?: string;
|
|
301
|
+
clientSecret?: string;
|
|
302
|
+
source: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
type EnvLookup = (name: string) => Promise<string | undefined> | string | undefined;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Collect settings from the stored credential first, then the environment.
|
|
309
|
+
* The subscription name is mandatory: without it there is no endpoint to call.
|
|
310
|
+
*/
|
|
311
|
+
async function lookupSettings(env: EnvLookup, credential?: ApiKeyCredential): Promise<Settings | undefined> {
|
|
312
|
+
const stored = credential?.env ?? {};
|
|
313
|
+
const subscription = stored[ENV_SUBSCRIPTION] ?? (await env(ENV_SUBSCRIPTION));
|
|
314
|
+
|
|
315
|
+
const token = credential?.key ?? (await env(ENV_ACCESS_TOKEN));
|
|
316
|
+
const clientId = stored[ENV_CLIENT_ID] ?? (await env(ENV_CLIENT_ID));
|
|
317
|
+
const clientSecret = stored[ENV_CLIENT_SECRET] ?? (await env(ENV_CLIENT_SECRET));
|
|
318
|
+
|
|
319
|
+
const origin = token
|
|
320
|
+
? credential?.key
|
|
321
|
+
? "stored access token"
|
|
322
|
+
: ENV_ACCESS_TOKEN
|
|
323
|
+
: clientId && clientSecret
|
|
324
|
+
? stored[ENV_CLIENT_ID]
|
|
325
|
+
? "stored client credentials"
|
|
326
|
+
: `${ENV_CLIENT_ID}/${ENV_CLIENT_SECRET}`
|
|
327
|
+
: undefined;
|
|
328
|
+
if (!origin) return undefined;
|
|
329
|
+
|
|
330
|
+
if (!subscription) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`No ${PROVIDER_NAME} subscription name — run /login ${PROVIDER_ID} or set ${ENV_SUBSCRIPTION}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { subscription, token, clientId, clientSecret, source: `${origin}, subscription "${subscription}"` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function accessTokenFor(settings: Settings, signal?: AbortSignal): Promise<string> {
|
|
340
|
+
return settings.token ?? cachedAccessToken(settings.clientId!, settings.clientSecret!, signal);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const apiKeyAuth: ApiKeyAuth = {
|
|
344
|
+
name: `${PROVIDER_NAME} subscription and client credentials`,
|
|
345
|
+
|
|
346
|
+
/** Interactive setup started by /login: subscription name, Client ID, Client Secret. */
|
|
347
|
+
async login(interaction: AuthInteraction): Promise<ApiKeyCredential> {
|
|
348
|
+
const subscription = (
|
|
349
|
+
await interaction.prompt({
|
|
350
|
+
type: "text",
|
|
351
|
+
message: "Subscription name (part of the API base URL, no default)",
|
|
352
|
+
placeholder: "all-models",
|
|
353
|
+
})
|
|
354
|
+
).trim();
|
|
355
|
+
if (!subscription) throw new Error("A subscription name is required");
|
|
356
|
+
|
|
357
|
+
const clientId = (
|
|
358
|
+
await interaction.prompt({ type: "text", message: "Client ID", placeholder: "OAuth 2.0 client id" })
|
|
359
|
+
).trim();
|
|
360
|
+
const clientSecret = (await interaction.prompt({ type: "secret", message: "Client Secret" })).trim();
|
|
361
|
+
if (!clientId || !clientSecret) throw new Error("Client ID and Client Secret are required");
|
|
362
|
+
|
|
363
|
+
// Verify credentials and subscription before storing; also primes the token cache.
|
|
364
|
+
interaction.notify({ type: "progress", message: "Verifying client credentials..." });
|
|
365
|
+
const token = await cachedAccessToken(clientId, clientSecret, interaction.signal);
|
|
366
|
+
|
|
367
|
+
interaction.notify({ type: "progress", message: `Checking subscription "${subscription}"...` });
|
|
368
|
+
const modelIds = (await fetchModelIds(subscription, token, interaction.signal)).filter((id) => !isHidden(id));
|
|
369
|
+
interaction.notify({ type: "info", message: `${modelIds.length} usable model(s) in "${subscription}"` });
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
type: "api_key",
|
|
373
|
+
env: {
|
|
374
|
+
[ENV_SUBSCRIPTION]: subscription,
|
|
375
|
+
[ENV_CLIENT_ID]: clientId,
|
|
376
|
+
[ENV_CLIENT_SECRET]: clientSecret,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async check({ ctx, credential }) {
|
|
382
|
+
const settings = await lookupSettings((name) => ctx.env(name), credential).catch(() => undefined);
|
|
383
|
+
return settings ? { type: "api_key", source: settings.source } : undefined;
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async resolve({ ctx, credential }) {
|
|
387
|
+
const settings = await lookupSettings((name) => ctx.env(name), credential);
|
|
388
|
+
if (!settings) return undefined;
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
auth: { apiKey: await accessTokenFor(settings), baseUrl: baseUrlFor(settings.subscription) },
|
|
392
|
+
env: { [ENV_SUBSCRIPTION]: settings.subscription },
|
|
393
|
+
source: settings.source,
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// =============================================================================
|
|
399
|
+
// Model catalog
|
|
400
|
+
// =============================================================================
|
|
401
|
+
|
|
402
|
+
function toModel(id: string, baseUrl: string): SwissModel {
|
|
403
|
+
const meta = MODEL_METADATA[id] ?? {};
|
|
404
|
+
return {
|
|
405
|
+
id,
|
|
406
|
+
name: meta.name ?? id,
|
|
407
|
+
api: meta.api ?? DEFAULT_API,
|
|
408
|
+
provider: PROVIDER_ID,
|
|
409
|
+
baseUrl,
|
|
410
|
+
reasoning: meta.reasoning ?? DEFAULT_METADATA.reasoning,
|
|
411
|
+
...(meta.thinkingLevelMap ? { thinkingLevelMap: meta.thinkingLevelMap } : {}),
|
|
412
|
+
input: meta.input ?? DEFAULT_METADATA.input,
|
|
413
|
+
cost: meta.cost ?? DEFAULT_METADATA.cost,
|
|
414
|
+
contextWindow: meta.contextWindow ?? DEFAULT_METADATA.contextWindow,
|
|
415
|
+
maxTokens: meta.maxTokens ?? DEFAULT_METADATA.maxTokens,
|
|
416
|
+
...(meta.compat ? { compat: meta.compat } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Throws on failure so pi keeps the previously stored catalog. */
|
|
421
|
+
async function fetchModels({ credential, signal }: RefreshModelsContext): Promise<readonly SwissModel[]> {
|
|
422
|
+
const apiKeyCredential: ApiKeyCredential | undefined = credential?.type === "api_key" ? credential : undefined;
|
|
423
|
+
const settings = await lookupSettings((name) => process.env[name], apiKeyCredential);
|
|
424
|
+
if (!settings) {
|
|
425
|
+
throw new Error(`No ${PROVIDER_NAME} credentials — run /login ${PROVIDER_ID} or set ${ENV_CLIENT_ID}/${ENV_CLIENT_SECRET}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const token = await accessTokenFor(settings, signal);
|
|
429
|
+
const baseUrl = baseUrlFor(settings.subscription);
|
|
430
|
+
|
|
431
|
+
return (await fetchModelIds(settings.subscription, token, signal))
|
|
432
|
+
.filter((id) => !isHidden(id))
|
|
433
|
+
.map((id) => toModel(id, baseUrl))
|
|
434
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// =============================================================================
|
|
438
|
+
// Extension entry point
|
|
439
|
+
// =============================================================================
|
|
440
|
+
|
|
441
|
+
export default function (pi: ExtensionAPI) {
|
|
442
|
+
pi.registerProvider(
|
|
443
|
+
createProvider<SwissApi>({
|
|
444
|
+
id: PROVIDER_ID,
|
|
445
|
+
name: PROVIDER_NAME,
|
|
446
|
+
// Per-credential endpoint; the subscription-specific URL comes from auth.
|
|
447
|
+
baseUrl: PRODUCT_URL,
|
|
448
|
+
auth: { apiKey: apiKeyAuth },
|
|
449
|
+
models: [],
|
|
450
|
+
fetchModels,
|
|
451
|
+
// Second line of defence: also hides models restored from a stale catalog cache.
|
|
452
|
+
filterModels: (models) => models.filter((model) => !isHidden(model.id)),
|
|
453
|
+
// Per-model dispatch: completions for everything unless MODEL_METADATA says otherwise.
|
|
454
|
+
api: {
|
|
455
|
+
"openai-completions": openAICompletionsApi(),
|
|
456
|
+
"openai-responses": openAIResponsesApi(),
|
|
457
|
+
},
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
}
|