@astralyn/pi 0.82.7 → 0.83.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/CHANGELOG.md +34 -5099
- package/README.md +12 -111
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +9 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/credential-print.d.ts +23 -0
- package/dist/cli/credential-print.d.ts.map +1 -0
- package/dist/cli/credential-print.js +116 -0
- package/dist/cli/credential-print.js.map +1 -0
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +3 -0
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/agent-session.d.ts +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +13 -6
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/runner.d.ts +1 -0
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +7 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/types.d.ts +7 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/footer-data-provider.d.ts +10 -0
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +1 -1
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/model-runtime.d.ts +2 -0
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +13 -6
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/resource-loader.d.ts +15 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +70 -9
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +34 -4
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/trust-manager.d.ts.map +1 -1
- package/dist/core/trust-manager.js +48 -9
- package/dist/core/trust-manager.js.map +1 -1
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +0 -2
- package/dist/extensions/index.js.map +1 -1
- package/dist/extensions/llama/provider.d.ts.map +1 -1
- package/dist/extensions/llama/provider.js +1 -1
- package/dist/extensions/llama/provider.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +43 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +4 -1
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/external-editor.d.ts.map +1 -1
- package/dist/modes/interactive/external-editor.js +40 -3
- package/dist/modes/interactive/external-editor.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +20 -6
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +13 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/utils/paths.d.ts +2 -3
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +2 -3
- package/dist/utils/paths.js.map +1 -1
- package/docs/bundled/README.md +13 -16
- package/docs/bundled/themes.md +1 -1
- package/docs/compaction.md +25 -30
- package/docs/custom-provider.md +298 -583
- package/docs/docs.json +0 -8
- package/docs/extensions.md +112 -116
- package/docs/index.md +18 -74
- package/docs/json.md +20 -14
- package/docs/models.md +193 -151
- package/docs/providers.md +2 -1
- package/docs/quickstart.md +3 -3
- package/docs/rpc.md +165 -123
- package/docs/sdk.md +119 -178
- package/docs/security.md +1 -1
- package/docs/session-format.md +139 -119
- package/docs/skills.md +0 -1
- package/docs/themes.md +2 -2
- package/docs/tui.md +411 -549
- package/docs/usage.md +3 -11
- package/examples/README.md +6 -1
- package/examples/extensions/README.md +13 -33
- package/examples/extensions/custom-provider-anthropic/index.ts +9 -3
- package/examples/extensions/dynamic-resources/dynamic.json +1 -1
- package/examples/extensions/truncated-tool.ts +2 -2
- package/examples/sdk/12-full-control.ts +2 -0
- package/npm-shrinkwrap.json +21 -21
- package/package.json +5 -5
- package/dist/extensions/plan/constants.d.ts +0 -68
- package/dist/extensions/plan/constants.d.ts.map +0 -1
- package/dist/extensions/plan/constants.js +0 -100
- package/dist/extensions/plan/constants.js.map +0 -1
- package/dist/extensions/plan/index.d.ts +0 -30
- package/dist/extensions/plan/index.d.ts.map +0 -1
- package/dist/extensions/plan/index.js +0 -429
- package/dist/extensions/plan/index.js.map +0 -1
- package/dist/extensions/plan/schema.d.ts +0 -20
- package/dist/extensions/plan/schema.d.ts.map +0 -1
- package/dist/extensions/plan/schema.js +0 -13
- package/dist/extensions/plan/schema.js.map +0 -1
- package/dist/extensions/plan/state.d.ts +0 -38
- package/dist/extensions/plan/state.d.ts.map +0 -1
- package/dist/extensions/plan/state.js +0 -96
- package/dist/extensions/plan/state.js.map +0 -1
- package/dist/extensions/plan/storage.d.ts +0 -26
- package/dist/extensions/plan/storage.d.ts.map +0 -1
- package/dist/extensions/plan/storage.js +0 -83
- package/dist/extensions/plan/storage.js.map +0 -1
- package/dist/extensions/plan/view.d.ts +0 -44
- package/dist/extensions/plan/view.d.ts.map +0 -1
- package/dist/extensions/plan/view.js +0 -149
- package/dist/extensions/plan/view.js.map +0 -1
- package/docs/bundled/extensions/README.md +0 -19
- package/docs/bundled/extensions/plan.md +0 -34
- package/examples/extensions/plan-mode/README.md +0 -66
- package/examples/extensions/plan-mode/index.ts +0 -390
- package/examples/extensions/plan-mode/utils.ts +0 -168
package/docs/custom-provider.md
CHANGED
|
@@ -1,433 +1,328 @@
|
|
|
1
1
|
# Custom Providers
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
- **Proxies** - Route requests through corporate proxies or API gateways
|
|
6
|
-
- **Custom endpoints** - Use self-hosted or private model deployments
|
|
7
|
-
- **OAuth/SSO** - Add authentication flows for enterprise providers
|
|
8
|
-
- **Custom APIs** - Implement streaming for non-standard LLM APIs
|
|
3
|
+
Use an extension when a provider needs runtime behavior: an OAuth flow, dynamic catalog, proxy-specific headers, payload rewriting, or a streaming protocol that Pi does not already support. For built-in provider setup, see [Providers](providers.md); for a static OpenAI-compatible endpoint, `models.json` is usually simpler; see [Custom Models](models.md).
|
|
9
4
|
|
|
10
5
|
## Example Extensions
|
|
11
6
|
|
|
12
|
-
|
|
7
|
+
These published extensions are the maintained end-to-end references:
|
|
13
8
|
|
|
14
|
-
- [`
|
|
15
|
-
- [`
|
|
9
|
+
- [`custom-provider-anthropic`](../examples/extensions/custom-provider-anthropic/) implements OAuth and a custom event stream.
|
|
10
|
+
- [`custom-provider-gitlab-duo`](../examples/extensions/custom-provider-gitlab-duo/) obtains provider-specific credentials, then delegates to Pi AI's public Anthropic and OpenAI stream APIs.
|
|
16
11
|
|
|
17
12
|
## Table of Contents
|
|
18
13
|
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
14
|
+
- [Provider Registration](#provider-registration)
|
|
15
|
+
- [Configuration Form](#configuration-form)
|
|
16
|
+
- [Complete pi-ai Provider](#complete-pi-ai-provider)
|
|
17
|
+
- [Composition, Updates, and Removal](#composition-updates-and-removal)
|
|
18
|
+
- [Dynamic Model Catalogs](#dynamic-model-catalogs)
|
|
19
|
+
- [API and Model Configuration](#api-and-model-configuration)
|
|
20
|
+
- [Authentication, Headers, and Request Hooks](#authentication-headers-and-request-hooks)
|
|
21
|
+
- [API Keys and Registered Headers](#api-keys-and-registered-headers)
|
|
22
|
+
- [OAuth](#oauth)
|
|
23
|
+
- [Payload and Header Hooks](#payload-and-header-hooks)
|
|
24
|
+
- [Custom Streaming APIs](#custom-streaming-apis)
|
|
25
|
+
- [Stream Contract](#stream-contract)
|
|
26
|
+
- [Forwarding Hooks from a Custom Stream](#forwarding-hooks-from-a-custom-stream)
|
|
27
|
+
- [Context Overflow Recovery](#context-overflow-recovery)
|
|
28
|
+
- [Testing](#testing)
|
|
29
|
+
|
|
30
|
+
## Provider Registration
|
|
31
|
+
|
|
32
|
+
`pi.registerProvider()` has two forms:
|
|
33
|
+
|
|
34
|
+
1. `pi.registerProvider(name, config)` configures a provider, models, optional OAuth, and an optional custom `streamSimple` handler.
|
|
35
|
+
2. `pi.registerProvider(provider)` registers a complete public pi-ai `Provider` when the extension owns native auth, catalog, filtering, or both stream methods.
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
An extension factory may be `async`; Pi waits for it before startup continues. Register during the factory when models must be present in `/model` and `pi --list-models`. See [Writing an Extension](extensions.md#writing-an-extension).
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
### Configuration Form
|
|
40
|
+
|
|
41
|
+
Use the configuration form for a standard supported API or to override a built-in provider:
|
|
34
42
|
|
|
35
43
|
```typescript
|
|
36
|
-
import { createProvider, openAICompletionsApi } from "@earendil-works/pi-ai";
|
|
37
44
|
import type { ExtensionAPI } from "@astralyn/pi";
|
|
38
45
|
|
|
39
46
|
export default function (pi: ExtensionAPI) {
|
|
40
|
-
|
|
41
|
-
id: "native-local",
|
|
42
|
-
name: "Native Local",
|
|
43
|
-
baseUrl: "http://localhost:8080/v1",
|
|
44
|
-
auth: {
|
|
45
|
-
apiKey: {
|
|
46
|
-
name: "Local server API key",
|
|
47
|
-
async login(interaction) {
|
|
48
|
-
return {
|
|
49
|
-
type: "api_key",
|
|
50
|
-
key: await interaction.prompt({ type: "secret", message: "API key" })
|
|
51
|
-
};
|
|
52
|
-
},
|
|
53
|
-
async resolve({ credential }) {
|
|
54
|
-
return credential?.key
|
|
55
|
-
? { auth: { apiKey: credential.key }, source: "stored API key" }
|
|
56
|
-
: undefined;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
models: [],
|
|
61
|
-
api: openAICompletionsApi()
|
|
62
|
-
}));
|
|
63
|
-
|
|
64
|
-
// Legacy provider-config form:
|
|
65
|
-
// Override baseUrl for existing provider
|
|
47
|
+
// Keeps Anthropic's existing models and auth; only the endpoint changes.
|
|
66
48
|
pi.registerProvider("anthropic", {
|
|
67
|
-
baseUrl: "https://proxy.example.com"
|
|
49
|
+
baseUrl: "https://proxy.example.com/anthropic",
|
|
50
|
+
headers: { "X-Proxy-Tenant": "$PROXY_TENANT" },
|
|
68
51
|
});
|
|
69
52
|
|
|
70
|
-
//
|
|
71
|
-
pi.registerProvider("
|
|
72
|
-
name: "
|
|
73
|
-
baseUrl: "
|
|
74
|
-
apiKey: "
|
|
53
|
+
// A new OpenAI-compatible provider.
|
|
54
|
+
pi.registerProvider("local-openai", {
|
|
55
|
+
name: "Local OpenAI",
|
|
56
|
+
baseUrl: "http://127.0.0.1:8080/v1",
|
|
57
|
+
apiKey: "local", // A literal placeholder for a server that ignores keys.
|
|
75
58
|
api: "openai-completions",
|
|
76
59
|
models: [
|
|
77
60
|
{
|
|
78
|
-
id: "
|
|
79
|
-
name: "
|
|
61
|
+
id: "local-model",
|
|
62
|
+
name: "Local Model",
|
|
80
63
|
reasoning: false,
|
|
81
|
-
input: ["text"
|
|
64
|
+
input: ["text"],
|
|
82
65
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
83
66
|
contextWindow: 128000,
|
|
84
|
-
maxTokens: 4096
|
|
85
|
-
}
|
|
86
|
-
]
|
|
67
|
+
maxTokens: 4096,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
87
70
|
});
|
|
88
71
|
}
|
|
89
72
|
```
|
|
90
73
|
|
|
91
|
-
|
|
74
|
+
When `models` is omitted, existing models are retained. When it is supplied, it replaces the models supplied by the built-in/native provider and earlier extension registration for that provider. A model can set its own `api` or `baseUrl` to override the provider default.
|
|
75
|
+
|
|
76
|
+
A `streamSimple` registration must also set the provider-level `api`. Pi invokes that handler only for models whose effective `api` matches the registered API tag. A custom tag such as `"my-company-api"` is valid; do **not** call the compatibility API registry's `registerApiProvider()` merely to use it with `pi.registerProvider()`.
|
|
92
77
|
|
|
93
|
-
|
|
78
|
+
### Complete pi-ai Provider
|
|
94
79
|
|
|
95
|
-
The
|
|
80
|
+
For a provider with native behavior, construct a public pi-ai `Provider` and register it directly. `createProvider`, `envApiKeyAuth`, and the `Provider`/`Model` types are public root exports. The lazy built-in API factories, including `openAICompletionsApi`, are public from `@earendil-works/pi-ai/compat`.
|
|
96
81
|
|
|
97
82
|
```typescript
|
|
98
|
-
|
|
99
|
-
pi
|
|
100
|
-
|
|
101
|
-
});
|
|
83
|
+
import type { ExtensionAPI } from "@astralyn/pi";
|
|
84
|
+
import { createProvider, envApiKeyAuth, type Model } from "@earendil-works/pi-ai";
|
|
85
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
|
|
102
86
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
87
|
+
const model: Model<"openai-completions"> = {
|
|
88
|
+
id: "local-model",
|
|
89
|
+
name: "Local Model",
|
|
90
|
+
api: "openai-completions",
|
|
91
|
+
provider: "native-local",
|
|
92
|
+
baseUrl: "http://127.0.0.1:8080/v1",
|
|
93
|
+
reasoning: false,
|
|
94
|
+
input: ["text"],
|
|
95
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
96
|
+
contextWindow: 128000,
|
|
97
|
+
maxTokens: 4096,
|
|
98
|
+
};
|
|
109
99
|
|
|
110
|
-
|
|
111
|
-
pi.registerProvider(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
100
|
+
export default function (pi: ExtensionAPI) {
|
|
101
|
+
pi.registerProvider(
|
|
102
|
+
createProvider({
|
|
103
|
+
id: "native-local",
|
|
104
|
+
name: "Native Local",
|
|
105
|
+
baseUrl: model.baseUrl,
|
|
106
|
+
auth: {
|
|
107
|
+
apiKey: envApiKeyAuth("Local server API key", ["LOCAL_OPENAI_API_KEY"]),
|
|
108
|
+
},
|
|
109
|
+
models: [model],
|
|
110
|
+
api: openAICompletionsApi(),
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
117
114
|
```
|
|
118
115
|
|
|
119
|
-
|
|
116
|
+
A complete `Provider` must have auth, `getModels()`, `stream()`, and `streamSimple()` behavior. `createProvider()` supplies the model/catalog plumbing and both stream methods from the public `ProviderStreams` value passed as `api`; use a hand-written `Provider` only when that helper does not fit. For hand-written API-key auth, `login(interaction)` returns an `ApiKeyCredential` and `resolve({ ctx, credential })` returns an `AuthResult` or `undefined`; `envApiKeyAuth()` implements that standard pattern.
|
|
120
117
|
|
|
121
|
-
|
|
118
|
+
### Composition, Updates, and Removal
|
|
122
119
|
|
|
123
|
-
|
|
120
|
+
A complete registered `Provider` is the base provider for its ID. Pi applies `models.json` configuration above that base, including final `modelOverrides`; see [Per-model Overrides](models.md#per-model-overrides). A named configuration registration is instead applied over the native/built-in provider and `models.json` layers.
|
|
124
121
|
|
|
125
|
-
|
|
122
|
+
A later named registration for the same ID merges its defined configuration values with the prior named registration. Registering a complete `Provider` for that ID replaces the named registration; registering by name replaces a complete extension provider for that ID.
|
|
126
123
|
|
|
127
|
-
|
|
128
|
-
import type { ExtensionAPI } from "@astralyn/pi";
|
|
129
|
-
|
|
130
|
-
export default async function (pi: ExtensionAPI) {
|
|
131
|
-
const response = await fetch("http://localhost:1234/v1/models");
|
|
132
|
-
const payload = (await response.json()) as {
|
|
133
|
-
data: Array<{
|
|
134
|
-
id: string;
|
|
135
|
-
name?: string;
|
|
136
|
-
context_window?: number;
|
|
137
|
-
max_tokens?: number;
|
|
138
|
-
}>;
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
pi.registerProvider("local-openai", {
|
|
142
|
-
baseUrl: "http://localhost:1234/v1",
|
|
143
|
-
apiKey: "$LOCAL_OPENAI_API_KEY",
|
|
144
|
-
api: "openai-completions",
|
|
145
|
-
models: payload.data.map((model) => ({
|
|
146
|
-
id: model.id,
|
|
147
|
-
name: model.name ?? model.id,
|
|
148
|
-
reasoning: false,
|
|
149
|
-
input: ["text"],
|
|
150
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
151
|
-
contextWindow: model.context_window ?? 128000,
|
|
152
|
-
maxTokens: model.max_tokens ?? 4096,
|
|
153
|
-
})),
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
This registers the fetched models before startup finishes.
|
|
124
|
+
Provider calls made from the factory are queued until the extension runner is ready. Calls made later, such as from a command, take effect immediately; no `/reload` is needed.
|
|
159
125
|
|
|
160
126
|
```typescript
|
|
161
|
-
pi.
|
|
162
|
-
baseUrl: "https://api.my-llm.com/v1",
|
|
163
|
-
apiKey: "$MY_LLM_API_KEY", // env var reference
|
|
164
|
-
api: "openai-completions", // which streaming API to use
|
|
165
|
-
models: [
|
|
166
|
-
{
|
|
167
|
-
id: "my-llm-large",
|
|
168
|
-
name: "My LLM Large",
|
|
169
|
-
reasoning: true, // supports extended thinking
|
|
170
|
-
input: ["text", "image"],
|
|
171
|
-
cost: {
|
|
172
|
-
input: 3.0, // $/million tokens
|
|
173
|
-
output: 15.0,
|
|
174
|
-
cacheRead: 0.3,
|
|
175
|
-
cacheWrite: 3.75
|
|
176
|
-
},
|
|
177
|
-
contextWindow: 200000,
|
|
178
|
-
maxTokens: 16384
|
|
179
|
-
}
|
|
180
|
-
]
|
|
181
|
-
});
|
|
127
|
+
pi.unregisterProvider("local-openai");
|
|
182
128
|
```
|
|
183
129
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
`apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`.
|
|
130
|
+
`unregisterProvider()` removes either form registered by the extension and recomposes the remaining layers. A built-in provider is restored when one exists; `models.json` configuration remains, while a provider defined only by the extension disappears.
|
|
187
131
|
|
|
188
|
-
|
|
132
|
+
### Dynamic Model Catalogs
|
|
189
133
|
|
|
190
|
-
Use `
|
|
134
|
+
The configuration form can refresh an extension-owned model list. Pi calls `refreshModels` during model refresh. Its returned list replaces the extension-provided models. Use `context.signal` for requests and `context.store` only when the catalog should persist across sessions.
|
|
191
135
|
|
|
192
136
|
```typescript
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
apiKey: "$MY_LLM_API_KEY",
|
|
137
|
+
pi.registerProvider("local-openai", {
|
|
138
|
+
baseUrl: "http://127.0.0.1:8080/v1",
|
|
139
|
+
apiKey: "local",
|
|
197
140
|
api: "openai-completions",
|
|
198
|
-
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
141
|
+
async refreshModels({ signal }) {
|
|
142
|
+
const response = await fetch("http://127.0.0.1:8080/v1/models", { signal });
|
|
143
|
+
if (!response.ok) throw new Error(`Model discovery failed: ${response.status}`);
|
|
144
|
+
|
|
145
|
+
const payload = (await response.json()) as { data: Array<{ id: string }> };
|
|
146
|
+
return payload.data.map((entry) => ({
|
|
147
|
+
id: entry.id,
|
|
148
|
+
name: entry.id,
|
|
149
|
+
reasoning: false,
|
|
150
|
+
input: ["text"],
|
|
151
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
152
|
+
contextWindow: 128000,
|
|
153
|
+
maxTokens: 4096,
|
|
154
|
+
}));
|
|
155
|
+
},
|
|
209
156
|
});
|
|
210
|
-
|
|
211
|
-
// Later, remove it
|
|
212
|
-
pi.unregisterProvider("my-llm");
|
|
213
157
|
```
|
|
214
158
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
Calls made after the initial extension load phase are applied immediately, so no `/reload` is required.
|
|
159
|
+
For one-time discovery that must finish before model selection, make the extension factory `async`, fetch the catalog there, and then call `registerProvider()` with `models`.
|
|
218
160
|
|
|
219
|
-
|
|
161
|
+
## API and Model Configuration
|
|
220
162
|
|
|
221
|
-
|
|
163
|
+
Set `api` on the provider as the default, or on a model when one provider has models served by different APIs. The built-in API tags are:
|
|
222
164
|
|
|
223
|
-
| API | Use
|
|
224
|
-
|
|
225
|
-
| `anthropic-messages` | Anthropic
|
|
226
|
-
| `openai-completions` | OpenAI Chat Completions
|
|
227
|
-
| `openai-responses` | OpenAI Responses
|
|
228
|
-
| `azure-openai-responses` | Azure OpenAI Responses
|
|
229
|
-
| `openai-codex-responses` | OpenAI Codex Responses
|
|
230
|
-
| `mistral-conversations` | Mistral
|
|
231
|
-
| `google-generative-ai` | Google Generative AI
|
|
232
|
-
| `google-vertex` | Google Vertex AI
|
|
233
|
-
| `bedrock-converse-stream` | Amazon Bedrock Converse
|
|
165
|
+
| API | Use |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `anthropic-messages` | Anthropic Messages-compatible APIs |
|
|
168
|
+
| `openai-completions` | OpenAI Chat Completions-compatible APIs |
|
|
169
|
+
| `openai-responses` | OpenAI Responses APIs |
|
|
170
|
+
| `azure-openai-responses` | Azure OpenAI Responses APIs |
|
|
171
|
+
| `openai-codex-responses` | OpenAI Codex Responses APIs |
|
|
172
|
+
| `mistral-conversations` | Mistral Conversations APIs |
|
|
173
|
+
| `google-generative-ai` | Google Generative AI APIs |
|
|
174
|
+
| `google-vertex` | Google Vertex AI APIs |
|
|
175
|
+
| `bedrock-converse-stream` | Amazon Bedrock Converse Stream APIs |
|
|
176
|
+
| `pi-messages` | Pi Messages APIs |
|
|
234
177
|
|
|
235
|
-
|
|
178
|
+
A configuration-form model requires `id`, `name`, `reasoning`, `input`, `cost`, `contextWindow`, and `maxTokens`. It may also set `api`, `baseUrl`, `headers`, `thinkingLevelMap`, and `compat`. `cost` rates are per million tokens. `thinkingLevelMap` maps Pi's `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` levels; `null` hides an unsupported level.
|
|
236
179
|
|
|
237
|
-
|
|
238
|
-
models: [{
|
|
239
|
-
id: "custom-model",
|
|
240
|
-
// ...
|
|
241
|
-
reasoning: true,
|
|
242
|
-
thinkingLevelMap: { // map pi levels to provider values; null hides unsupported levels
|
|
243
|
-
minimal: null,
|
|
244
|
-
low: null,
|
|
245
|
-
medium: null,
|
|
246
|
-
high: "default",
|
|
247
|
-
xhigh: null,
|
|
248
|
-
max: "max"
|
|
249
|
-
},
|
|
250
|
-
compat: {
|
|
251
|
-
supportsDeveloperRole: false, // use "system" instead of "developer"
|
|
252
|
-
supportsReasoningEffort: true,
|
|
253
|
-
maxTokensField: "max_tokens", // instead of "max_completion_tokens"
|
|
254
|
-
requiresToolResultName: true, // tool results need name field
|
|
255
|
-
thinkingFormat: "qwen", // top-level enable_thinking: true
|
|
256
|
-
cacheControlFormat: "anthropic" // Anthropic-style cache_control markers
|
|
257
|
-
}
|
|
258
|
-
}]
|
|
259
|
-
```
|
|
180
|
+
Keep endpoint-specific compatibility settings in `compat` rather than reimplementing a standard protocol. The full model fields, thinking maps, cost tiers, and Anthropic/OpenAI compatibility options are documented in [Custom Models](models.md#model-configuration), [Anthropic Messages Compatibility](models.md#anthropic-messages-compatibility), and [OpenAI Compatibility](models.md#openai-compatibility).
|
|
260
181
|
|
|
261
|
-
|
|
262
|
-
Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user, assistant, or tool-result text content.
|
|
182
|
+
## Authentication, Headers, and Request Hooks
|
|
263
183
|
|
|
264
|
-
|
|
184
|
+
### API Keys and Registered Headers
|
|
265
185
|
|
|
266
|
-
|
|
267
|
-
> Use `mistral-conversations` for native Mistral models.
|
|
268
|
-
> If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed.
|
|
186
|
+
For a new config-form provider, supply `apiKey` unless it has OAuth or a complete native provider supplies auth. `apiKey`, provider `headers`, and model `headers` use the same value syntax as `models.json`:
|
|
269
187
|
|
|
270
|
-
|
|
188
|
+
- `$NAME` and `${NAME}` interpolate environment variables.
|
|
189
|
+
- A value beginning with `!` runs a command for the whole value.
|
|
190
|
+
- `$$` and `$!` emit literal `$` and `!`.
|
|
191
|
+
- Other text, including uppercase text, is literal.
|
|
271
192
|
|
|
272
|
-
|
|
193
|
+
Values are resolved at request time. See [Value Resolution](models.md#value-resolution) for command behavior and security considerations.
|
|
273
194
|
|
|
274
195
|
```typescript
|
|
275
|
-
pi.registerProvider("
|
|
276
|
-
baseUrl: "https://api.example.com",
|
|
277
|
-
apiKey: "$
|
|
278
|
-
authHeader: true, // adds Authorization: Bearer header
|
|
196
|
+
pi.registerProvider("company-api", {
|
|
197
|
+
baseUrl: "https://api.example.com/v1",
|
|
198
|
+
apiKey: "$COMPANY_API_KEY",
|
|
279
199
|
api: "openai-completions",
|
|
280
|
-
|
|
200
|
+
headers: {
|
|
201
|
+
"X-Tenant": "$COMPANY_TENANT",
|
|
202
|
+
"X-Proxy-Token": "!company-token print",
|
|
203
|
+
},
|
|
204
|
+
authHeader: true, // Adds Authorization: Bearer <resolved API key>.
|
|
205
|
+
models: [/* ... */],
|
|
281
206
|
});
|
|
282
207
|
```
|
|
283
208
|
|
|
284
|
-
|
|
209
|
+
`authHeader` is useful for a custom API that expects a bearer token but does not create that header itself. Standard Pi AI API implementations already apply their own authentication. A custom stream receives the resolved key as `options.apiKey` and the final headers as `options.headers`; forward both as appropriate for its protocol.
|
|
285
210
|
|
|
286
|
-
|
|
211
|
+
### OAuth
|
|
287
212
|
|
|
288
|
-
|
|
213
|
+
The configuration form adapts the extension OAuth callbacks to Pi AI's provider auth and exposes the provider through `/login`. This callback shape is specific to `ProviderConfig.oauth`: a complete native `Provider` instead supplies `auth.oauth.login(interaction)`, `refresh(credential, signal?)`, and `toAuth(credential)`. Do not use the configuration form's `refreshToken` or `getApiKey` names on native `auth.oauth`.
|
|
289
214
|
|
|
290
215
|
```typescript
|
|
216
|
+
import type { ExtensionAPI } from "@astralyn/pi";
|
|
291
217
|
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
|
292
218
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
301
|
-
const method = await callbacks.onSelect({
|
|
302
|
-
message: "Select login method:",
|
|
303
|
-
options: [
|
|
304
|
-
{ id: "browser", label: "Browser OAuth" },
|
|
305
|
-
{ id: "device", label: "Device code" }
|
|
306
|
-
]
|
|
307
|
-
});
|
|
308
|
-
if (!method) throw new Error("Login cancelled");
|
|
309
|
-
|
|
310
|
-
let code: string;
|
|
311
|
-
if (method === "device") {
|
|
312
|
-
callbacks.onDeviceCode({
|
|
313
|
-
userCode: "ABCD-1234",
|
|
314
|
-
verificationUri: "https://sso.corp.com/device",
|
|
315
|
-
intervalSeconds: 5,
|
|
316
|
-
expiresInSeconds: 900
|
|
317
|
-
});
|
|
318
|
-
code = await pollDeviceCodeUntilComplete();
|
|
319
|
-
} else {
|
|
320
|
-
callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
|
|
321
|
-
code = await callbacks.onPrompt({ message: "Enter SSO code:" });
|
|
322
|
-
}
|
|
219
|
+
async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
220
|
+
callbacks.onAuth({
|
|
221
|
+
url: "https://sso.example.com/authorize?...",
|
|
222
|
+
instructions: "Sign in, then paste the authorization code.",
|
|
223
|
+
});
|
|
224
|
+
const code = await callbacks.onPrompt({ message: "Authorization code" });
|
|
323
225
|
|
|
324
|
-
|
|
325
|
-
|
|
226
|
+
// Exchange the code with this provider's OAuth server. Pass callbacks.signal
|
|
227
|
+
// to provider requests when supported.
|
|
228
|
+
return exchangeAuthorizationCode(code, callbacks.signal);
|
|
229
|
+
}
|
|
326
230
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
expires: Date.now() + tokens.expiresIn * 1000
|
|
331
|
-
};
|
|
332
|
-
},
|
|
231
|
+
async function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
232
|
+
return refreshAuthorization(credentials.refresh);
|
|
233
|
+
}
|
|
333
234
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
235
|
+
export default function (pi: ExtensionAPI) {
|
|
236
|
+
pi.registerProvider("company-api", {
|
|
237
|
+
baseUrl: "https://api.example.com/v1",
|
|
238
|
+
api: "openai-responses",
|
|
239
|
+
models: [/* ... */],
|
|
240
|
+
oauth: {
|
|
241
|
+
name: "Company AI",
|
|
242
|
+
login,
|
|
243
|
+
refreshToken,
|
|
244
|
+
getApiKey: (credentials) => credentials.access,
|
|
341
245
|
},
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return credentials.access;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
348
248
|
```
|
|
349
249
|
|
|
350
|
-
|
|
250
|
+
Return credentials with `refresh`, `access`, and `expires` (a millisecond timestamp). Pi adds the stored OAuth credential's internal type tag; extension callbacks return `OAuthCredentials`, not a credential with `type: "oauth"`.
|
|
351
251
|
|
|
352
|
-
|
|
252
|
+
`OAuthLoginCallbacks` provides these UI-neutral operations:
|
|
353
253
|
|
|
354
|
-
|
|
254
|
+
- `onAuth({ url, instructions? })` opens/displays an authorization URL.
|
|
255
|
+
- `onDeviceCode({ userCode, verificationUri, intervalSeconds?, expiresInSeconds? })` displays a device flow code.
|
|
256
|
+
- `onPrompt({ message, placeholder?, allowEmpty? })` asks for text and resolves to the entered string.
|
|
257
|
+
- `onSelect({ message, options: [{ id, label }] })` resolves to an option ID or `undefined`.
|
|
258
|
+
- `onManualCodeInput?.()` optionally requests a manually pasted code.
|
|
259
|
+
- `onProgress?.(message)` reports transient progress, and `signal` is an optional `AbortSignal` for the login flow.
|
|
355
260
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// Show device code (for device authorization flow)
|
|
362
|
-
onDeviceCode(params: {
|
|
363
|
-
userCode: string;
|
|
364
|
-
verificationUri: string;
|
|
365
|
-
intervalSeconds?: number;
|
|
366
|
-
expiresInSeconds?: number;
|
|
367
|
-
}): void;
|
|
368
|
-
|
|
369
|
-
// Show transient progress
|
|
370
|
-
onProgress?(message: string): void;
|
|
371
|
-
|
|
372
|
-
// Prompt user for input (for manual token entry)
|
|
373
|
-
onPrompt(params: { message: string }): Promise<string>;
|
|
374
|
-
|
|
375
|
-
// Show an interactive selector, e.g. to choose browser OAuth vs device code
|
|
376
|
-
onSelect(params: {
|
|
377
|
-
message: string;
|
|
378
|
-
options: { id: string; label: string }[];
|
|
379
|
-
}): Promise<string | undefined>;
|
|
380
|
-
}
|
|
381
|
-
```
|
|
261
|
+
Do not use `usesCallbackServer`; it is deprecated and ignored by the canonical OAuth flow. Keep OAuth tokens out of logs and error messages.
|
|
262
|
+
|
|
263
|
+
### Payload and Header Hooks
|
|
264
|
+
|
|
265
|
+
For a standard Pi AI API stream, Pi's extension hooks work automatically:
|
|
382
266
|
|
|
383
|
-
|
|
267
|
+
- `before_provider_headers` mutates the fully assembled headers. A `null` header value removes that header.
|
|
268
|
+
- `before_provider_request` inspects or replaces the serialized payload.
|
|
269
|
+
- `after_provider_response` sees the status and normalized headers before the response body is consumed.
|
|
384
270
|
|
|
385
|
-
|
|
271
|
+
See [provider request events](extensions.md#before_provider_headers) for ordering and examples. `onPayload`, `onResponse`, and `transformHeaders` are **not** `ProviderConfig` fields. Pi passes the first two to a stream as `SimpleStreamOptions`; header transformation has already happened before a custom `streamSimple` is called.
|
|
272
|
+
|
|
273
|
+
A custom stream that sends HTTP itself must call `options.onPayload` and `options.onResponse` to preserve the payload/response hooks. It must also use the supplied headers. For example, immediately before consuming a custom HTTP response:
|
|
386
274
|
|
|
387
275
|
```typescript
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
276
|
+
const payload = { model: model.id, stream: true };
|
|
277
|
+
const replacement = await options?.onPayload?.(payload, model);
|
|
278
|
+
const requestPayload = replacement === undefined ? payload : replacement;
|
|
279
|
+
|
|
280
|
+
const headers = Object.fromEntries(
|
|
281
|
+
Object.entries(options?.headers ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
|
|
282
|
+
);
|
|
283
|
+
const response = await fetch(`${model.baseUrl}/stream`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "content-type": "application/json", ...headers },
|
|
286
|
+
body: JSON.stringify(requestPayload),
|
|
287
|
+
signal: options?.signal,
|
|
288
|
+
});
|
|
289
|
+
await options?.onResponse?.(
|
|
290
|
+
{ status: response.status, headers: Object.fromEntries(response.headers.entries()) },
|
|
291
|
+
model,
|
|
292
|
+
);
|
|
293
|
+
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
|
|
393
294
|
```
|
|
394
295
|
|
|
395
|
-
|
|
296
|
+
When a custom stream delegates to a public Pi AI API stream, pass through `options` (and merge rather than discard `options.headers`) so those hooks continue to run. The GitLab Duo example follows this pattern.
|
|
396
297
|
|
|
397
|
-
|
|
298
|
+
## Custom Streaming APIs
|
|
398
299
|
|
|
399
|
-
|
|
400
|
-
- [anthropic.ts](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/providers/anthropic.ts) - Anthropic Messages API
|
|
401
|
-
- [mistral.ts](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/providers/mistral.ts) - Mistral Conversations API
|
|
402
|
-
- [openai-completions.ts](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/providers/openai-completions.ts) - OpenAI Chat Completions
|
|
403
|
-
- [openai-responses.ts](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/providers/openai-responses.ts) - OpenAI Responses API
|
|
404
|
-
- [google.ts](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/providers/google.ts) - Google Generative AI
|
|
405
|
-
- [amazon-bedrock.ts](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/providers/amazon-bedrock.ts) - AWS Bedrock
|
|
300
|
+
Implement `streamSimple` only for a protocol that cannot use a built-in API tag or a public Pi AI API stream. The custom Anthropic example implements the event protocol directly; the GitLab Duo example shows the preferred delegation approach when an endpoint is still Anthropic- or OpenAI-compatible.
|
|
406
301
|
|
|
407
|
-
### Stream
|
|
302
|
+
### Stream Contract
|
|
408
303
|
|
|
409
|
-
|
|
304
|
+
The configuration-form handler signature is exactly:
|
|
410
305
|
|
|
411
306
|
```typescript
|
|
412
307
|
import {
|
|
308
|
+
type Api,
|
|
413
309
|
type AssistantMessage,
|
|
414
310
|
type AssistantMessageEventStream,
|
|
311
|
+
calculateCost,
|
|
415
312
|
type Context,
|
|
313
|
+
createAssistantMessageEventStream,
|
|
416
314
|
type Model,
|
|
417
315
|
type SimpleStreamOptions,
|
|
418
|
-
calculateCost,
|
|
419
|
-
createAssistantMessageEventStream,
|
|
420
316
|
} from "@earendil-works/pi-ai";
|
|
421
317
|
|
|
422
318
|
function streamMyProvider(
|
|
423
|
-
model: Model<
|
|
319
|
+
model: Model<Api>,
|
|
424
320
|
context: Context,
|
|
425
|
-
options?: SimpleStreamOptions
|
|
321
|
+
options?: SimpleStreamOptions,
|
|
426
322
|
): AssistantMessageEventStream {
|
|
427
323
|
const stream = createAssistantMessageEventStream();
|
|
428
324
|
|
|
429
|
-
(async () => {
|
|
430
|
-
// Initialize output message
|
|
325
|
+
void (async () => {
|
|
431
326
|
const output: AssistantMessage = {
|
|
432
327
|
role: "assistant",
|
|
433
328
|
content: [],
|
|
@@ -442,28 +337,51 @@ function streamMyProvider(
|
|
|
442
337
|
totalTokens: 0,
|
|
443
338
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
444
339
|
},
|
|
445
|
-
stopReason: "
|
|
340
|
+
stopReason: "pending",
|
|
446
341
|
timestamp: Date.now(),
|
|
447
342
|
};
|
|
448
343
|
|
|
449
344
|
try {
|
|
450
|
-
// Push start event
|
|
451
345
|
stream.push({ type: "start", partial: output });
|
|
452
346
|
|
|
453
|
-
//
|
|
454
|
-
|
|
347
|
+
// Send the request, consume the provider stream, and update output.
|
|
348
|
+
const contentIndex = output.content.length;
|
|
349
|
+
output.content.push({ type: "text", text: "" });
|
|
350
|
+
stream.push({ type: "text_start", contentIndex, partial: output });
|
|
351
|
+
|
|
352
|
+
// For each provider text delta:
|
|
353
|
+
const delta = "...";
|
|
354
|
+
const block = output.content[contentIndex];
|
|
355
|
+
if (block.type === "text") {
|
|
356
|
+
block.text += delta;
|
|
357
|
+
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
|
|
358
|
+
}
|
|
455
359
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
type: "
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
360
|
+
// After the provider completes this block:
|
|
361
|
+
if (block.type === "text") {
|
|
362
|
+
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Update usage whenever the provider reports it, then recalculate cost.
|
|
366
|
+
output.usage.totalTokens =
|
|
367
|
+
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
368
|
+
calculateCost(model, output.usage);
|
|
369
|
+
|
|
370
|
+
// The omitted provider loop must map its terminal event by replacing
|
|
371
|
+
// "pending" with "stop", "length", "toolUse", "error", or "aborted".
|
|
372
|
+
if (output.stopReason === "pending") {
|
|
373
|
+
throw new Error("Provider stream ended without a stop reason");
|
|
374
|
+
}
|
|
375
|
+
if (output.stopReason === "error" || output.stopReason === "aborted") {
|
|
376
|
+
throw new Error(output.errorMessage || "An unknown error occurred");
|
|
377
|
+
}
|
|
378
|
+
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
463
379
|
} catch (error) {
|
|
464
|
-
|
|
380
|
+
const reason = options?.signal?.aborted ? "aborted" : "error";
|
|
381
|
+
output.stopReason = reason;
|
|
465
382
|
output.errorMessage = error instanceof Error ? error.message : String(error);
|
|
466
|
-
stream.push({ type: "error", reason
|
|
383
|
+
stream.push({ type: "error", reason, error: output });
|
|
384
|
+
} finally {
|
|
467
385
|
stream.end();
|
|
468
386
|
}
|
|
469
387
|
})();
|
|
@@ -472,122 +390,68 @@ function streamMyProvider(
|
|
|
472
390
|
}
|
|
473
391
|
```
|
|
474
392
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
Push events via `stream.push()` in this order:
|
|
478
|
-
|
|
479
|
-
1. `{ type: "start", partial: output }` - Stream started
|
|
393
|
+
Emit `start` before partial content events, update `output.content` before each event's `partial`, and end exactly once after either terminal event. The terminal event shapes are deliberately narrow: `done.reason` is `"stop" | "length" | "toolUse"`; `error.reason` is `"error" | "aborted"`. Map the upstream stop reason to one of those values and keep it equal to `output.stopReason`.
|
|
480
394
|
|
|
481
|
-
|
|
482
|
-
- `{ type: "text_start", contentIndex, partial }` - Text block started
|
|
483
|
-
- `{ type: "text_delta", contentIndex, delta, partial }` - Text chunk
|
|
484
|
-
- `{ type: "text_end", contentIndex, content, partial }` - Text block ended
|
|
485
|
-
- `{ type: "thinking_start", contentIndex, partial }` - Thinking started
|
|
486
|
-
- `{ type: "thinking_delta", contentIndex, delta, partial }` - Thinking chunk
|
|
487
|
-
- `{ type: "thinking_end", contentIndex, content, partial }` - Thinking ended
|
|
488
|
-
- `{ type: "toolcall_start", contentIndex, partial }` - Tool call started
|
|
489
|
-
- `{ type: "toolcall_delta", contentIndex, delta, partial }` - Tool call JSON chunk
|
|
490
|
-
- `{ type: "toolcall_end", contentIndex, toolCall, partial }` - Tool call ended
|
|
395
|
+
Use the index of each block in `output.content` as `contentIndex` and emit matching start, delta, and end events:
|
|
491
396
|
|
|
492
|
-
|
|
397
|
+
| Block | Events | Final content field |
|
|
398
|
+
| --- | --- | --- |
|
|
399
|
+
| Text | `text_start`, `text_delta`, `text_end` | `text` |
|
|
400
|
+
| Thinking | `thinking_start`, `thinking_delta`, `thinking_end` | `thinking` |
|
|
401
|
+
| Tool call | `toolcall_start`, `toolcall_delta`, `toolcall_end` | `ToolCall` |
|
|
493
402
|
|
|
494
|
-
|
|
403
|
+
Tool calls need an `id`, `name`, and parsed `arguments` object in the final `toolcall_end`. Accumulate streamed JSON before parsing it. For replayable thinking, preserve any provider signature in the block's `thinkingSignature` field. Populate `usage` with provider-reported values (`input`, `output`, `cacheRead`, `cacheWrite`, and optional `cacheWrite1h`/`reasoning`) before calling `calculateCost()`.
|
|
495
404
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
Add content blocks to `output.content` as they arrive:
|
|
405
|
+
Register the handler with a matching custom API tag:
|
|
499
406
|
|
|
500
407
|
```typescript
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
block.text += delta;
|
|
509
|
-
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// When block completes
|
|
513
|
-
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
|
|
408
|
+
pi.registerProvider("my-provider", {
|
|
409
|
+
baseUrl: "https://api.example.com",
|
|
410
|
+
apiKey: "$MY_PROVIDER_API_KEY",
|
|
411
|
+
api: "my-provider-stream",
|
|
412
|
+
models: [/* models whose effective api is my-provider-stream */],
|
|
413
|
+
streamSimple: streamMyProvider,
|
|
414
|
+
});
|
|
514
415
|
```
|
|
515
416
|
|
|
516
|
-
###
|
|
417
|
+
### Forwarding Hooks from a Custom Stream
|
|
517
418
|
|
|
518
|
-
|
|
419
|
+
`SimpleStreamOptions` also carries cancellation, the resolved API key, reasoning level, thinking budgets, cache/session settings, retry/timeout options, headers, and the payload/response callbacks. Honor the options your protocol supports, especially `signal`, `apiKey`, and `headers`. Do not silently replace the supplied options when delegating:
|
|
519
420
|
|
|
520
421
|
```typescript
|
|
521
|
-
|
|
522
|
-
output.content.push({
|
|
523
|
-
type: "toolCall",
|
|
524
|
-
id: toolCallId,
|
|
525
|
-
name: toolName,
|
|
526
|
-
arguments: {}
|
|
527
|
-
});
|
|
528
|
-
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
|
529
|
-
|
|
530
|
-
// Accumulate JSON
|
|
531
|
-
let partialJson = "";
|
|
532
|
-
partialJson += jsonDelta;
|
|
533
|
-
try {
|
|
534
|
-
block.arguments = JSON.parse(partialJson);
|
|
535
|
-
} catch {}
|
|
536
|
-
stream.push({ type: "toolcall_delta", contentIndex, delta: jsonDelta, partial: output });
|
|
537
|
-
|
|
538
|
-
// Complete
|
|
539
|
-
stream.push({
|
|
540
|
-
type: "toolcall_end",
|
|
541
|
-
contentIndex,
|
|
542
|
-
toolCall: { type: "toolCall", id, name, arguments: block.arguments },
|
|
543
|
-
partial: output
|
|
544
|
-
});
|
|
545
|
-
```
|
|
422
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/compat";
|
|
546
423
|
|
|
547
|
-
|
|
424
|
+
const responsesApi = openAIResponsesApi();
|
|
548
425
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
output.usage.cacheRead = response.usage.cache_read_tokens ?? 0;
|
|
555
|
-
output.usage.cacheWrite = response.usage.cache_write_tokens ?? 0;
|
|
556
|
-
output.usage.totalTokens = output.usage.input + output.usage.output +
|
|
557
|
-
output.usage.cacheRead + output.usage.cacheWrite;
|
|
558
|
-
calculateCost(model, output.usage);
|
|
426
|
+
// Inside a custom handler, after adapting the model/API as necessary:
|
|
427
|
+
return responsesApi.streamSimple(adaptedModel, context, {
|
|
428
|
+
...options,
|
|
429
|
+
headers: { ...options?.headers, "X-Relay": "company" },
|
|
430
|
+
});
|
|
559
431
|
```
|
|
560
432
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
When a request exceeds the model's context window, pi can recover automatically by compacting the conversation and retrying. This recovery only kicks in if pi recognizes the failure as an overflow.
|
|
433
|
+
This is a public API. It avoids depending on pi-ai implementation files and preserves Pi's request hooks.
|
|
564
434
|
|
|
565
|
-
|
|
435
|
+
## Context Overflow Recovery
|
|
566
436
|
|
|
567
|
-
- `stopReason
|
|
568
|
-
- `errorMessage` matches one of pi's known overflow patterns (see [`packages/ai/src/utils/overflow.ts`](https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/src/utils/overflow.ts))
|
|
437
|
+
Pi uses the public `isContextOverflow(message, contextWindow)` helper when deciding whether to auto-compact. It recognizes its built-in error patterns and also detects a few silent-overflow usage shapes. A custom stream should emit `stopReason: "error"` with the provider's real error in `errorMessage`.
|
|
569
438
|
|
|
570
|
-
If
|
|
439
|
+
If an upstream uses an unrecognized overflow message, normalize only that provider's known overflow error. The generic `context_length_exceeded` prefix is recognized. A `message_end` replacement occurs before Pi's overflow recovery check:
|
|
571
440
|
|
|
572
441
|
```typescript
|
|
573
|
-
|
|
442
|
+
import type { ExtensionAPI } from "@astralyn/pi";
|
|
574
443
|
|
|
575
|
-
|
|
576
|
-
pi.registerProvider("my-provider", { /* ... */ });
|
|
444
|
+
const PROVIDER_OVERFLOW = /request exceeds the acme input limit/i;
|
|
577
445
|
|
|
578
|
-
|
|
446
|
+
export default function (pi: ExtensionAPI) {
|
|
447
|
+
pi.on("message_end", (event) => {
|
|
579
448
|
const message = event.message;
|
|
580
|
-
if (message.role !== "assistant") return;
|
|
449
|
+
if (message.role !== "assistant" || message.provider !== "my-provider") return;
|
|
581
450
|
if (message.stopReason !== "error") return;
|
|
582
|
-
if (
|
|
583
|
-
message.provider !== "my-provider" &&
|
|
584
|
-
ctx.model?.provider !== "my-provider"
|
|
585
|
-
)
|
|
586
|
-
return;
|
|
587
451
|
|
|
588
452
|
const errorMessage = message.errorMessage ?? "";
|
|
589
453
|
if (errorMessage.includes("context_length_exceeded")) return;
|
|
590
|
-
if (!
|
|
454
|
+
if (!PROVIDER_OVERFLOW.test(errorMessage)) return;
|
|
591
455
|
|
|
592
456
|
return {
|
|
593
457
|
message: {
|
|
@@ -599,168 +463,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
599
463
|
}
|
|
600
464
|
```
|
|
601
465
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
1. Detect the overflow from `errorMessage`.
|
|
605
|
-
2. Drop the failed assistant message from live context.
|
|
606
|
-
3. Run compaction.
|
|
607
|
-
4. Retry the request once.
|
|
608
|
-
|
|
609
|
-
Guard the rewrite carefully:
|
|
610
|
-
|
|
611
|
-
- Scope it to your provider (`message.provider` and `ctx.model?.provider`) so unrelated errors from other providers are untouched.
|
|
612
|
-
- Match a provider-specific pattern, not pi's generic overflow patterns. Rewriting rate-limit or throttling errors (`rate limit`, `too many requests`) would falsely trigger compaction instead of pi's normal retry-with-backoff path.
|
|
613
|
-
- Skip when `errorMessage` already includes `context_length_exceeded` so the handler is idempotent.
|
|
614
|
-
|
|
615
|
-
### Registration
|
|
616
|
-
|
|
617
|
-
Register your stream function:
|
|
618
|
-
|
|
619
|
-
```typescript
|
|
620
|
-
pi.registerProvider("my-provider", {
|
|
621
|
-
baseUrl: "https://api.example.com",
|
|
622
|
-
apiKey: "$MY_API_KEY",
|
|
623
|
-
api: "my-custom-api",
|
|
624
|
-
models: [...],
|
|
625
|
-
streamSimple: streamMyProvider
|
|
626
|
-
});
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
## Testing Your Implementation
|
|
466
|
+
Do not rewrite rate limits, authentication failures, or generic server errors as overflows. With auto-compaction enabled, Pi removes the failed assistant message from live context, compacts, and retries once. Test the normalized message with the public `isContextOverflow()` helper and the model's configured `contextWindow`.
|
|
630
467
|
|
|
631
|
-
|
|
468
|
+
## Testing
|
|
632
469
|
|
|
633
|
-
|
|
634
|
-
|------|---------|
|
|
635
|
-
| `stream.test.ts` | Basic streaming, text output |
|
|
636
|
-
| `tokens.test.ts` | Token counting and usage |
|
|
637
|
-
| `abort.test.ts` | AbortSignal handling |
|
|
638
|
-
| `empty.test.ts` | Empty/minimal responses |
|
|
639
|
-
| `context-overflow.test.ts` | Context window limits |
|
|
640
|
-
| `image-limits.test.ts` | Image input handling |
|
|
641
|
-
| `unicode-surrogate.test.ts` | Unicode edge cases |
|
|
642
|
-
| `tool-call-without-result.test.ts` | Tool call edge cases |
|
|
643
|
-
| `image-tool-result.test.ts` | Images in tool results |
|
|
644
|
-
| `total-tokens.test.ts` | Total token calculation |
|
|
645
|
-
| `cross-provider-handoff.test.ts` | Context handoff between providers |
|
|
470
|
+
Use the local examples as references rather than copying pi-ai's private implementation or test sources. Test a custom stream directly by iterating its public `AssistantMessageEventStream`, then awaiting `stream.result()` for the final message.
|
|
646
471
|
|
|
647
|
-
|
|
472
|
+
At minimum, cover:
|
|
648
473
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
/** API endpoint URL. Required when defining models. */
|
|
657
|
-
baseUrl?: string;
|
|
658
|
-
|
|
659
|
-
/** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */
|
|
660
|
-
apiKey?: string;
|
|
661
|
-
|
|
662
|
-
/** API type for streaming. Required at provider or model level when defining models. */
|
|
663
|
-
api?: Api;
|
|
664
|
-
|
|
665
|
-
/** Custom streaming implementation for non-standard APIs. */
|
|
666
|
-
streamSimple?: (
|
|
667
|
-
model: Model<Api>,
|
|
668
|
-
context: Context,
|
|
669
|
-
options?: SimpleStreamOptions
|
|
670
|
-
) => AssistantMessageEventStream;
|
|
671
|
-
|
|
672
|
-
/** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */
|
|
673
|
-
headers?: Record<string, string>;
|
|
674
|
-
|
|
675
|
-
/** If true, adds Authorization: Bearer header with the resolved API key. */
|
|
676
|
-
authHeader?: boolean;
|
|
677
|
-
|
|
678
|
-
/** Models to register. If provided, replaces all existing models for this provider. */
|
|
679
|
-
models?: ProviderModelConfig[];
|
|
680
|
-
|
|
681
|
-
/** OAuth provider for /login support. */
|
|
682
|
-
oauth?: {
|
|
683
|
-
name: string;
|
|
684
|
-
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
|
|
685
|
-
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
|
|
686
|
-
getApiKey(credentials: OAuthCredentials): string;
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
## Model Definition Reference
|
|
692
|
-
|
|
693
|
-
```typescript
|
|
694
|
-
interface ProviderModelConfig {
|
|
695
|
-
/** Model ID (e.g., "claude-sonnet-4-20250514"). */
|
|
696
|
-
id: string;
|
|
697
|
-
|
|
698
|
-
/** Display name (e.g., "Claude 4 Sonnet"). */
|
|
699
|
-
name: string;
|
|
700
|
-
|
|
701
|
-
/** API type override for this specific model. */
|
|
702
|
-
api?: Api;
|
|
703
|
-
|
|
704
|
-
/** API endpoint URL override for this specific model. */
|
|
705
|
-
baseUrl?: string;
|
|
706
|
-
|
|
707
|
-
/** Whether the model supports extended thinking. */
|
|
708
|
-
reasoning: boolean;
|
|
709
|
-
|
|
710
|
-
/** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */
|
|
711
|
-
thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
|
|
712
|
-
|
|
713
|
-
/** Supported input types. */
|
|
714
|
-
input: ("text" | "image")[];
|
|
715
|
-
|
|
716
|
-
/** Cost per million tokens (for usage tracking). */
|
|
717
|
-
cost: {
|
|
718
|
-
input: number;
|
|
719
|
-
output: number;
|
|
720
|
-
cacheRead: number;
|
|
721
|
-
cacheWrite: number;
|
|
722
|
-
};
|
|
723
|
-
|
|
724
|
-
/** Maximum context window size in tokens. */
|
|
725
|
-
contextWindow: number;
|
|
726
|
-
|
|
727
|
-
/** Maximum output tokens. */
|
|
728
|
-
maxTokens: number;
|
|
729
|
-
|
|
730
|
-
/** Custom headers for this specific model. */
|
|
731
|
-
headers?: Record<string, string>;
|
|
732
|
-
|
|
733
|
-
/** Compatibility settings for the selected API. */
|
|
734
|
-
compat?: {
|
|
735
|
-
// openai-completions
|
|
736
|
-
supportsStore?: boolean;
|
|
737
|
-
supportsDeveloperRole?: boolean;
|
|
738
|
-
supportsReasoningEffort?: boolean;
|
|
739
|
-
supportsUsageInStreaming?: boolean;
|
|
740
|
-
supportsStrictMode?: boolean;
|
|
741
|
-
supportsOpenAIGrammarTools?: boolean; // openai-completions/openai-responses; false falls back to normal function tools
|
|
742
|
-
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
|
743
|
-
requiresToolResultName?: boolean;
|
|
744
|
-
requiresAssistantAfterToolResult?: boolean;
|
|
745
|
-
requiresThinkingAsText?: boolean;
|
|
746
|
-
requiresReasoningContentOnAssistantMessages?: boolean;
|
|
747
|
-
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
|
|
748
|
-
chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
|
|
749
|
-
cacheControlFormat?: "anthropic";
|
|
750
|
-
sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter";
|
|
751
|
-
sendSessionAffinityHeaders?: boolean;
|
|
752
|
-
|
|
753
|
-
// anthropic-messages
|
|
754
|
-
supportsEagerToolInputStreaming?: boolean;
|
|
755
|
-
supportsLongCacheRetention?: boolean;
|
|
756
|
-
sendSessionAffinityHeaders?: boolean;
|
|
757
|
-
supportsCacheControlOnTools?: boolean;
|
|
758
|
-
forceAdaptiveThinking?: boolean;
|
|
759
|
-
allowEmptySignature?: boolean;
|
|
760
|
-
supportsStrictTools?: boolean;
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
```
|
|
474
|
+
- registration and discovery with `pi -e path/to/extension --list-models`;
|
|
475
|
+
- normal text, thinking, and tool-call event ordering, including fragmented tool JSON;
|
|
476
|
+
- final usage/cost values, empty responses, provider errors, and aborts;
|
|
477
|
+
- resolved API keys and headers, plus payload/response hook forwarding for a stream that makes HTTP itself;
|
|
478
|
+
- the configured context window and overflow normalization with `isContextOverflow(message, model.contextWindow)`;
|
|
479
|
+
- one interactive request with the selected provider, including a tool call if the provider claims tool support.
|
|
764
480
|
|
|
765
|
-
|
|
766
|
-
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content.
|
|
481
|
+
The two example extensions above cover the two main test seams: direct custom event production and delegation to Pi AI's public streaming APIs.
|