pi-spark 0.18.0 → 0.19.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 +7 -1
- package/assets/screenshot-codex-resets.png +0 -0
- package/docs/background-model-calls-and-openai-codex-sessions.md +75 -0
- package/docs/openai-codex-banked-rate-limit-resets.md +255 -0
- package/docs/reverse-engineering-fireworks-credits.md +99 -0
- package/package.json +11 -10
- package/src/components/loader.ts +17 -0
- package/src/features/credits/index.ts +3 -0
- package/src/features/credits/providers/index.ts +6 -1
- package/src/features/credits/providers/openai-codex-panel.ts +229 -0
- package/src/features/credits/providers/openai-codex.ts +129 -25
- package/src/features/credits/status.ts +4 -3
- package/src/features/credits/types.ts +7 -3
- package/src/features/editor/index.ts +5 -0
- package/src/features/presets/selector.ts +1 -5
- package/src/features/recap/index.ts +1 -1
- package/src/features/recap/manager.ts +3 -10
- package/src/features/title/index.ts +2 -2
- package/src/features/title/manager.ts +4 -10
- package/src/utils/model.ts +19 -1
package/README.md
CHANGED
|
@@ -37,10 +37,16 @@ pi-spark ships with custom editor, footer, and fullscreen rendering, replacing t
|
|
|
37
37
|
pi-spark shows the active provider's credit balance or rate-limit usage in the status line, so you can keep an eye on what's left without leaving the terminal.
|
|
38
38
|
|
|
39
39
|
- Supported providers: DeepSeek, Fireworks, Kimi Code, Moonshot, OpenAI Codex, OpenRouter, and Vercel AI Gateway.
|
|
40
|
-
- Most provider fetching follows [CodexBar](https://github.com/steipete/codexbar). Fireworks is the exception: its balance sits behind an internal gRPC API, reverse-engineered from the `firectl` binary (see [
|
|
40
|
+
- Most provider fetching follows [CodexBar](https://github.com/steipete/codexbar). Fireworks is the exception: its balance sits behind an internal gRPC API, reverse-engineered from the `firectl` binary (see [Reverse-Engineering Fireworks Credits](./docs/reverse-engineering-fireworks-credits.md)).
|
|
41
41
|
|
|
42
42
|

|
|
43
43
|
|
|
44
|
+
#### OpenAI Codex banked resets
|
|
45
|
+
|
|
46
|
+
Banked rate-limit resets are saved benefits that can reset eligible Codex usage windows when redeemed. When available, their count appears after the usage windows. Run `/codex-reset` to inspect each reset and its expiration date, then select one to redeem. See [OpenAI Codex Banked Rate-Limit Resets](./docs/openai-codex-banked-rate-limit-resets.md) for the underlying behavior and internal APIs.
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
44
50
|
### Presets
|
|
45
51
|
|
|
46
52
|
pi-spark lets you define named model presets in `spark.json` (see [Configuration](#configuration)), so you can switch between models and thinking levels without retyping provider details. The active preset is shown on the editor's top border.
|
|
Binary file
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Background Model Calls and OpenAI Codex Sessions
|
|
2
|
+
|
|
3
|
+
> This document was generated with the [Pi](https://pi.dev/) coding agent.
|
|
4
|
+
|
|
5
|
+
In pi-spark, the title and recap features call the model outside Pi's main agent loop. They send a small, independent conversation through `completeSimple()` and persist only the resulting text and usage.
|
|
6
|
+
|
|
7
|
+
On July 10, 2026, the day GPT-5.6 became available, I configured `openai-codex/gpt-5.6-luna` for title and recap generation while the main thread also used the `openai-codex` provider.
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
Two failures appeared.
|
|
12
|
+
|
|
13
|
+
Without a session ID, GPT-5.6 Luna returned no text:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
Codex error: Model not found gpt-5.6-luna-free-1p-codexswic-ev3
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Passing the main thread's session ID to `completeSimple()` fixed routing, but made the background call reuse the main Codex WebSocket. Its response changed the socket's server-side continuation state. The next main turn then failed:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
Codex error: Previous response with id 'resp_...' not found.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Using a non-cached WebSocket was not enough: it stopped the background call from writing a client-side continuation, but the physical socket was still shared by session ID.
|
|
26
|
+
|
|
27
|
+
## Root cause
|
|
28
|
+
|
|
29
|
+
For `openai-codex-responses`, the session ID serves two roles:
|
|
30
|
+
|
|
31
|
+
- Request-routing identity through `session-id` and `x-client-request-id` headers
|
|
32
|
+
- Client-side WebSocket cache key and response-chain identity
|
|
33
|
+
|
|
34
|
+
A background call therefore needs a valid Codex session ID, but it must not reuse the main thread's ID.
|
|
35
|
+
|
|
36
|
+
In our tests, Codex accepted independent session IDs generated by Pi's `uuidv7()`. UUIDv4 values from `crypto.randomUUID()` and IDs modified with prefixes or suffixes were instead routed to an unavailable internal model.
|
|
37
|
+
|
|
38
|
+
## Solution
|
|
39
|
+
|
|
40
|
+
`completeBackground()` applies special handling only to `openai-codex-responses`. Every call gets an independent Pi UUIDv7 session ID:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
completeSimple(model, context, { ...options, sessionId: uuidv7() });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The background session resource is closed in `finally`. Non-Codex providers use an ordinary one-shot `completeSimple()` call without a shared session ID or transport override.
|
|
47
|
+
|
|
48
|
+
For OpenAI Codex, title, recap, and the main thread consequently have separate:
|
|
49
|
+
|
|
50
|
+
- WebSocket connections
|
|
51
|
+
- Response chains
|
|
52
|
+
- Session routing identities
|
|
53
|
+
- Prompt-cache identities
|
|
54
|
+
|
|
55
|
+
The main socket is never closed or modified by background work. Every background call uses a fresh ID, so overlapping title, recap, or retry calls cannot affect each other's response chains.
|
|
56
|
+
|
|
57
|
+
## Error handling
|
|
58
|
+
|
|
59
|
+
Provider failures from `completeSimple()` may arrive as assistant messages rather than thrown exceptions. Title and recap therefore check:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
if (response.stopReason === "error") {
|
|
63
|
+
throw new Error(response.errorMessage ?? "Background generation failed");
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This prevents a provider error with empty content from being mistaken for an empty model response.
|
|
68
|
+
|
|
69
|
+
## Cache and performance
|
|
70
|
+
|
|
71
|
+
The independent-session design preserves the main thread's WebSocket reuse, `previous_response_id` continuation, and prompt-cache identity. Background generation pays for its own WebSocket handshake, but that work does not invalidate or reconnect the main thread.
|
|
72
|
+
|
|
73
|
+
Cache admission remains provider-controlled and noisy. In repeated GPT-5.6 Luna title A/B probes, isolated background calls caused no continuation errors and showed no negative main-thread cache trend. Main second-turn latency remained around the normal control range instead of paying the reconnect penalty seen when the shared main socket was closed.
|
|
74
|
+
|
|
75
|
+
Use the [`measuring-model-cache`](../.agents/skills/measuring-model-cache/SKILL.md) skill to repeat the comparison after changes to Pi, providers, session handling, title, recap, or other background model features.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# OpenAI Codex Banked Rate-Limit Resets
|
|
2
|
+
|
|
3
|
+
> This document was generated with the [Pi](https://pi.dev/) coding agent.
|
|
4
|
+
|
|
5
|
+
OpenAI Codex allows eligible users to save a rate-limit reset and redeem it later. OpenAI calls this a **banked rate-limit reset**, usually shortened to **banked reset** or **reset**. Internal APIs and source code instead use **rate-limit reset credit** and `RateLimitResetCredit`.
|
|
6
|
+
|
|
7
|
+
A reset is not money, an OpenAI API credit, or a transferable benefit. User-facing interfaces should therefore say `reset`, while code may retain the API's `credit` terminology.
|
|
8
|
+
|
|
9
|
+
## How resets work
|
|
10
|
+
|
|
11
|
+
A banked reset restores eligible Codex subscription rate-limit windows when redeemed. It is separate from the normal five-hour and weekly windows shown in Codex usage data.
|
|
12
|
+
|
|
13
|
+
Resets are promotional benefits, not recurring subscription allowances. Confirmed grants have included the June 2026 launch grant and referral promotions. OpenAI may offer different rewards in the future, with eligibility determined dynamically by plan, region, workspace, account state, prior Codex use, and promotion-specific limits. The offer shown in the product is authoritative.
|
|
14
|
+
|
|
15
|
+
Banked resets usually expire 30 days after they are granted unless an offer states otherwise. The API's `available_count` is the number already granted and currently available; it does not represent how many more resets a user may earn.
|
|
16
|
+
|
|
17
|
+
## Integration surfaces
|
|
18
|
+
|
|
19
|
+
The feature has two integration surfaces:
|
|
20
|
+
|
|
21
|
+
- **Codex App Server JSON-RPC** is the documented, supported integration boundary
|
|
22
|
+
- **ChatGPT WHAM REST** is used by the official open-source Codex client and covered by its contract tests, but it is not a public REST API with a compatibility guarantee
|
|
23
|
+
|
|
24
|
+
The REST interface is convenient for a Pi extension because Pi already stores and refreshes the required ChatGPT OAuth credentials. It should nevertheless be isolated behind a small adapter so endpoint or schema changes do not spread through the feature.
|
|
25
|
+
|
|
26
|
+
## WHAM REST endpoints
|
|
27
|
+
|
|
28
|
+
The confirmed production base URL is:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
https://chatgpt.com/backend-api
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Method | Path | Purpose |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `GET` | `/wham/usage` | Read rate-limit windows and the available-reset summary |
|
|
37
|
+
| `GET` | `/wham/rate-limit-reset-credits` | Read individual reset details |
|
|
38
|
+
| `POST` | `/wham/rate-limit-reset-credits/consume` | Redeem one reset |
|
|
39
|
+
|
|
40
|
+
The official client also supports paths under `<codex-backend-base>/api/codex/...`, but OpenAI has not documented a public production host for that form. In particular, do not assume that `https://api.openai.com/api/codex/...` is valid.
|
|
41
|
+
|
|
42
|
+
## Authentication
|
|
43
|
+
|
|
44
|
+
These endpoints require ChatGPT subscription OAuth, not a normal OpenAI API key:
|
|
45
|
+
|
|
46
|
+
```http
|
|
47
|
+
Authorization: Bearer <access_token>
|
|
48
|
+
ChatGPT-Account-ID: <account_id>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A Pi extension should ask Pi's model registry to resolve the credential so expired OAuth tokens are refreshed. It should not read or parse `~/.pi/agent/auth.json` directly. Tokens and complete request headers must never be logged or included in model context.
|
|
52
|
+
|
|
53
|
+
## Reading usage and the reset summary
|
|
54
|
+
|
|
55
|
+
Request:
|
|
56
|
+
|
|
57
|
+
```http
|
|
58
|
+
GET /backend-api/wham/usage HTTP/1.1
|
|
59
|
+
Host: chatgpt.com
|
|
60
|
+
Authorization: Bearer <access_token>
|
|
61
|
+
ChatGPT-Account-ID: <account_id>
|
|
62
|
+
Accept: application/json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Relevant response fields:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"plan_type": "plus",
|
|
70
|
+
"rate_limit": {
|
|
71
|
+
"primary_window": {
|
|
72
|
+
"used_percent": 27,
|
|
73
|
+
"limit_window_seconds": 18000,
|
|
74
|
+
"reset_after_seconds": 13140,
|
|
75
|
+
"reset_at": 1782770922
|
|
76
|
+
},
|
|
77
|
+
"secondary_window": {
|
|
78
|
+
"used_percent": 4,
|
|
79
|
+
"limit_window_seconds": 604800,
|
|
80
|
+
"reset_after_seconds": 566340,
|
|
81
|
+
"reset_at": 1783357722
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"rate_limit_reset_credits": {
|
|
85
|
+
"available_count": 2
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`primary_window` is normally the five-hour window and `secondary_window` the weekly window. `reset_at` is a Unix timestamp; `reset_after_seconds` is the corresponding relative duration observed in live responses. Read the reset count defensively:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const available = response.rate_limit_reset_credits?.available_count ?? 0;
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This endpoint provides only a summary. Use the details endpoint to show expiration times or select a particular reset.
|
|
97
|
+
|
|
98
|
+
## Reading reset details
|
|
99
|
+
|
|
100
|
+
Request:
|
|
101
|
+
|
|
102
|
+
```http
|
|
103
|
+
GET /backend-api/wham/rate-limit-reset-credits HTTP/1.1
|
|
104
|
+
Host: chatgpt.com
|
|
105
|
+
Authorization: Bearer <access_token>
|
|
106
|
+
ChatGPT-Account-ID: <account_id>
|
|
107
|
+
Accept: application/json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Response:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"credits": [
|
|
115
|
+
{
|
|
116
|
+
"id": "RateLimitResetCredit_...",
|
|
117
|
+
"reset_type": "codex_rate_limits",
|
|
118
|
+
"status": "available",
|
|
119
|
+
"granted_at": "2026-06-17T00:00:00Z",
|
|
120
|
+
"expires_at": "2026-07-17T00:00:00Z",
|
|
121
|
+
"title": "Full reset (Weekly + 5 hr)",
|
|
122
|
+
"description": "Ready to redeem"
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
"available_count": 2
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The official Codex client consumes these fields:
|
|
130
|
+
|
|
131
|
+
- `id`
|
|
132
|
+
- `reset_type`
|
|
133
|
+
- `status`
|
|
134
|
+
- `granted_at`
|
|
135
|
+
- `expires_at`
|
|
136
|
+
- `title`
|
|
137
|
+
- `description`
|
|
138
|
+
- `available_count`
|
|
139
|
+
|
|
140
|
+
The backend may return additional fields, but the official client deliberately ignores them. A live response contained `total_earned_count: 0` alongside four available credits, so `total_earned_count` must not be used as an earned or available balance. Nullable fields such as `redeem_started_at` and `redeemed_at` were also present but are unnecessary when listing available resets.
|
|
141
|
+
|
|
142
|
+
Treat `available_count` as authoritative. The backend may limit the number of entries in `credits`, so `credits.length` is not a reliable substitute. Only entries whose status is `available` should be offered for redemption.
|
|
143
|
+
|
|
144
|
+
## Consuming a reset
|
|
145
|
+
|
|
146
|
+
Redeeming a reset is irreversible. The UI should show the selected reset, its expiration time, and the current rate-limit windows, then require explicit confirmation with a safe default of cancel.
|
|
147
|
+
|
|
148
|
+
Request:
|
|
149
|
+
|
|
150
|
+
```http
|
|
151
|
+
POST /backend-api/wham/rate-limit-reset-credits/consume HTTP/1.1
|
|
152
|
+
Host: chatgpt.com
|
|
153
|
+
Authorization: Bearer <access_token>
|
|
154
|
+
ChatGPT-Account-ID: <account_id>
|
|
155
|
+
Accept: application/json
|
|
156
|
+
Content-Type: application/json
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
"redeem_request_id": "55cbe0c1-71ab-4c52-b855-80746f73ff52",
|
|
160
|
+
"credit_id": "RateLimitResetCredit_..."
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `redeem_request_id` is a required idempotency key; a UUID is appropriate (UUIDv7 is suitable and is already available from `@earendil-works/pi-agent-core`)
|
|
165
|
+
- `credit_id` is optional; when omitted, the server chooses a reset
|
|
166
|
+
- Retries of the same logical redemption must reuse the original `redeem_request_id`
|
|
167
|
+
|
|
168
|
+
Successful response:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"code": "reset",
|
|
173
|
+
"credit": {
|
|
174
|
+
"id": "RateLimitResetCredit_...",
|
|
175
|
+
"reset_type": "codex_rate_limits",
|
|
176
|
+
"status": "redeemed",
|
|
177
|
+
"redeemed_at": "2026-07-10T12:30:00Z"
|
|
178
|
+
},
|
|
179
|
+
"windows_reset": 2
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The REST response uses one of four snake-case result codes:
|
|
184
|
+
|
|
185
|
+
| Code | Meaning |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `reset` | The reset was redeemed and eligible windows were reset |
|
|
188
|
+
| `nothing_to_reset` | No rate-limit window was currently eligible |
|
|
189
|
+
| `no_credit` | No banked reset was available |
|
|
190
|
+
| `already_redeemed` | This idempotent request had already completed; treat it as success |
|
|
191
|
+
|
|
192
|
+
After `reset` or `already_redeemed`, refresh usage rather than deriving the new count or window state from stale client data. Reset details can be fetched again the next time the selection panel opens.
|
|
193
|
+
|
|
194
|
+
## Error handling and compatibility
|
|
195
|
+
|
|
196
|
+
The REST API can change independently of pi-spark. An adapter should handle:
|
|
197
|
+
|
|
198
|
+
- Missing or newly added fields
|
|
199
|
+
- `401` for missing or expired authorization
|
|
200
|
+
- `403` for an ineligible account, workspace, region, or endpoint
|
|
201
|
+
- Non-JSON responses, including proxy or Cloudflare HTML
|
|
202
|
+
- Request cancellation and timeouts
|
|
203
|
+
- Ambiguous network failures during redemption
|
|
204
|
+
|
|
205
|
+
Do not include response bodies in user-facing errors unless they have been validated and sanitized. If the details endpoint fails, `/wham/usage` can still provide `available_count` for a degraded summary.
|
|
206
|
+
|
|
207
|
+
## pi-spark behavior
|
|
208
|
+
|
|
209
|
+
When `/wham/usage` reports one or more available resets, the credits status appends a summary after the normal windows:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
Codex 5h 42% 7d 7% (4 resets available)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The suffix is omitted when no reset is available. `/codex-reset` opens an interactive panel that loads current usage and reset details, lists available resets by title, grant time, and expiration time, and sorts the earliest expiration first. Selecting a reset opens a separate confirmation prompt before any POST request is made.
|
|
216
|
+
|
|
217
|
+
Each confirmed redemption uses a new UUIDv7 idempotency key and makes one consume request without automatic retries. Successful and idempotently completed redemptions refresh the credits status.
|
|
218
|
+
|
|
219
|
+
## Public App Server equivalent
|
|
220
|
+
|
|
221
|
+
The documented Codex App Server exposes equivalent operations over JSON-RPC.
|
|
222
|
+
|
|
223
|
+
Read limits and resets:
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{ "method": "account/rateLimits/read", "id": 1 }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The response includes camel-case `rateLimitResetCredits`, `availableCount`, and individual credits.
|
|
230
|
+
|
|
231
|
+
Consume a reset:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"method": "account/rateLimitResetCredit/consume",
|
|
236
|
+
"id": 2,
|
|
237
|
+
"params": {
|
|
238
|
+
"idempotencyKey": "55cbe0c1-71ab-4c52-b855-80746f73ff52",
|
|
239
|
+
"creditId": "RateLimitResetCredit_..."
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The App Server is the safer boundary for long-lived external integrations. Direct WHAM access is simpler inside pi-spark today, but should remain replaceable.
|
|
245
|
+
|
|
246
|
+
## References
|
|
247
|
+
|
|
248
|
+
- [Codex App Server](https://developers.openai.com/codex/app-server)
|
|
249
|
+
- [Using Codex with your ChatGPT plan](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan)
|
|
250
|
+
- [Codex Referral Promotions](https://help.openai.com/en/articles/20001271-codex-referral-promotions)
|
|
251
|
+
- [Codex pricing and invitations](https://developers.openai.com/codex/pricing)
|
|
252
|
+
- [Official REST implementation](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client/rate_limit_resets.rs)
|
|
253
|
+
- [Official REST contract tests](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs)
|
|
254
|
+
- [Official backend response types](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/types.rs)
|
|
255
|
+
- [App Server reset processing](https://github.com/openai/codex/blob/main/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Reverse-Engineering Fireworks Credits
|
|
2
|
+
|
|
3
|
+
> This document was generated with the [Pi](https://pi.dev/) coding agent.
|
|
4
|
+
|
|
5
|
+
Most providers in the credits feature expose a documented HTTP endpoint that returns a balance. [Fireworks](https://fireworks.ai/) does not. Its dashboard shows account credit, but there is no public REST route for it. This note records how the balance API was found by reverse-engineering the official `firectl` CLI, and how the provider is implemented on top of it.
|
|
6
|
+
|
|
7
|
+
## Where the binary came from
|
|
8
|
+
|
|
9
|
+
Fireworks ships a command-line tool, [`firectl`](https://docs.fireworks.ai/tools-sdks/firectl/). The [Homebrew tap formula](https://github.com/fw-ai/homebrew-firectl/blob/main/Formula/firectl.rb) points at per-platform binaries; the `darwin-amd64` build was used here.
|
|
10
|
+
|
|
11
|
+
It is a Go binary, and crucially an **unstripped** one, which keeps the symbol table, the `pclntab` (function/line metadata), and the full module dependency list. None of that yields original source, but it exposes the program's structure in detail.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
go version -m firectl # build info: firectl 1.7.22, Go 1.25.0, module path
|
|
15
|
+
go tool nm firectl # 81k symbols, including package-qualified names
|
|
16
|
+
go tool objdump -s SYM firectl # disassembly annotated with file:line
|
|
17
|
+
go run github.com/goretk/redress@latest source firectl # folder/file/function tree
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Locating the credit feature
|
|
21
|
+
|
|
22
|
+
The CLI is a Cobra app that is a thin client over a gRPC service named `gateway.Gateway`. Listing the RPC method paths embedded in the binary surfaced the relevant calls:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
strings firectl | grep -oE '/gateway\.Gateway/[A-Za-z]+' | sort -u
|
|
26
|
+
# ... /gateway.Gateway/GetBalance
|
|
27
|
+
# ... /gateway.Gateway/GetLedger
|
|
28
|
+
# ... /gateway.Gateway/ListCreditRedemptions
|
|
29
|
+
# ... /gateway.Gateway/ListAccounts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`GetBalance` is exactly what the dashboard shows, but checking the symbol table revealed that **no CLI command is wired to it** — it exists in the gateway client yet is not reachable through any `firectl` subcommand. So the goal became calling it directly.
|
|
33
|
+
|
|
34
|
+
## Working out authentication
|
|
35
|
+
|
|
36
|
+
Two facts had to be recovered: the host and the credential format.
|
|
37
|
+
|
|
38
|
+
The binary contains several Fireworks hostnames. The inference API (`api.fireworks.ai`) is the well-known one, but the control plane uses a separate host:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
strings firectl | grep -oE '[a-z0-9.-]+\.fireworks\.ai' | sort -u
|
|
42
|
+
# api.fireworks.ai (inference)
|
|
43
|
+
# gateway.fireworks.ai (control-plane gRPC) <- this one
|
|
44
|
+
# app.fireworks.ai, device-auth.fireworks.ai
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For the credential, the disassembly of the client's auth interceptor showed it sets gRPC metadata and references both `authorization` (Bearer) and `x-api-key`. The Bearer slot expects an OIDC ID token, which is what interactive `firectl signin` produces. API-key auth uses the `x-api-key` header instead.
|
|
48
|
+
|
|
49
|
+
This matched what happened on the wire while probing the endpoint with a saved API key:
|
|
50
|
+
|
|
51
|
+
- `api.fireworks.ai:443` returned HTTP 404 — wrong host for this service.
|
|
52
|
+
- `gateway.fireworks.ai:443` with `authorization: Bearer <key>` returned `UNAUTHENTICATED: invalid id token` — the key is not a JWT.
|
|
53
|
+
- `gateway.fireworks.ai:443` with `x-api-key: <key>` authenticated, then returned `INVALID_ARGUMENT` — auth worked; the request body was incomplete.
|
|
54
|
+
|
|
55
|
+
## Reconstructing the messages
|
|
56
|
+
|
|
57
|
+
The Go protobuf structs carry their field tags as string literals, so the message shapes can be read straight out of the binary:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
strings firectl | grep -oE 'protobuf:"[^"]*name=(name|money|currency_code|units|nanos)[^"]*"'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That, plus the generated getter names (`(*Balance).GetMoney`, `(*GetBalanceRequest).GetName`), gives:
|
|
64
|
+
|
|
65
|
+
```proto
|
|
66
|
+
message GetBalanceRequest { string name = 1; } // "accounts/<account_id>"
|
|
67
|
+
message Balance { Money money = 1; }
|
|
68
|
+
message Money { string currency_code = 1; int64 units = 2; int32 nanos = 3; } // google.type.Money
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`GetBalance` needs a `name` like `accounts/<account_id>`. Rather than ask the user for their account id, `ListAccounts` (which the same API key can call) returns it:
|
|
72
|
+
|
|
73
|
+
```proto
|
|
74
|
+
message Account { string name = 1; string display_name = 2; }
|
|
75
|
+
message ListAccountsResponse { repeated Account accounts = 1; }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Confirming end to end
|
|
79
|
+
|
|
80
|
+
A minimal Node.js client using `@grpc/grpc-js` and `@grpc/proto-loader` confirmed the full flow against the live service:
|
|
81
|
+
|
|
82
|
+
1. `ListAccounts` with `x-api-key` → `accounts/<id>`.
|
|
83
|
+
2. `GetBalance { name }` → `Balance { money: { currency_code, units, nanos } }`.
|
|
84
|
+
|
|
85
|
+
The amount is `units + nanos / 1e9` (the standard `google.type.Money` encoding), e.g., `units=9, nanos=694293840` → `$9.69`.
|
|
86
|
+
|
|
87
|
+
## How the provider is implemented in the credits feature
|
|
88
|
+
|
|
89
|
+
The provider lives in [`src/features/credits/providers/fireworks.ts`](../src/features/credits/providers/fireworks.ts) and follows the same `CreditsProvider` contract as the HTTP-based providers; only the transport differs.
|
|
90
|
+
|
|
91
|
+
- **Transport.** A single, reused `@grpc/grpc-js` client dials `gateway.fireworks.ai:443` over TLS. The service is declared in [`fireworks.proto`](../src/features/credits/providers/fireworks.proto) — the minimal slice needed, loaded at runtime with `@grpc/proto-loader`. The `.proto` is resolved relative to the module via `import.meta.url`, so it ships and loads from the package directory.
|
|
92
|
+
- **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The key comes from Pi's `modelRegistry.getApiKeyForProvider("fireworks")` (env `FIREWORKS_API_KEY`), handled by the manager.
|
|
93
|
+
- **Account resolution.** `ListAccounts` is called once and the resolved `accounts/<id>` is cached per API key, so steady-state refreshes are a single `GetBalance` round trip.
|
|
94
|
+
- **Cancellation.** The shared `AbortSignal` cancels the in-flight gRPC call, and a defensive deadline bounds each request.
|
|
95
|
+
- **Result.** `Money` is converted to a dollar amount and returned as `{ type: "balance", remaining }`, which the status line renders like any other balance provider.
|
|
96
|
+
|
|
97
|
+
## Caveats
|
|
98
|
+
|
|
99
|
+
`GetBalance` is an internal API: it is intentionally absent from `firectl`, undocumented, and not part of any public contract. Its host, request shape, and behavior can change at any time without notice, which would break this provider. The narrow `.proto` keeps the blast radius small if that happens.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-spark",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Pi package that polishes your daily experience.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-coding-agent",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"index.ts",
|
|
17
17
|
"src",
|
|
18
18
|
"assets",
|
|
19
|
+
"docs",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
@@ -36,19 +37,19 @@
|
|
|
36
37
|
"zod": "^4.4.3"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
|
-
"@earendil-works/pi-agent-core": "
|
|
40
|
-
"@earendil-works/pi-ai": "
|
|
41
|
-
"@earendil-works/pi-coding-agent": "
|
|
42
|
-
"@earendil-works/pi-tui": "
|
|
40
|
+
"@earendil-works/pi-agent-core": ">=0.80.6",
|
|
41
|
+
"@earendil-works/pi-ai": ">=0.80.6",
|
|
42
|
+
"@earendil-works/pi-coding-agent": ">=0.80.6",
|
|
43
|
+
"@earendil-works/pi-tui": ">=0.80.6",
|
|
43
44
|
"typebox": "*"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
|
-
"@earendil-works/pi-agent-core": "
|
|
47
|
-
"@earendil-works/pi-ai": "
|
|
48
|
-
"@earendil-works/pi-coding-agent": "
|
|
49
|
-
"@earendil-works/pi-tui": "
|
|
47
|
+
"@earendil-works/pi-agent-core": ">=0.80.6",
|
|
48
|
+
"@earendil-works/pi-ai": ">=0.80.6",
|
|
49
|
+
"@earendil-works/pi-coding-agent": ">=0.80.6",
|
|
50
|
+
"@earendil-works/pi-tui": ">=0.80.6",
|
|
50
51
|
"@types/node": "*",
|
|
51
52
|
"typebox": "*",
|
|
52
|
-
"typescript": "^
|
|
53
|
+
"typescript": "^7.0.2"
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Loader as BaseLoader } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** The built-in loader hardcodes 1 column of horizontal padding, which is private. */
|
|
4
|
+
const PADDING_X = 1;
|
|
5
|
+
|
|
6
|
+
export class Loader extends BaseLoader {
|
|
7
|
+
override render(width: number): string[] {
|
|
8
|
+
// Inflate the width so the base loader keeps full content width, then strip its padding.
|
|
9
|
+
return super.render(width + PADDING_X * 2).map((line) => {
|
|
10
|
+
if (line.length === 0) {
|
|
11
|
+
return line;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return line.slice(PADDING_X, line.length - PADDING_X);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CreditsManager } from "./manager";
|
|
2
|
+
import { registerProviderExtensions } from "./providers";
|
|
2
3
|
import { loadConfig } from "../../config";
|
|
3
4
|
import { isUsage } from "../../utils/usage";
|
|
4
5
|
|
|
@@ -20,6 +21,8 @@ export function registerCredits(pi: ExtensionAPI): void {
|
|
|
20
21
|
if (!ctx.hasUI || !config) return;
|
|
21
22
|
|
|
22
23
|
creditsManager = new CreditsManager();
|
|
24
|
+
registerProviderExtensions(pi, ctx, async (ctx) => await creditsManager?.refresh(ctx));
|
|
25
|
+
|
|
23
26
|
creditsManager.refresh(ctx);
|
|
24
27
|
});
|
|
25
28
|
|
|
@@ -6,7 +6,8 @@ import { openaiCodexProvider } from "./openai-codex";
|
|
|
6
6
|
import { openrouterProvider } from "./openrouter";
|
|
7
7
|
import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { CreditsProvider, RefreshCredits } from "../types";
|
|
10
11
|
|
|
11
12
|
const PROVIDERS: CreditsProvider[] = [
|
|
12
13
|
deepseekProvider,
|
|
@@ -22,3 +23,7 @@ const PROVIDERS: CreditsProvider[] = [
|
|
|
22
23
|
export function findProvider(provider?: string): CreditsProvider | undefined {
|
|
23
24
|
return PROVIDERS.find((entry) => entry.id === provider);
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
export function registerProviderExtensions(pi: ExtensionAPI, ctx: ExtensionContext, refresh: RefreshCredits): void {
|
|
28
|
+
for (const provider of PROVIDERS) provider.register?.(pi, ctx, refresh);
|
|
29
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { DynamicBorder, keyHint, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Container, matchesKey, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { Loader } from "../../../components/loader";
|
|
5
|
+
import { renderCredits } from "../status";
|
|
6
|
+
|
|
7
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { BankedRateLimitReset, RateLimitResetCreditsResponse } from "./openai-codex";
|
|
10
|
+
import type { Credits } from "../types";
|
|
11
|
+
|
|
12
|
+
const DATE_COLUMN_WIDTH = 14;
|
|
13
|
+
|
|
14
|
+
export interface CodexResetPanelData extends RateLimitResetCreditsResponse {
|
|
15
|
+
usage: Credits;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function showCodexResetSelector(ctx: ExtensionContext, load: (signal: AbortSignal) => Promise<CodexResetPanelData>): Promise<BankedRateLimitReset | undefined> {
|
|
19
|
+
const selected = await ctx.ui.custom<BankedRateLimitReset | null>((tui, theme, _keybindings, done) => {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
|
|
22
|
+
const container = new Container();
|
|
23
|
+
const box = new Box(1, 1);
|
|
24
|
+
const loader = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Loading...");
|
|
25
|
+
|
|
26
|
+
let settled = false;
|
|
27
|
+
let selectList: SelectList | undefined;
|
|
28
|
+
|
|
29
|
+
const finish = (value: BankedRateLimitReset | null) => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
|
|
32
|
+
settled = true;
|
|
33
|
+
controller.abort();
|
|
34
|
+
loader.stop();
|
|
35
|
+
|
|
36
|
+
done(value);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
40
|
+
|
|
41
|
+
box.addChild(new Text(theme.bold(theme.fg("accent", "OpenAI Codex banked rate-limit resets")), 0, 0));
|
|
42
|
+
box.addChild(loader);
|
|
43
|
+
box.addChild(new Spacer(1));
|
|
44
|
+
box.addChild(new Text(keyHint("tui.select.cancel", "cancel"), 0, 0));
|
|
45
|
+
|
|
46
|
+
container.addChild(box);
|
|
47
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
48
|
+
|
|
49
|
+
loader.start();
|
|
50
|
+
|
|
51
|
+
load(controller.signal).then((data) => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
|
|
54
|
+
loader.stop();
|
|
55
|
+
|
|
56
|
+
const credits = data.credits.filter((credit) => credit.status === "available").toSorted((a, b) => getExpirationTime(a) - getExpirationTime(b));
|
|
57
|
+
if (data.available_count === 0) {
|
|
58
|
+
ctx.ui.notify("No Codex resets available", "info");
|
|
59
|
+
finish(null);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (credits.length === 0) {
|
|
63
|
+
ctx.ui.notify(`${formatAvailableResets(data.available_count)}, but no reset details were returned`, "warning");
|
|
64
|
+
finish(null);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
box.clear();
|
|
69
|
+
box.addChild(new Text(theme.bold(theme.fg("accent", "OpenAI Codex banked rate-limit resets")), 0, 0));
|
|
70
|
+
box.addChild(new Text(renderCredits(theme, "", data.usage), 0, 0));
|
|
71
|
+
box.addChild(new Spacer(1));
|
|
72
|
+
|
|
73
|
+
const creditsById = new Map(credits.map((credit) => [credit.id, credit]));
|
|
74
|
+
const items: SelectItem[] = credits.map((credit) => ({
|
|
75
|
+
value: credit.id,
|
|
76
|
+
label: formatCreditTitle(credit),
|
|
77
|
+
description: formatCreditDescription(credit),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
81
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
82
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
83
|
+
description: (text) => theme.fg("muted", text),
|
|
84
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
85
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
86
|
+
});
|
|
87
|
+
selectList.onSelect = (item) => finish(creditsById.get(item.value) ?? null);
|
|
88
|
+
selectList.onCancel = () => finish(null);
|
|
89
|
+
|
|
90
|
+
box.addChild(selectList);
|
|
91
|
+
box.addChild(new Spacer(1));
|
|
92
|
+
|
|
93
|
+
const keyHints = [rawKeyHint("↑↓", "navigate"), keyHint("tui.select.confirm", "redeem"), keyHint("tui.select.cancel", "cancel")];
|
|
94
|
+
box.addChild(new Text(keyHints.join(" "), 0, 0));
|
|
95
|
+
|
|
96
|
+
tui.requestRender();
|
|
97
|
+
}).catch((error) => {
|
|
98
|
+
if (settled || controller.signal.aborted) return;
|
|
99
|
+
|
|
100
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
101
|
+
finish(null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
render: (width: number) => container.render(width),
|
|
106
|
+
invalidate: () => container.invalidate(),
|
|
107
|
+
handleInput: (data: string) => {
|
|
108
|
+
if (selectList) {
|
|
109
|
+
selectList.handleInput(data);
|
|
110
|
+
tui.requestRender();
|
|
111
|
+
} else if (matchesKey(data, "escape")) {
|
|
112
|
+
finish(null);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return selected ?? undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function confirmCodexReset(ctx: ExtensionContext, credit: BankedRateLimitReset): Promise<boolean> {
|
|
122
|
+
const confirmed = await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
|
|
123
|
+
const container = new Container();
|
|
124
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
125
|
+
|
|
126
|
+
const box = new Box(1, 1);
|
|
127
|
+
box.addChild(new Text(theme.bold(theme.fg("accent", "Redeem reset?")), 0, 0));
|
|
128
|
+
box.addChild(new Spacer(1));
|
|
129
|
+
|
|
130
|
+
const details = [
|
|
131
|
+
formatCreditTitle(credit),
|
|
132
|
+
formatCreditDescription(credit, false),
|
|
133
|
+
credit.description?.trim(),
|
|
134
|
+
].filter(Boolean).join("\n");
|
|
135
|
+
|
|
136
|
+
box.addChild(new Text(theme.fg("muted", details), 0, 0));
|
|
137
|
+
box.addChild(new Spacer(1));
|
|
138
|
+
box.addChild(new Text(theme.fg("warning", "This permanently consumes one banked reset and resets all eligible windows."), 0, 0));
|
|
139
|
+
box.addChild(new Spacer(1));
|
|
140
|
+
|
|
141
|
+
const items: SelectItem[] = [
|
|
142
|
+
{ value: "yes", label: "Yes" },
|
|
143
|
+
{ value: "no", label: "No" },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const selectList = new SelectList(items, 2, {
|
|
147
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
148
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
149
|
+
description: (text) => theme.fg("muted", text),
|
|
150
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
151
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
152
|
+
});
|
|
153
|
+
selectList.onSelect = (item) => done(item.value === "yes");
|
|
154
|
+
selectList.onCancel = () => done(false);
|
|
155
|
+
|
|
156
|
+
box.addChild(selectList);
|
|
157
|
+
box.addChild(new Spacer(1));
|
|
158
|
+
|
|
159
|
+
const keyHints = [rawKeyHint("↑↓", "navigate"), keyHint("tui.select.confirm", "confirm"), keyHint("tui.select.cancel", "cancel")];
|
|
160
|
+
box.addChild(new Text(keyHints.join(" "), 0, 0));
|
|
161
|
+
|
|
162
|
+
container.addChild(box);
|
|
163
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
render: (width: number) => container.render(width),
|
|
167
|
+
invalidate: () => container.invalidate(),
|
|
168
|
+
handleInput: (data: string) => {
|
|
169
|
+
selectList.handleInput(data);
|
|
170
|
+
tui.requestRender();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return confirmed ?? false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function showCodexResetLoader(ctx: ExtensionContext, run: () => Promise<void>): Promise<void> {
|
|
179
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
180
|
+
const container = new Container();
|
|
181
|
+
const loader = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Redeeming...");
|
|
182
|
+
|
|
183
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
184
|
+
|
|
185
|
+
const box = new Box(1, 1);
|
|
186
|
+
box.addChild(new Text(theme.bold(theme.fg("accent", "OpenAI Codex banked rate-limit resets")), 0, 0));
|
|
187
|
+
box.addChild(loader);
|
|
188
|
+
|
|
189
|
+
container.addChild(box);
|
|
190
|
+
container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
|
|
191
|
+
|
|
192
|
+
loader.start();
|
|
193
|
+
|
|
194
|
+
run()
|
|
195
|
+
.catch((error: unknown) => ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"))
|
|
196
|
+
.finally(() => {
|
|
197
|
+
loader.stop();
|
|
198
|
+
done();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
render: (width: number) => container.render(width),
|
|
203
|
+
invalidate: () => container.invalidate(),
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatAvailableResets(count: number): string {
|
|
209
|
+
return `${count} ${count === 1 ? "reset" : "resets"} available`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatCreditTitle(credit: BankedRateLimitReset): string {
|
|
213
|
+
return credit.title?.trim() || "Banked rate-limit reset";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function formatCreditDescription(credit: BankedRateLimitReset, padEnd: boolean = true): string {
|
|
217
|
+
const formatDate = (value: string) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(new Date(value));
|
|
218
|
+
|
|
219
|
+
const granted = padEnd ? `Granted ${formatDate(credit.granted_at)}`.padEnd(DATE_COLUMN_WIDTH) : `Granted ${formatDate(credit.granted_at)}`;
|
|
220
|
+
const expires = padEnd ? (credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration").padEnd(DATE_COLUMN_WIDTH) : (credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration");
|
|
221
|
+
return `${granted} · ${expires}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getExpirationTime(credit: BankedRateLimitReset): number {
|
|
225
|
+
if (!credit.expires_at) return Number.POSITIVE_INFINITY;
|
|
226
|
+
|
|
227
|
+
const timestamp = Date.parse(credit.expires_at);
|
|
228
|
+
return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp;
|
|
229
|
+
}
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
+
import { uuidv7 } from "@earendil-works/pi-agent-core";
|
|
2
|
+
|
|
3
|
+
import { confirmCodexReset, formatAvailableResets, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
|
|
1
4
|
import { toNumber } from "../../../utils/format";
|
|
2
5
|
|
|
3
6
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import type {
|
|
7
|
+
import type { CodexResetPanelData } from "./openai-codex-panel";
|
|
8
|
+
import type { Credits, CreditsProvider, RefreshCredits } from "../types";
|
|
5
9
|
|
|
6
10
|
const PROVIDER = "openai-codex";
|
|
7
|
-
const
|
|
11
|
+
const BASE_URL = "https://chatgpt.com/backend-api";
|
|
12
|
+
const USAGE_PATH = "/wham/usage";
|
|
13
|
+
const RESET_CREDITS_PATH = "/wham/rate-limit-reset-credits";
|
|
14
|
+
const CONSUME_RESET_PATH = "/wham/rate-limit-reset-credits/consume";
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
8
16
|
|
|
9
17
|
interface CodexUsageResponse {
|
|
10
18
|
rate_limit?: {
|
|
11
19
|
primary_window?: CodexRateWindow | null;
|
|
12
20
|
secondary_window?: CodexRateWindow | null;
|
|
13
21
|
} | null;
|
|
22
|
+
rate_limit_reset_credits?: { available_count: number } | null;
|
|
14
23
|
credits?: { unlimited?: boolean } | null;
|
|
15
24
|
}
|
|
16
25
|
|
|
@@ -18,16 +27,116 @@ interface CodexRateWindow {
|
|
|
18
27
|
used_percent?: number | string;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
export interface BankedRateLimitReset {
|
|
31
|
+
id: string;
|
|
32
|
+
status: string;
|
|
33
|
+
granted_at: string;
|
|
34
|
+
expires_at: string | null;
|
|
35
|
+
title?: string | null;
|
|
36
|
+
description?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RateLimitResetCreditsResponse {
|
|
40
|
+
credits: BankedRateLimitReset[];
|
|
41
|
+
available_count: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ConsumeResetResponse {
|
|
45
|
+
code: "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
|
|
46
|
+
windows_reset: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
|
|
50
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
|
|
51
|
+
if (!apiKey) return;
|
|
52
|
+
|
|
53
|
+
const headers = buildHeaders(ctx, apiKey);
|
|
54
|
+
|
|
55
|
+
const credit = await showCodexResetSelector(ctx, async (signal) => {
|
|
56
|
+
const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
|
|
57
|
+
const [usage, details] = await Promise.all([fetchUsage(headers, requestSignal), fetchResetCredits(headers, requestSignal)]);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...details,
|
|
61
|
+
usage: toCredits(usage, `· ${formatAvailableResets(details.available_count)}`),
|
|
62
|
+
} satisfies CodexResetPanelData;
|
|
63
|
+
});
|
|
64
|
+
if (!credit || !(await confirmCodexReset(ctx, credit))) return;
|
|
65
|
+
|
|
66
|
+
await redeemReset(ctx, headers, credit, refresh);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function redeemReset(ctx: ExtensionContext, headers: Record<string, string>, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
|
|
70
|
+
await showCodexResetLoader(ctx, async () => {
|
|
71
|
+
const response = await consumeReset(headers, uuidv7(), credit.id);
|
|
72
|
+
if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
|
|
73
|
+
if (response.code === "no_credit") throw new Error("No Codex resets are available");
|
|
74
|
+
}).finally(() => refresh(ctx));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildHeaders(ctx: ExtensionContext, apiKey: string): Record<string, string> {
|
|
78
|
+
const headers: Record<string, string> = {
|
|
79
|
+
Accept: "application/json",
|
|
80
|
+
Authorization: `Bearer ${apiKey}`,
|
|
81
|
+
};
|
|
24
82
|
|
|
25
|
-
|
|
83
|
+
const credential = ctx.modelRegistry.authStorage.get(PROVIDER);
|
|
84
|
+
const accountId = credential?.type === "oauth" ? (credential.accountId as string | undefined) : undefined;
|
|
85
|
+
if (accountId) headers["ChatGPT-Account-ID"] = accountId;
|
|
86
|
+
|
|
87
|
+
return headers;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function requestJson<T>(path: string, headers: Record<string, string>, signal: AbortSignal, init: RequestInit = {}): Promise<T> {
|
|
91
|
+
const requestHeaders = new Headers(headers);
|
|
92
|
+
new Headers(init.headers).forEach((value, key) => requestHeaders.set(key, value));
|
|
93
|
+
|
|
94
|
+
const response = await fetch(`${BASE_URL}${path}`, {
|
|
95
|
+
...init,
|
|
96
|
+
headers: requestHeaders,
|
|
97
|
+
signal,
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) throw new Error(`Codex backend returned ${response.status}`);
|
|
100
|
+
|
|
101
|
+
return (await response.json()) as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function fetchUsage(headers: Record<string, string>, signal: AbortSignal): Promise<CodexUsageResponse> {
|
|
105
|
+
return requestJson<CodexUsageResponse>(USAGE_PATH, headers, signal);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fetchResetCredits(headers: Record<string, string>, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
|
|
109
|
+
return requestJson<RateLimitResetCreditsResponse>(RESET_CREDITS_PATH, headers, signal);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function consumeReset(headers: Record<string, string>, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
|
|
113
|
+
return requestJson<ConsumeResetResponse>(
|
|
114
|
+
CONSUME_RESET_PATH,
|
|
115
|
+
headers,
|
|
116
|
+
AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ redeem_request_id: redeemRequestId, credit_id: creditId }),
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toCredits(usage: CodexUsageResponse, suffix?: string): Credits {
|
|
126
|
+
return {
|
|
127
|
+
type: "windows",
|
|
128
|
+
unlimited: usage.credits?.unlimited === true,
|
|
129
|
+
lanes: [
|
|
130
|
+
{ label: "5h", percent: parseUsedPercent(usage.rate_limit?.primary_window) },
|
|
131
|
+
{ label: "7d", percent: parseUsedPercent(usage.rate_limit?.secondary_window) },
|
|
132
|
+
],
|
|
133
|
+
suffix,
|
|
134
|
+
};
|
|
26
135
|
}
|
|
27
136
|
|
|
28
137
|
function parseUsedPercent(window?: CodexRateWindow | null): number | undefined {
|
|
29
138
|
const value = toNumber(window?.used_percent);
|
|
30
|
-
return
|
|
139
|
+
return value === undefined ? undefined : Math.min(100, Math.max(0, value));
|
|
31
140
|
}
|
|
32
141
|
|
|
33
142
|
export const openaiCodexProvider: CreditsProvider = {
|
|
@@ -35,26 +144,21 @@ export const openaiCodexProvider: CreditsProvider = {
|
|
|
35
144
|
label: "Codex",
|
|
36
145
|
|
|
37
146
|
async fetch(ctx, apiKey, signal): Promise<Credits> {
|
|
38
|
-
const headers
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const accountId = getAccountId(ctx);
|
|
44
|
-
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
|
147
|
+
const headers = buildHeaders(ctx, apiKey);
|
|
148
|
+
const usage = await fetchUsage(headers, signal);
|
|
149
|
+
const availableCount = usage.rate_limit_reset_credits?.available_count ?? 0;
|
|
150
|
+
const suffix = availableCount > 0 ? `(${formatAvailableResets(availableCount)})` : undefined;
|
|
45
151
|
|
|
46
|
-
|
|
47
|
-
|
|
152
|
+
return toCredits(usage, suffix);
|
|
153
|
+
},
|
|
48
154
|
|
|
49
|
-
|
|
155
|
+
register(pi, ctx, refresh): void {
|
|
156
|
+
const credential = ctx.modelRegistry.authStorage.get(PROVIDER);
|
|
157
|
+
if (credential?.type !== "oauth") return;
|
|
50
158
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ label: "5h", percent: parseUsedPercent(payload.rate_limit?.primary_window) },
|
|
56
|
-
{ label: "7d", percent: parseUsedPercent(payload.rate_limit?.secondary_window) },
|
|
57
|
-
],
|
|
58
|
-
};
|
|
159
|
+
pi.registerCommand("codex-reset", {
|
|
160
|
+
description: "Select and redeem a banked OpenAI Codex rate-limit reset",
|
|
161
|
+
handler: async (_args, ctx) => await runCodexReset(ctx, refresh),
|
|
162
|
+
});
|
|
59
163
|
},
|
|
60
164
|
};
|
|
@@ -7,10 +7,11 @@ const BALANCE_WARNING = 10;
|
|
|
7
7
|
const BALANCE_ERROR = 5;
|
|
8
8
|
|
|
9
9
|
export function renderCredits(theme: Theme, label: string, credits: Credits): string {
|
|
10
|
-
const styledLabel = theme.fg("dim", label);
|
|
10
|
+
const styledLabel = theme.fg("dim", label ? `${label} ` : "");
|
|
11
|
+
const value = credits.type === "windows" ? renderWindows(theme, credits) : renderBalance(theme, credits);
|
|
12
|
+
const suffix = credits.suffix ? ` ${theme.fg("dim", credits.suffix)}` : "";
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
return `${styledLabel} ${renderBalance(theme, credits)}`;
|
|
14
|
+
return `${styledLabel}${value}${suffix}`;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function renderError(theme: Theme, label: string, message: string): string {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderId } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Normalized credits/usage for a provider.
|
|
@@ -7,18 +7,22 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
7
7
|
* - `balance` providers (e.g., OpenRouter, Vercel AI Gateway) report a remaining dollar balance.
|
|
8
8
|
* - `windows` providers (e.g., OpenAI Codex) report rate-limit windows as used percentages.
|
|
9
9
|
*/
|
|
10
|
-
export type Credits =
|
|
10
|
+
export type Credits = (
|
|
11
11
|
| { type: "balance"; remaining?: number | undefined }
|
|
12
|
-
| { type: "windows"; lanes: CreditsLane[]; unlimited?: boolean | undefined }
|
|
12
|
+
| { type: "windows"; lanes: CreditsLane[]; unlimited?: boolean | undefined }
|
|
13
|
+
) & { suffix?: string | undefined };
|
|
13
14
|
|
|
14
15
|
export interface CreditsLane {
|
|
15
16
|
label: string;
|
|
16
17
|
percent: number | undefined;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export type RefreshCredits = (ctx: ExtensionContext) => Promise<void>;
|
|
21
|
+
|
|
19
22
|
/** A credits source for a Pi provider, shown in the status line while that provider is active. */
|
|
20
23
|
export interface CreditsProvider {
|
|
21
24
|
readonly id: ProviderId;
|
|
22
25
|
readonly label: string;
|
|
23
26
|
fetch(ctx: ExtensionContext, apiKey: string, signal: AbortSignal): Promise<Credits>;
|
|
27
|
+
register?(pi: ExtensionAPI, ctx: ExtensionContext, refresh: RefreshCredits): void;
|
|
24
28
|
}
|
|
@@ -155,6 +155,11 @@ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
|
|
|
155
155
|
pi.on("agent_end", () => {
|
|
156
156
|
runningToolCallIds.clear();
|
|
157
157
|
editor?.setWorkingMessage();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
pi.on("agent_settled", () => {
|
|
161
|
+
runningToolCallIds.clear();
|
|
162
|
+
editor?.setWorkingMessage();
|
|
158
163
|
spinner?.stop();
|
|
159
164
|
});
|
|
160
165
|
|
|
@@ -38,11 +38,7 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
|
|
|
38
38
|
box.addChild(selectList);
|
|
39
39
|
box.addChild(new Spacer(1));
|
|
40
40
|
|
|
41
|
-
const keyHints = [
|
|
42
|
-
rawKeyHint("↑↓", "navigate"),
|
|
43
|
-
keyHint("tui.select.confirm", "select"),
|
|
44
|
-
keyHint("tui.select.cancel", "cancel"),
|
|
45
|
-
];
|
|
41
|
+
const keyHints = [rawKeyHint("↑↓", "navigate"), keyHint("tui.select.confirm", "select"), keyHint("tui.select.cancel", "cancel")];
|
|
46
42
|
box.addChild(new Text(keyHints.join(" "), 0, 0));
|
|
47
43
|
|
|
48
44
|
container.addChild(box);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
2
1
|
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
|
|
4
3
|
import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
|
|
5
4
|
import { sanitizeText } from "../../utils/format";
|
|
6
|
-
import { resolveModelSettings } from "../../utils/model";
|
|
5
|
+
import { completeBackground, resolveModelSettings } from "../../utils/model";
|
|
7
6
|
|
|
8
7
|
import type { Api, Model, ModelThinkingLevel, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
|
|
9
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -85,18 +84,12 @@ export class RecapManager {
|
|
|
85
84
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
86
85
|
if (!auth.ok) throw new Error(auth.error);
|
|
87
86
|
|
|
88
|
-
const options: SimpleStreamOptions = {
|
|
89
|
-
maxTokens: MAX_TOKENS,
|
|
90
|
-
// Codex uses the session ID for request routing; without it, background calls may be
|
|
91
|
-
// routed to an unavailable model.
|
|
92
|
-
sessionId: ctx.sessionManager.getSessionId(),
|
|
93
|
-
signal,
|
|
94
|
-
};
|
|
87
|
+
const options: SimpleStreamOptions = { maxTokens: MAX_TOKENS, signal };
|
|
95
88
|
if (auth.apiKey) options.apiKey = auth.apiKey;
|
|
96
89
|
if (auth.headers) options.headers = auth.headers;
|
|
97
90
|
if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
|
|
98
91
|
|
|
99
|
-
const response = await
|
|
92
|
+
const response = await completeBackground(model, {
|
|
100
93
|
systemPrompt: SYSTEM_PROMPT,
|
|
101
94
|
messages: [{
|
|
102
95
|
role: "user",
|
|
@@ -15,8 +15,8 @@ export function registerTitle(pi: ExtensionAPI): void {
|
|
|
15
15
|
|
|
16
16
|
// Generate the title after the first exchange, once the session has context to summarize.
|
|
17
17
|
// The manager runs at most once and skips sessions that already have a name.
|
|
18
|
-
pi.on("
|
|
19
|
-
titleManager?.run(ctx);
|
|
18
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
19
|
+
void titleManager?.run(ctx);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
pi.on("session_shutdown", () => {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
2
1
|
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
|
|
4
3
|
import { sanitizeText } from "../../utils/format";
|
|
5
|
-
import { resolveModelSettings } from "../../utils/model";
|
|
4
|
+
import { completeBackground, resolveModelSettings } from "../../utils/model";
|
|
6
5
|
|
|
7
6
|
import type { Api, Model, ModelThinkingLevel, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
|
|
8
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -13,6 +12,7 @@ const SYSTEM_PROMPT = [
|
|
|
13
12
|
"Base the title only on the transcript; do not invent topics, files, or intent.",
|
|
14
13
|
"Capture the user's main goal or the session's central task.",
|
|
15
14
|
"Respond in the conversation's primary language.",
|
|
15
|
+
"For English, use sentence case, never title case. Add spaces between English and CJK text.",
|
|
16
16
|
"Output a single title of 3-8 words. No trailing punctuation, quotes, markdown, or prefix like 'Title:'.",
|
|
17
17
|
].join(" ");
|
|
18
18
|
|
|
@@ -68,18 +68,12 @@ export class TitleManager {
|
|
|
68
68
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
69
69
|
if (!auth.ok) throw new Error(auth.error);
|
|
70
70
|
|
|
71
|
-
const options: SimpleStreamOptions = {
|
|
72
|
-
maxTokens: MAX_TOKENS,
|
|
73
|
-
// Codex uses the session ID for request routing; without it, background calls may be
|
|
74
|
-
// routed to an unavailable model.
|
|
75
|
-
sessionId: ctx.sessionManager.getSessionId(),
|
|
76
|
-
signal,
|
|
77
|
-
};
|
|
71
|
+
const options: SimpleStreamOptions = { maxTokens: MAX_TOKENS, signal };
|
|
78
72
|
if (auth.apiKey) options.apiKey = auth.apiKey;
|
|
79
73
|
if (auth.headers) options.headers = auth.headers;
|
|
80
74
|
if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
|
|
81
75
|
|
|
82
|
-
const response = await
|
|
76
|
+
const response = await completeBackground(model, {
|
|
83
77
|
systemPrompt: SYSTEM_PROMPT,
|
|
84
78
|
messages: [{
|
|
85
79
|
role: "user",
|
package/src/utils/model.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { uuidv7 } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { clampThinkingLevel, cleanupSessionResources } from "@earendil-works/pi-ai";
|
|
3
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
2
4
|
|
|
3
5
|
import { formatModel } from "./format";
|
|
4
6
|
|
|
@@ -29,6 +31,22 @@ export function isFreeModel(model: Model<Api>): boolean {
|
|
|
29
31
|
return model.cost.input === 0 && model.cost.output === 0 && model.cost.cacheRead === 0 && model.cost.cacheWrite === 0;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Complete a one-shot background request, using an isolated session for OpenAI Codex models.
|
|
36
|
+
* See [the investigation](../../docs/background-model-calls-and-openai-codex-sessions.md).
|
|
37
|
+
*/
|
|
38
|
+
export const completeBackground: typeof completeSimple = async (model, context, options) => {
|
|
39
|
+
if (model.api !== "openai-codex-responses") return completeSimple(model, context, options);
|
|
40
|
+
|
|
41
|
+
const sessionId = uuidv7();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
return await completeSimple(model, context, { ...options, sessionId });
|
|
45
|
+
} finally {
|
|
46
|
+
cleanupSessionResources(sessionId);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
32
50
|
/**
|
|
33
51
|
* Resolve the model and thinking level for a background feature (recap, title, ...).
|
|
34
52
|
*
|