@miruzero/zgpt 0.1.4 → 0.1.5
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 +67 -14
- package/docs/architecture.md +1 -1
- package/docs/custom-mcp-management-api.md +132 -0
- package/package.json +7 -3
- package/src/api/browser-observed.json +82 -0
- package/src/api/contracts.json +101 -0
- package/src/api/index.js +46 -4
- package/src/cli/sdk-dispatch.js +37 -6
- package/src/connector/index.js +2 -1
- package/src/connector/mcp.js +154 -0
- package/src/connector/write.js +2 -1
- package/src/conversation/index.js +29 -0
- package/src/conversation/read.js +41 -7
- package/src/project/index.js +35 -1
- package/src/transport/direct/http-client.js +40 -12
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Zero ChatGPT
|
|
2
2
|
|
|
3
|
-
`
|
|
3
|
+
`@miruzero/zgpt` is the standalone ChatGPT SDK and CLI package. `M:\Zero_ChatGPT` is the developer checkout path, **not** a module import path for npm users.
|
|
4
4
|
|
|
5
5
|
## Scope
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ This package contains only ChatGPT-specific runtime code:
|
|
|
12
12
|
- ChatGPT connector operations
|
|
13
13
|
- ChatGPT Web protocol mechanics
|
|
14
14
|
- direct HTTP/SSE transport
|
|
15
|
-
- ChatGPT mutable state
|
|
15
|
+
- ChatGPT mutable state in the runtime's configurable `state/` directory (`ZERO_CHATGPT_STATE_DIR`)
|
|
16
16
|
|
|
17
17
|
It contains no Zero Core, MCP, Agent orchestration, or generic device/task runtime. The CLI is a thin adapter over the same public SDK exports.
|
|
18
18
|
|
|
@@ -25,13 +25,31 @@ npm install -g @miruzero/zgpt@latest
|
|
|
25
25
|
zgpt
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
For a project-local installation
|
|
28
|
+
For a project-local installation using the published package name:
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
31
|
npm install @miruzero/zgpt@latest
|
|
32
32
|
npx zgpt
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
If you want the **short JavaScript import** `require('zgpt')`, install the published scoped package with an npm alias:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install zgpt@npm:@miruzero/zgpt@latest
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const chatgpt = require('zgpt');
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The two installation forms are alternatives. If you installed `@miruzero/zgpt` under its original name, import it using that same name:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const chatgpt = require('@miruzero/zgpt');
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The npm package name and the CLI binary name are independent; installing **only** `@miruzero/zgpt` does not create a local `node_modules/zgpt` alias.
|
|
52
|
+
|
|
35
53
|
The package exposes the `zgpt` executable through npm's `bin` field. A local `npm install` does not add `node_modules/.bin` to the parent shell's PATH, so use `npx zgpt` for local installs or install globally for the bare `zgpt` command.
|
|
36
54
|
|
|
37
55
|
### Hierarchical SDK → CLI automatic command discovery
|
|
@@ -57,20 +75,28 @@ zgpt api invoke "GET /public-api/conversation_limit"
|
|
|
57
75
|
|
|
58
76
|
## Public API
|
|
59
77
|
|
|
78
|
+
After `npm install zgpt@npm:@miruzero/zgpt@latest`:
|
|
79
|
+
|
|
60
80
|
```js
|
|
61
|
-
const chatgpt = require('
|
|
81
|
+
const chatgpt = require('zgpt');
|
|
62
82
|
|
|
83
|
+
async function main() {
|
|
84
|
+
// Configure ZERO_CHATGPT_STATE_DIR and authenticate before these calls.
|
|
85
|
+
// Session acquisition can be requested via chatgpt.session.acquire() if needed.
|
|
63
86
|
await chatgpt.auth.ensure();
|
|
64
|
-
await chatgpt.
|
|
65
|
-
await chatgpt.
|
|
66
|
-
await chatgpt.
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
const conversation = await chatgpt.conversation.get('YOUR_CONVERSATION_ID');
|
|
88
|
+
const projects = await chatgpt.project.list();
|
|
89
|
+
const connectors = await chatgpt.connector.listConnectors();
|
|
90
|
+
|
|
91
|
+
// Mutations happen only when explicitly invoked:
|
|
92
|
+
// await chatgpt.conversation.send('YOUR_CONVERSATION_ID', 'Hello');
|
|
93
|
+
// await chatgpt.conversation.create('New conversation');
|
|
94
|
+
// await chatgpt.project.create('My project');
|
|
69
95
|
|
|
70
96
|
// Profile and Chat settings (only when entitled; writes require explicit bodies)
|
|
71
97
|
await chatgpt.profile.getMe();
|
|
72
98
|
await chatgpt.profile.getCreator();
|
|
73
|
-
await chatgpt.profile.updateCreator({ body: { display_name: '
|
|
99
|
+
// await chatgpt.profile.updateCreator({ body: { display_name: 'Developer' } });
|
|
74
100
|
await chatgpt.settings.getUser();
|
|
75
101
|
await chatgpt.settings.getConsent();
|
|
76
102
|
await chatgpt.settings.getNotifications();
|
|
@@ -78,7 +104,7 @@ await chatgpt.profile.getAuraIdentity(); // GET /backend-api/aura/identity
|
|
|
78
104
|
|
|
79
105
|
// ChatGPT Web API catalog: saved Web evidence plus verified supplemental routes.
|
|
80
106
|
// Generic routes require appropriate account permissions and exact request contracts.
|
|
81
|
-
console.log(chatgpt.api.summary.total); //
|
|
107
|
+
console.log(chatgpt.api.summary.total); // 1291 (includes provisional paths)
|
|
82
108
|
const route = chatgpt.api.find({ method: 'GET', route: '/api/auth/session' });
|
|
83
109
|
const authSession = await chatgpt.api.invoke(route.key);
|
|
84
110
|
const limits = await chatgpt.api.invoke('GET /public-api/conversation_limit');
|
|
@@ -87,10 +113,16 @@ const limits = await chatgpt.api.invoke('GET /public-api/conversation_limit');
|
|
|
87
113
|
// Parameterized route and explicit one-shot writes (only with a verified payload):
|
|
88
114
|
// await chatgpt.api.invoke('POST /backend-api/hosting/sites/{id}/access',
|
|
89
115
|
// { pathValues: [siteId], body: verifiedBody });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch(error => {
|
|
119
|
+
console.error(error.message);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
});
|
|
90
122
|
```
|
|
91
123
|
|
|
92
|
-
|
|
93
|
-
Historical endpoints remain in `src/api/catalog.json`; the runtime combines
|
|
124
|
+
Saved web evidence is stored in `src/api/discovered.json`, while the four authenticated Browser Network GET discoveries are in `src/api/browser-observed.json`.
|
|
125
|
+
Historical endpoints remain in `src/api/catalog.json`; the runtime combines all three catalogs. Runtime query contracts are stored separately in `src/api/contracts.json`.
|
|
94
126
|
`api.byKey`, `api.functions` and `api.groups` provide typed route-name dispatch.
|
|
95
127
|
This is an **undocumented Web endpoint catalog**, not a guarantee of API stability,
|
|
96
128
|
authorization, correct mutation payloads, or complete browser-feature parity.
|
|
@@ -101,4 +133,25 @@ authorization, correct mutation payloads, or complete browser-feature parity.
|
|
|
101
133
|
- Session exchange transport must be supplied explicitly; no browser/CDP transport is implicit.
|
|
102
134
|
- 429 opens shared cooldown and is not amplified by retries.
|
|
103
135
|
- No hardcoded credentials or secret-bearing diagnostics.
|
|
104
|
-
-
|
|
136
|
+
- By default, state is stored in `state/` relative to the installed package. For an npm-installed/global package, configure `ZERO_CHATGPT_STATE_DIR` to a **writable, user-owned directory outside `node_modules`** before authenticating. Never commit or publish this state.
|
|
137
|
+
|
|
138
|
+
## API verification and request contracts (source checkout)
|
|
139
|
+
|
|
140
|
+
A catalog entry is **not** a proof of a working API. The local verification report classifies all 1,287 routes, and the bounded auditor uses a known-safe GET allowlist, with no mutations, retries, response bodies, cookies, or credentials persisted.
|
|
141
|
+
|
|
142
|
+
```powershell
|
|
143
|
+
npm run api:audit
|
|
144
|
+
npm run api:audit:live -- --start 0 --max 32
|
|
145
|
+
npm run api:audit:live -- --start 32 --max 25
|
|
146
|
+
|
|
147
|
+
zgpt api models getModelsTrailingSlash
|
|
148
|
+
zgpt api images getImagesPromptItems --query surface=sketch_followups
|
|
149
|
+
zgpt api apps getAppsDirectoryBrowse --query category=productivity
|
|
150
|
+
zgpt api composer getComposerItems --query 'entrypoint=chat&limit=10'
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The `--query` flag accepts shell-friendly `name=value` or `name=value&name2=value2`, as well as a JSON object. For GET routes requiring query parameters, see `zgpt api show "GET /backend-api/..."` and `src/api/contracts.json`. The auditor and capture scanners are developer scripts in this source checkout and are not bundled in the npm package. Version `0.1.5` includes the release-ready SDK/CLI changes described above once published.
|
|
154
|
+
|
|
155
|
+
### Custom MCP server CRUD and action refresh
|
|
156
|
+
|
|
157
|
+
The connector namespace supports MCP server creation, updates, deletion, OAuth metadata discovery, action refresh and `disconnectConnectorLink` (unlink a connected account without deleting the server definition). Discover exact SDK methods with `zgpt help connector`. **Live verified:** tunnel listing, OAuth discovery, and linked-action refresh (4 actions returned). **Mock verified only:** server create/edit/delete and connector unlink (no live destructive actions). Creating an OAuth-enabled connector auto-discovers metadata unless explicitly overridden. Refresh and unlink take a `linkId`, not a server definition ID. See the included `docs/custom-mcp-management-api.md` for request contracts and validation boundaries.
|
package/docs/architecture.md
CHANGED
|
@@ -74,4 +74,4 @@ conversation is the only owner of stream diagnosis and repair. Callers request a
|
|
|
74
74
|
5. External ChatGPT responses are validated before state mutation.
|
|
75
75
|
6. This package owns only ChatGPT runtime state.
|
|
76
76
|
7. No Zero Core, MCP, Agent, device, task, or adapter implementation lives here.
|
|
77
|
-
8.
|
|
77
|
+
8. Loops, pagination, retry, and polling are allowed when required by the flow; they must have a measurable progress/termination condition, an explicit owner, and bounded failure recovery. Repeated writes must not be replayed without an idempotency guarantee. The current ChatGPT stream recovery remains demand-driven; no blanket prohibition on loops is implied.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Custom MCP Server API — Web Client source audit (2026-10-12)
|
|
2
|
+
|
|
3
|
+
## Status and scope
|
|
4
|
+
|
|
5
|
+
**Implemented in local source, no remote mutations performed.** The source of truth is existing ChatGPT Web callsites in the captured files `C:\Users\Administrator\Documents\OSSGPT\chatgpt.com\cdn\assets\conversation-small-cyhg2evird2sa17j.js` and `c2675c8c-j30y5ypuebsjl0k8.js`. The `OAI-Product-Sku` header was resolved from an imported constant in `4813494d-k4tqhmof689yx2je.js`; Web sends `OAI-Product-Sku: CONNECTOR_SETTING`.
|
|
6
|
+
|
|
7
|
+
Browser form fields: name, optional description, HTTPS server URL or `tunnel_<32 lowercase letters/digits>`, optional PNG icon (data URL; max 10 KB), authentication selection. The Web flow builds `auth_request` from supported authentication types and OAuth discovery before creating a connector. OAuth connection/linking is a separate operation after creation.
|
|
8
|
+
|
|
9
|
+
| Operation | Web route | Body from Web source | Verification |
|
|
10
|
+
| --- | --- | --- | --- |
|
|
11
|
+
| Create | POST `/backend-api/aip/connectors/mcp` | `{name,description,logo_url,mcp_url OR tunnel_id,custom_headers?,auth_request}` | Static callsite; SDK mocked |
|
|
12
|
+
| Read detail | GET `/backend-api/aip/connectors/{id}` | Path `connector_id` | Static callsite; SDK mocked |
|
|
13
|
+
| Rename | PATCH `/backend-api/aip/connectors/{id}/name` | `{name}` | Static callsite; SDK mocked |
|
|
14
|
+
| Description | PATCH `/backend-api/aip/connectors/{id}/description` | `{description}` | Static callsite; SDK mocked |
|
|
15
|
+
| Icon | PATCH `/backend-api/aip/connectors/{id}/logo` | `{logo_url}` | Static callsite; SDK mocked |
|
|
16
|
+
| Delete | DELETE `/backend-api/aip/connectors/{id}` | Path `connector_id` | Static callsite; SDK mocked |
|
|
17
|
+
| Refresh tools | POST `/backend-api/aip/connectors/mcp/refresh_actions` | `{link_id}` **not** connector ID | Static callsite; SDK mocked; previous SDK wrapper exists |
|
|
18
|
+
| OAuth discovery | POST `/backend-api/aip/connectors/mcp/oauth_config` | `{mcp_url OR tunnel_id,custom_headers}` | **Live SDK and CLI verified using `https://mcps.devteam.sc/core`** |
|
|
19
|
+
| List tunnels | GET `/backend-api/aip/connectors/mcp/tunnels` | None | **Live SDK HTTP 200**; CLI Exit 0 |
|
|
20
|
+
|
|
21
|
+
No confirmed endpoint for editing an existing server's **MCP URL** directly. Do not infer that renaming/reloading edits the transport URL; recreation may be required but was not performed.
|
|
22
|
+
|
|
23
|
+
## SDK API
|
|
24
|
+
|
|
25
|
+
The public exports from `src/connector/mcp.js` are automatically exposed under `zgpt.connector` and as CLI commands via the existing dispatch logic:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
const zgpt = require('@miruzero/zgpt');
|
|
29
|
+
|
|
30
|
+
// Real GET, with authorized local session:
|
|
31
|
+
const {tunnels} = await zgpt.connector.listCustomMcpTunnels();
|
|
32
|
+
|
|
33
|
+
// Read-only metadata discovery from the authenticated ChatGPT Web backend.
|
|
34
|
+
const discovered = await zgpt.connector.getCustomMcpOAuthConfig(
|
|
35
|
+
'https://mcps.devteam.sc/core'
|
|
36
|
+
);
|
|
37
|
+
console.log(discovered.oauth_config.type); // OAUTH
|
|
38
|
+
// Only call the next step if you intend to create an actual connector.
|
|
39
|
+
// Default: auto-discover auth and preserve PKCE, scope and registration metadata.
|
|
40
|
+
const created = await zgpt.connector.createCustomMcpServer({
|
|
41
|
+
name: 'DevTeam MCP',
|
|
42
|
+
mcpUrl: 'https://mcps.devteam.sc/core'
|
|
43
|
+
});
|
|
44
|
+
// To bypass discovery, supply a fully configured authRequest explicitly,
|
|
45
|
+
// or set authentication:'NONE' only for intentionally unauthenticated servers.
|
|
46
|
+
await zgpt.connector.renameCustomMcpServer({connectorId:created.connector.id,name:'Renamed MCP'});
|
|
47
|
+
// NOTE: use a linkId returned by the connection/link flow, not created.connector.id:
|
|
48
|
+
// await zgpt.connector.reloadCustomMcpServerActions({linkId:'link_...'});
|
|
49
|
+
// Explicit destructive command only when intended:
|
|
50
|
+
// await zgpt.connector.deleteCustomMcpServer({connectorId:created.connector.id});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**The above example includes mutations and was NOT executed on a live account.** Use only against servers you intend to create/change/delete.
|
|
54
|
+
|
|
55
|
+
## CLI
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
zgpt help connector
|
|
59
|
+
zgpt connector listCustomMcpTunnels
|
|
60
|
+
zgpt connector getCustomMcpOAuthConfig https://mcps.devteam.sc/core
|
|
61
|
+
zgpt api show "POST /backend-api/aip/connectors/mcp"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
All methods also appear under `zgpt connector <methodName>`. JSON object arguments are passed via `--args JSON_ARRAY` and all non-read methods require `--allow-write`. The general `api` dispatcher also uses the same catalog and enforces required body fields.
|
|
65
|
+
|
|
66
|
+
## Verification and outstanding work
|
|
67
|
+
|
|
68
|
+
- Unit/contract suite: **196/196 tests passed** (full project tests as of 2026-10-12).
|
|
69
|
+
- Live read: `GET /backend-api/aip/connectors/mcp/tunnels` HTTP 200 and matching CLI operation exited 0.
|
|
70
|
+
- Mutations (POST create, PATCH metadata, DELETE, POST refresh) **not live tested**; their success depends on actual owner permissions, OAuth configuration, valid image input and server capabilities.
|
|
71
|
+
- The create function only creates a connector definition. Completing OAuth and linking a server is a separate browser/client flow. The wrapper deliberately does not invent OAuth registration/scopes or auto-install a connector link.
|
|
72
|
+
- `logo_url` supports HTTPS or a PNG Data URL, matching the browser's FileReader path and 10 KB limit; generated artwork was not uploaded.
|
|
73
|
+
- The source changes are local and **not yet included in published npm 0.1.4**.
|
|
74
|
+
|
|
75
|
+
## Source paths
|
|
76
|
+
|
|
77
|
+
- `src/connector/mcp.js`
|
|
78
|
+
- `src/connector/write.js`
|
|
79
|
+
- `src/connector/index.js`
|
|
80
|
+
- `src/api/contracts.json`
|
|
81
|
+
- `src/api/index.js`
|
|
82
|
+
- `test/connector-mcp-management.test.js`
|
|
83
|
+
|
|
84
|
+
## Live OAuth Discovery evidence (2026-10-12, MiruZero)
|
|
85
|
+
|
|
86
|
+
- Actual MCP URL supplied by user: `https://mcps.devteam.sc/core`.
|
|
87
|
+
- Used the existing authenticated SDK session to call `POST /backend-api/aip/connectors/mcp/oauth_config` with `{mcp_url:'https://mcps.devteam.sc/core', custom_headers:[]}` and product SKU `CONNECTOR_SETTING`.
|
|
88
|
+
- **Live verified:** response carried `oauth_config.type=OAUTH`, resource matching the supplied URL, authorization/token/registration endpoints, `pkce_required=true`, `pkce_methods=['S256']`, `scopes_supported=['mcp:read']`, and `token_endpoint_auth_methods_supported=['none']`.
|
|
89
|
+
- CLI `zgpt connector getCustomMcpOAuthConfig https://mcps.devteam.sc/core` returned Exit 0 and the same safe metadata. CLI now preserves public OAuth discovery fields without exposing client secrets, credentials, access tokens or state-bearing authorization URLs.
|
|
90
|
+
- The local `createCustomMcpServer({name,mcpUrl})` defaults to automatic metadata discovery, maps the returned OAuth configuration into `auth_request.supported_auth`, and only then issues the create POST. This sequencing was tested using a stubbed transport, **not performed on the live user account**.
|
|
91
|
+
- Discovery failures and malformed/unsupported OAuth metadata fail closed; the SDK does **not** switch to `NONE` automatically. To intentionally skip discovery, pass either `authentication:'NONE'` or a previously configured `authRequest`.
|
|
92
|
+
- Connector creation does **not** mean OAuth authorization completed. Registration, PKCE redirect/callback, connector link creation, and action refresh are separate operations. They remain untested against this MCP server and are not automatically triggered.
|
|
93
|
+
- Local source changes are **not** included in the npm release `@miruzero/zgpt@0.1.4` until a new version is published.
|
|
94
|
+
|
|
95
|
+
## MCP action refresh: live SDK + CLI verification (2026-10-12)
|
|
96
|
+
|
|
97
|
+
**VERIFIED for one authorized connection on MiruZero.** A user-supplied cURL trace was treated as evidence of the request shape rather than copied into source or the session store. Existing SDK auth/session logic supplied the access token and cookies; only the `linkId` came from the user's request. Neither bearer token, account ID, device ID nor browser fingerprint headers were hard-coded.
|
|
98
|
+
|
|
99
|
+
Request contract: `POST /backend-api/aip/connectors/mcp/refresh_actions`, body `{link_id}`, `OAI-Product-Sku: CONNECTOR_SETTING`, authenticated via SDK, no automatic HTTP retry. The SDK's legacy `connector.refreshConnectorActions({linkId})` is the single request implementation; `connector.reloadCustomMcpServerActions({linkId})` delegates to it.
|
|
100
|
+
|
|
101
|
+
Verified:
|
|
102
|
+
- JavaScript SDK `connector.reloadCustomMcpServerActions({linkId: '<authorized-link-id>'})`: completed successfully and returned an `actions` array.
|
|
103
|
+
- Windows CLI `zgpt connector reloadCustomMcpServerActions <authorized-link-id> --allow-write`: exit code **0**, response had an `actions` array containing **4 actions**.
|
|
104
|
+
- Added string-argument convenience `connector.reloadCustomMcpServerActions('<authorized-link-id>')`, auto-exposed to CLI via grouped SDK dispatch.
|
|
105
|
+
- Tests cover a single, non-retried POST with exact body, product-SKU header, automatic CLI write guard, both options-object and string argument support.
|
|
106
|
+
|
|
107
|
+
Not verified: automatic OAuth linking, creating a connection link, or behavior for other connector accounts. No secrets were persisted from the sample cURL and no unrelated connectors were modified. The response contents and individual action names were intentionally not logged.
|
|
108
|
+
|
|
109
|
+
## Connected account unlink — Settings “ยกเลิกการเชื่อมต่อ”
|
|
110
|
+
|
|
111
|
+
**Implemented in SDK and automatically exposed in CLI. The real account link was NOT deleted.**
|
|
112
|
+
|
|
113
|
+
The user-provided browser cURL shows a connected-account operation that is *different* from deleting the MCP server definition:
|
|
114
|
+
|
|
115
|
+
- **Disconnect link:** `DELETE /backend-api/aip/connectors/links/{link_id}` (no JSON body)
|
|
116
|
+
- **Delete MCP definition:** `DELETE /backend-api/aip/connectors/{connector_id}`
|
|
117
|
+
- **Refresh linked actions:** `POST /backend-api/aip/connectors/mcp/refresh_actions` with `{link_id}`
|
|
118
|
+
|
|
119
|
+
`zgpt.connector.disconnectConnectorLink({linkId})` delegates directly to the existing API catalog dispatcher, which binds Zero's current authorization/session and sends the CONNECTOR_SETTING product SKU. It accepts a positional string `linkId` for CLI convenience. It rejects malformed/non-link IDs before executing any HTTP request, and `retry:false` prevents replay of DELETE.
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const zgpt = require('@miruzero/zgpt');
|
|
123
|
+
// Only run to intentionally disconnect an authorized account:
|
|
124
|
+
await zgpt.connector.disconnectConnectorLink({linkId:'link_YOUR_LINK_ID'});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```powershell
|
|
128
|
+
# Destructive: disconnect a link only when intended:
|
|
129
|
+
zgpt connector disconnectConnectorLink link_YOUR_LINK_ID --allow-write
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The CLI refuses the command without `--allow-write`. A 204 No Content response normalizes to `{success:true}`, while non-2xx statuses propagate as errors. Tests verify URL/method/no-body, existing injected auth, missing/wrong ID guards, HTTP 403, one-shot execution and CLI write approval. **Only the request shape and the mock transport behavior are verified; live deletion has deliberately not been attempted.** The supplied Bearer token, cookies, account identifiers and browser fingerprint headers were not inserted into the codebase.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miruzero/zgpt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "src/cli.js",
|
|
11
11
|
"test": "node --test",
|
|
12
|
+
"prepublishOnly": "npm test",
|
|
12
13
|
"api:rescan": "node scripts/collect-web-api.js",
|
|
13
|
-
"api:reconcile": "node scripts/reconcile-web-api.js"
|
|
14
|
+
"api:reconcile": "node scripts/reconcile-web-api.js",
|
|
15
|
+
"api:audit": "node scripts/audit-web-api.js",
|
|
16
|
+
"api:audit:live": "node scripts/audit-web-api.js --live"
|
|
14
17
|
},
|
|
15
18
|
"engines": {
|
|
16
19
|
"node": ">=20"
|
|
@@ -24,7 +27,8 @@
|
|
|
24
27
|
"files": [
|
|
25
28
|
"src",
|
|
26
29
|
"README.md",
|
|
27
|
-
"docs/architecture.md"
|
|
30
|
+
"docs/architecture.md",
|
|
31
|
+
"docs/custom-mcp-management-api.md"
|
|
28
32
|
],
|
|
29
33
|
"publishConfig": {
|
|
30
34
|
"access": "public"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "Agent Browser v0.39.0 attached to logged-in chatgpt.com, captured read-only HTTP status metadata; no auth headers or payloads persisted",
|
|
3
|
+
"observedAt": "2026-10-12",
|
|
4
|
+
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"key": "GET /backend-api/accounts/check/v4-2023-04-27",
|
|
7
|
+
"method": "GET",
|
|
8
|
+
"client": "Request",
|
|
9
|
+
"route": "/backend-api/accounts/check/v4-2023-04-27",
|
|
10
|
+
"name": "getAccountsCheckV420230427",
|
|
11
|
+
"family": "accounts",
|
|
12
|
+
"scope": "CHATGPT_WEB",
|
|
13
|
+
"evidence": [
|
|
14
|
+
"AGENT_BROWSER_SESSION_NETWORK"
|
|
15
|
+
],
|
|
16
|
+
"captureCount": 1,
|
|
17
|
+
"statuses": {
|
|
18
|
+
"200": 1
|
|
19
|
+
},
|
|
20
|
+
"verification": "LIVE_BROWSER_HTTP_200",
|
|
21
|
+
"liveStatus": 200,
|
|
22
|
+
"observedAt": "2026-10-12"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"key": "GET /backend-api/ps/plugins/installed",
|
|
26
|
+
"method": "GET",
|
|
27
|
+
"client": "Request",
|
|
28
|
+
"route": "/backend-api/ps/plugins/installed",
|
|
29
|
+
"name": "getPsPluginsInstalled",
|
|
30
|
+
"family": "ps",
|
|
31
|
+
"scope": "CHATGPT_WEB_PLUGINS",
|
|
32
|
+
"evidence": [
|
|
33
|
+
"AGENT_BROWSER_SESSION_NETWORK"
|
|
34
|
+
],
|
|
35
|
+
"captureCount": 1,
|
|
36
|
+
"statuses": {
|
|
37
|
+
"200": 1
|
|
38
|
+
},
|
|
39
|
+
"verification": "LIVE_BROWSER_HTTP_200",
|
|
40
|
+
"liveStatus": 200,
|
|
41
|
+
"observedAt": "2026-10-12"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"key": "GET /backend-api/wham/accounts/check",
|
|
45
|
+
"method": "GET",
|
|
46
|
+
"client": "Request",
|
|
47
|
+
"route": "/backend-api/wham/accounts/check",
|
|
48
|
+
"name": "getWhamAccountsCheck",
|
|
49
|
+
"family": "wham",
|
|
50
|
+
"scope": "WORK_ACCOUNTS",
|
|
51
|
+
"evidence": [
|
|
52
|
+
"AGENT_BROWSER_SESSION_NETWORK"
|
|
53
|
+
],
|
|
54
|
+
"captureCount": 1,
|
|
55
|
+
"statuses": {
|
|
56
|
+
"200": 1
|
|
57
|
+
},
|
|
58
|
+
"verification": "LIVE_BROWSER_HTTP_200",
|
|
59
|
+
"liveStatus": 200,
|
|
60
|
+
"observedAt": "2026-10-12"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"key": "GET /backend-api/wham/sites/access",
|
|
64
|
+
"method": "GET",
|
|
65
|
+
"client": "Request",
|
|
66
|
+
"route": "/backend-api/wham/sites/access",
|
|
67
|
+
"name": "getWhamSitesAccess",
|
|
68
|
+
"family": "wham",
|
|
69
|
+
"scope": "WORK_SITES",
|
|
70
|
+
"evidence": [
|
|
71
|
+
"AGENT_BROWSER_SESSION_NETWORK"
|
|
72
|
+
],
|
|
73
|
+
"captureCount": 1,
|
|
74
|
+
"statuses": {
|
|
75
|
+
"200": 1
|
|
76
|
+
},
|
|
77
|
+
"verification": "LIVE_BROWSER_HTTP_200",
|
|
78
|
+
"liveStatus": 200,
|
|
79
|
+
"observedAt": "2026-10-12"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"GET /backend-api/models/config": {
|
|
3
|
+
"requiredQuery": [
|
|
4
|
+
"slug"
|
|
5
|
+
],
|
|
6
|
+
"contractEvidence": "STATIC_OSSGPT_QUERY; LIVE_422_MISSING_SLUG",
|
|
7
|
+
"runtimeNotes": "Frontend supplies a model slug; live GET with current model slugs returned 404 in this session, so availability remains unverified."
|
|
8
|
+
},
|
|
9
|
+
"GET /backend-api/gizmos/user_action_settings": {
|
|
10
|
+
"requiredQuery": [
|
|
11
|
+
"gizmo_id"
|
|
12
|
+
],
|
|
13
|
+
"contractEvidence": "STATIC_OSSGPT_QUERY; LIVE_422_MISSING_GIZMO_ID",
|
|
14
|
+
"runtimeNotes": "Requires a valid GPT identifier. No permitted GPT ID was available in the tested bootstrap response."
|
|
15
|
+
},
|
|
16
|
+
"GET /backend-api/apps/directory/browse": {
|
|
17
|
+
"requiredQuery": [
|
|
18
|
+
"category"
|
|
19
|
+
],
|
|
20
|
+
"contractEvidence": "STATIC_OSSGPT_QUERY; LIVE_422_MISSING_CATEGORY; LIVE_200_CATEGORY_PRODUCTIVITY",
|
|
21
|
+
"runtimeNotes": "Use a category id from GET /backend-api/apps/directory/apps. Productivity returned HTTP 200 in this session."
|
|
22
|
+
},
|
|
23
|
+
"GET /backend-api/composer/items": {
|
|
24
|
+
"requiredQuery": [
|
|
25
|
+
"entrypoint"
|
|
26
|
+
],
|
|
27
|
+
"contractEvidence": "STATIC_OSSGPT_QUERY; LIVE_422_MISSING_ENTRYPOINT; LIVE_200_ENTRYPOINT_CHAT",
|
|
28
|
+
"runtimeNotes": "The saved web callsite also sends limit and sometimes timezone; the server required entrypoint. entrypoint=chat&limit=10 returned HTTP 200."
|
|
29
|
+
},
|
|
30
|
+
"GET /backend-api/images/prompt-items": {
|
|
31
|
+
"requiredQuery": [
|
|
32
|
+
"surface"
|
|
33
|
+
],
|
|
34
|
+
"contractEvidence": "STATIC_OSSGPT_QUERY; LIVE_422_MISSING_SURFACE; LIVE_200_SURFACE_SKETCH_FOLLOWUPS",
|
|
35
|
+
"runtimeNotes": "The web source passes surface=sketch_followups; live result was HTTP 200."
|
|
36
|
+
},
|
|
37
|
+
"POST /backend-api/aip/connectors/mcp": {
|
|
38
|
+
"requiredBodyFields": [
|
|
39
|
+
"name",
|
|
40
|
+
"auth_request"
|
|
41
|
+
],
|
|
42
|
+
"bodyAnyOf": [
|
|
43
|
+
"mcp_url",
|
|
44
|
+
"tunnel_id"
|
|
45
|
+
],
|
|
46
|
+
"contractEvidence": "OSSGPT_WEB_ONSUBMIT_CUSTOM_MCP; STATIC_WEB_SAFEPOST",
|
|
47
|
+
"runtimeNotes": "Create connector definition; no write-side effects tested. Browser UI sends name, description, logo_url, either mcp_url or tunnel_id, optional custom_headers, and auth_request with supported_auth/oauth_client_params/default_scopes/oidc_enabled/use_cimd. Linking/OAuth occurs in a subsequent flow."
|
|
48
|
+
},
|
|
49
|
+
"PATCH /backend-api/aip/connectors/{id}/name": {
|
|
50
|
+
"requiredBodyFields": [
|
|
51
|
+
"name"
|
|
52
|
+
],
|
|
53
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEPATCH",
|
|
54
|
+
"runtimeNotes": "Path connector_id, requestBody {name}. Not live-tested against user data."
|
|
55
|
+
},
|
|
56
|
+
"PATCH /backend-api/aip/connectors/{id}/description": {
|
|
57
|
+
"requiredBodyFields": [
|
|
58
|
+
"description"
|
|
59
|
+
],
|
|
60
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEPATCH",
|
|
61
|
+
"runtimeNotes": "Path connector_id, requestBody {description}; empty description is permitted for clearing. No live mutation test."
|
|
62
|
+
},
|
|
63
|
+
"PATCH /backend-api/aip/connectors/{id}/logo": {
|
|
64
|
+
"requiredBodyFields": [
|
|
65
|
+
"logo_url"
|
|
66
|
+
],
|
|
67
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEPATCH",
|
|
68
|
+
"runtimeNotes": "Path connector_id, requestBody {logo_url}; actual upload step is separate. No live mutation test."
|
|
69
|
+
},
|
|
70
|
+
"DELETE /backend-api/aip/connectors/{id}": {
|
|
71
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEDELETE",
|
|
72
|
+
"runtimeNotes": "Destructive removal of connector definition; never automatically executed by audit."
|
|
73
|
+
},
|
|
74
|
+
"DELETE /backend-api/aip/connectors/links/{id}": {
|
|
75
|
+
"contractEvidence": "USER_PROVIDED_BROWSER_CURL; SDK_MOCK_TEST",
|
|
76
|
+
"runtimeNotes": "Unlinks a connected account by link_id from ChatGPT Settings. No request body. Requires authenticated session and CONNECTOR_SETTING product SKU. Distinct from deleting the MCP connector definition. Never run against a live link without an explicit unlink instruction."
|
|
77
|
+
},
|
|
78
|
+
"POST /backend-api/aip/connectors/mcp/refresh_actions": {
|
|
79
|
+
"requiredBodyFields": [
|
|
80
|
+
"link_id"
|
|
81
|
+
],
|
|
82
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEPOST; EXISTING_CONNECTOR_TEST",
|
|
83
|
+
"runtimeNotes": "Refresh action list by link_id, NOT connector_id. Side-effecting command; not executed live."
|
|
84
|
+
},
|
|
85
|
+
"POST /backend-api/aip/connectors/mcp/oauth_config": {
|
|
86
|
+
"bodyAnyOf": [
|
|
87
|
+
"mcp_url",
|
|
88
|
+
"tunnel_id"
|
|
89
|
+
],
|
|
90
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEPOST",
|
|
91
|
+
"runtimeNotes": "RequestBody {mcp_url,custom_headers} or {tunnel_id,custom_headers}; response.oauth_config; potentially queries remote server. Not executed live."
|
|
92
|
+
},
|
|
93
|
+
"GET /backend-api/aip/connectors/mcp/tunnels": {
|
|
94
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEGET; LIVE_SDK_HTTP_200",
|
|
95
|
+
"runtimeNotes": "Read-only GET returned HTTP 200 with a tunnels array in this session."
|
|
96
|
+
},
|
|
97
|
+
"GET /backend-api/aip/connectors/{id}": {
|
|
98
|
+
"contractEvidence": "STATIC_OSSGPT_WEB_SAFEGET",
|
|
99
|
+
"runtimeNotes": "Read connector by id; requires authorized connector_id and may include actions. Not probed with user ID."
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/api/index.js
CHANGED
|
@@ -6,8 +6,11 @@ const paths = require('../config/paths');
|
|
|
6
6
|
const direct = require('../transport/direct/http-client');
|
|
7
7
|
const catalogData = require('./catalog.json');
|
|
8
8
|
const discoveredData = require('./discovered.json');
|
|
9
|
+
const browserObservedData = require('./browser-observed.json');
|
|
10
|
+
const contracts = require('./contracts.json');
|
|
9
11
|
|
|
10
|
-
const catalog = Object.freeze([...catalogData.entries, ...discoveredData.entries].map((entry) =>
|
|
12
|
+
const catalog = Object.freeze([...catalogData.entries, ...discoveredData.entries, ...browserObservedData.entries].map((entry) =>
|
|
13
|
+
Object.freeze({ ...entry, ...(contracts[entry.key] || {}) })));
|
|
11
14
|
if (new Set(catalog.map((entry) => entry.key)).size !== catalog.length ||
|
|
12
15
|
new Set(catalog.map((entry) => entry.name)).size !== catalog.length) {
|
|
13
16
|
throw new Error('ChatGPT Web API catalog contains duplicate keys or function names');
|
|
@@ -17,6 +20,7 @@ const summary = Object.freeze({
|
|
|
17
20
|
total: catalog.length,
|
|
18
21
|
publicRequest: catalog.filter((entry) => entry.client === 'PublicRequest').length,
|
|
19
22
|
supplemental: discoveredData.entries.length,
|
|
23
|
+
browserObserved: browserObservedData.entries.length,
|
|
20
24
|
source: catalogData.source,
|
|
21
25
|
supplementSource: discoveredData.source
|
|
22
26
|
});
|
|
@@ -115,11 +119,41 @@ exports.find = (criteria = {}) => exports.list(criteria)[0] || null;
|
|
|
115
119
|
|
|
116
120
|
exports.invoke = async (reference, options = {}) => {
|
|
117
121
|
const entry = resolveEntry(reference);
|
|
122
|
+
for (const parameter of entry.requiredQuery || []) {
|
|
123
|
+
const value = options.query?.[parameter];
|
|
124
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
125
|
+
const err = new Error(`ChatGPT API requires query parameter ${parameter}: ${entry.key}`);
|
|
126
|
+
err.code = 'API_QUERY_PARAMETER_REQUIRED';
|
|
127
|
+
err.parameter = parameter;
|
|
128
|
+
err.route = entry.route;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const field of entry.requiredBodyFields || []) {
|
|
133
|
+
if (!options.body || typeof options.body !== 'object' || !Object.hasOwn(options.body,field) ||
|
|
134
|
+
options.body[field] === undefined || options.body[field] === null) {
|
|
135
|
+
const err = new Error(`ChatGPT API requires body field ${field}: ${entry.key}`);
|
|
136
|
+
err.code='API_BODY_PARAMETER_REQUIRED';
|
|
137
|
+
err.parameter=field;
|
|
138
|
+
err.route=entry.route;
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (entry.bodyAnyOf?.length && !entry.bodyAnyOf.some(field =>
|
|
143
|
+
typeof options.body?.[field] === 'string' && options.body[field].trim())) {
|
|
144
|
+
const err = new Error(`ChatGPT API requires one of ${entry.bodyAnyOf.join(', ')}: ${entry.key}`);
|
|
145
|
+
err.code='API_BODY_PARAMETER_REQUIRED';
|
|
146
|
+
err.parameter=entry.bodyAnyOf.join('|');
|
|
147
|
+
err.route=entry.route;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
118
150
|
const rendered = renderRoute(entry.route, options);
|
|
119
151
|
// PublicRequest uses /public-api as its base. Captured wire paths already include it.
|
|
120
152
|
const endpoint = entry.client === 'PublicRequest' && !rendered.startsWith('/public-api/')
|
|
121
153
|
? '/public-api' + rendered : rendered;
|
|
122
|
-
|
|
154
|
+
// Captured 307 redirect: /models/ canonicalizes to /models. Keep the catalog alias callable.
|
|
155
|
+
const canonicalEndpoint = endpoint === '/backend-api/models/' ? '/backend-api/models' : endpoint;
|
|
156
|
+
const target = appendQuery(canonicalEndpoint, options.query);
|
|
123
157
|
const context = await bindContext(entry, options);
|
|
124
158
|
const responseType = options.responseType === 'text' ? 'text' : 'json';
|
|
125
159
|
const request = responseType === 'text'
|
|
@@ -127,7 +161,7 @@ exports.invoke = async (reference, options = {}) => {
|
|
|
127
161
|
: (options.requestJson || direct.requestJson);
|
|
128
162
|
const requestOptions = {
|
|
129
163
|
method: entry.method,
|
|
130
|
-
route: entry.route,
|
|
164
|
+
route: canonicalEndpoint === endpoint ? entry.route : canonicalEndpoint,
|
|
131
165
|
retry: false,
|
|
132
166
|
...(options.body === undefined ? {} : { body: options.body }),
|
|
133
167
|
...(options.headers ? { headers: options.headers } : {}),
|
|
@@ -149,7 +183,15 @@ exports.invoke = async (reference, options = {}) => {
|
|
|
149
183
|
}
|
|
150
184
|
if (options.rawResponse === true) return response;
|
|
151
185
|
if (responseType === 'text') return response.text ?? '';
|
|
152
|
-
|
|
186
|
+
if (response.status === 204 || response.status === 205) return { success: true };
|
|
187
|
+
if (response.json === null || response.json === undefined) {
|
|
188
|
+
const error = new Error(`ChatGPT API expected JSON but received no valid JSON: ${entry.method} ${entry.route}`);
|
|
189
|
+
error.code = 'API_UNEXPECTED_RESPONSE';
|
|
190
|
+
error.status = response.status;
|
|
191
|
+
error.route = entry.route;
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
return response.json;
|
|
153
195
|
};
|
|
154
196
|
|
|
155
197
|
const byKey = Object.create(null);
|
package/src/cli/sdk-dispatch.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Generic CLI adapter for the public SDK. No duplicate endpoint registry or operation implementation.
|
|
4
4
|
const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
5
5
|
const secretKey = /(?:access[_-]?token|refresh[_-]?token|id[_-]?token|auth(?:orization)?|cookie|password|passphrase|secret|credential|csrf|api[_-]?key|session[_-]?token|private[_-]?key)/i;
|
|
6
|
-
const safeReaderName = /^(?:(?:get|list|read|find|search|has|is|can|check|describe|inspect|status)$|(?:get|list|read|find|search|has|is|can|check|describe|inspect|status)[A-Z_])/;
|
|
6
|
+
const safeReaderName = /^(?:(?:get|list|read|find|search|has|is|can|check|describe|inspect|status|conversations)$|(?:get|list|read|find|search|has|is|can|check|describe|inspect|status)[A-Z_])/;
|
|
7
7
|
const error = (message) => Object.assign(new Error(message), { code: 'CLI_USAGE', exitCode: 2 });
|
|
8
8
|
const own = (obj, key) => {
|
|
9
9
|
if (!obj || typeof obj !== 'object' && typeof obj !== 'function' || forbiddenKeys.has(key)) return undefined;
|
|
@@ -11,14 +11,32 @@ const own = (obj, key) => {
|
|
|
11
11
|
return descriptor && Object.hasOwn(descriptor, 'value') ? descriptor.value : undefined;
|
|
12
12
|
};
|
|
13
13
|
const json = (value) => JSON.stringify(value, null, 2);
|
|
14
|
-
const
|
|
14
|
+
const publicOauthMetadata = new Set([
|
|
15
|
+
'authorization_url','authorization_server_base','authorization_response_iss_parameter_supported'
|
|
16
|
+
]);
|
|
17
|
+
const safeOauthMetadata = (key, entry) => {
|
|
18
|
+
if (!publicOauthMetadata.has(key)) return false;
|
|
19
|
+
if (key === 'authorization_response_iss_parameter_supported') return typeof entry === 'boolean' || entry === null;
|
|
20
|
+
if (typeof entry !== 'string') return false;
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(entry);
|
|
23
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.hash &&
|
|
24
|
+
[...url.searchParams.keys()].every(param =>
|
|
25
|
+
!/(token|code|state|nonce|secret|password|credential|key)/i.test(param));
|
|
26
|
+
} catch { return false; }
|
|
27
|
+
};
|
|
28
|
+
const redact = (value, depth = 0, seen = new WeakSet(), oauthMetadata = false) => {
|
|
15
29
|
if (depth > 30) return '[DEPTH_LIMIT]';
|
|
16
30
|
if (!value || typeof value !== 'object') return value;
|
|
17
31
|
if (seen.has(value)) return '[CIRCULAR]';
|
|
18
32
|
seen.add(value);
|
|
19
|
-
if (Array.isArray(value)) return value.map(
|
|
20
|
-
return Object.fromEntries(Object.entries(value).map(([key, entry]) =>
|
|
21
|
-
|
|
33
|
+
if (Array.isArray(value)) return value.map(entry => redact(entry, depth + 1, seen, oauthMetadata));
|
|
34
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
|
|
35
|
+
const isConfig = key === 'oauth_config' && entry && typeof entry === 'object' && !Array.isArray(entry);
|
|
36
|
+
const allowMetadata = oauthMetadata && safeOauthMetadata(key, entry);
|
|
37
|
+
return [key, secretKey.test(key) && !isConfig && !allowMetadata
|
|
38
|
+
? '[REDACTED]' : redact(entry, depth + 1, seen, oauthMetadata || !!isConfig)];
|
|
39
|
+
}));
|
|
22
40
|
};
|
|
23
41
|
const show = (stdout, value) => stdout(json(redact(value)));
|
|
24
42
|
const jsonArgument = (input, flag, kind) => {
|
|
@@ -30,6 +48,19 @@ const jsonArgument = (input, flag, kind) => {
|
|
|
30
48
|
throw error(flag + ' must be a JSON object');
|
|
31
49
|
return value;
|
|
32
50
|
};
|
|
51
|
+
const queryArgument = (input) => {
|
|
52
|
+
if (input.startsWith('{')) return jsonArgument(input, '--query', 'object');
|
|
53
|
+
if (!input.includes('=')) throw error('--query requires JSON or name=value parameters');
|
|
54
|
+
const query = {};
|
|
55
|
+
for (const [name,value] of new URLSearchParams(input)) {
|
|
56
|
+
if (!name || forbiddenKeys.has(name)) throw error('Invalid --query key');
|
|
57
|
+
if (Object.hasOwn(query,name)) {
|
|
58
|
+
const prior=query[name];
|
|
59
|
+
query[name]=Array.isArray(prior)?[...prior,value]:[prior,value];
|
|
60
|
+
} else query[name]=value;
|
|
61
|
+
}
|
|
62
|
+
return query;
|
|
63
|
+
};
|
|
33
64
|
const optionsFrom = (args, allowed) => {
|
|
34
65
|
const flags = {};
|
|
35
66
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -244,7 +275,7 @@ async function apiCommand(argv, runtime, stdout) {
|
|
|
244
275
|
for (const key of ['token','sessionHeaders','requestJson','requestText','env','sessionFile','headers','rawResponse','auth'])
|
|
245
276
|
if (Object.hasOwn(options, key)) throw error('CLI cannot override SDK authentication/transport: ' + key);
|
|
246
277
|
if (flags['--path-values']) options.pathValues = jsonArgument(flags['--path-values'], '--path-values', 'array');
|
|
247
|
-
if (flags['--query']) options.query =
|
|
278
|
+
if (flags['--query']) options.query = queryArgument(flags['--query']);
|
|
248
279
|
if (flags['--body']) options.body = jsonArgument(flags['--body'], '--body', 'object');
|
|
249
280
|
if (writeRequired(entry.method) && !flags['--allow-write']) throw error('Non-read API requires --allow-write');
|
|
250
281
|
if (!writeRequired(entry.method) && Object.hasOwn(options, 'body')) throw error('Read-only API cannot use --body');
|
package/src/connector/index.js
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Browser-derived MCP connector operations. The single SDK API catalog owns routes,
|
|
4
|
+
// authentication, transport, encoding and response handling. Never auto-create a link.
|
|
5
|
+
const api = require('../api');
|
|
6
|
+
|
|
7
|
+
const bad = (message) => Object.assign(new Error(message), { code: 'BAD_ARGS' });
|
|
8
|
+
const nonempty = (value, field) => {
|
|
9
|
+
if (typeof value !== 'string' || !value.trim()) throw bad(field + ' is required');
|
|
10
|
+
return value.trim();
|
|
11
|
+
};
|
|
12
|
+
const safeHttpUrl = (value, field) => {
|
|
13
|
+
const text = nonempty(value, field);
|
|
14
|
+
let url;
|
|
15
|
+
try { url = new URL(text); } catch { throw bad(field + ' must be a valid HTTPS URL'); }
|
|
16
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
17
|
+
throw bad(field + ' must be a valid HTTPS URL without embedded credentials');
|
|
18
|
+
return text;
|
|
19
|
+
};
|
|
20
|
+
const logoValue = (value) => {
|
|
21
|
+
const input=nonempty(value,'logoUrl');
|
|
22
|
+
if (input.startsWith('data:')) {
|
|
23
|
+
const match=/^data:image\/png;base64,([A-Za-z0-9+/]+={0,2})$/.exec(input);
|
|
24
|
+
if (!match) throw bad('logoUrl must be a PNG data URL');
|
|
25
|
+
if (Buffer.from(match[1],'base64').length > 10 * 1024)
|
|
26
|
+
throw bad('logoUrl PNG exceeds the 10 KB maximum');
|
|
27
|
+
return input;
|
|
28
|
+
}
|
|
29
|
+
return safeHttpUrl(input,'logoUrl');
|
|
30
|
+
};
|
|
31
|
+
const makeTarget = ({mcpUrl, tunnelId}) => {
|
|
32
|
+
if (mcpUrl != null && tunnelId != null) throw bad('Choose either mcpUrl or tunnelId, not both');
|
|
33
|
+
if (mcpUrl != null) return {mcp_url:safeHttpUrl(mcpUrl,'mcpUrl')};
|
|
34
|
+
if (tunnelId != null) {
|
|
35
|
+
const id=nonempty(tunnelId,'tunnelId');
|
|
36
|
+
if (!/^tunnel_[a-z0-9]{32}$/.test(id)) throw bad('tunnelId must match tunnel_<32 lowercase letters or digits>');
|
|
37
|
+
return {tunnel_id:id};
|
|
38
|
+
}
|
|
39
|
+
throw bad('mcpUrl or tunnelId is required');
|
|
40
|
+
};
|
|
41
|
+
const request = (key, options, patch = {}) =>
|
|
42
|
+
api.invoke(key, {
|
|
43
|
+
...options,...patch,
|
|
44
|
+
headers:{...options.headers,...patch.headers,'OAI-Product-Sku':'CONNECTOR_SETTING'}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const authRequestFromDiscovery = (discovery) => {
|
|
48
|
+
const config=discovery?.oauth_config;
|
|
49
|
+
const invalid=() => Object.assign(new Error('MCP OAuth discovery returned incomplete or unsupported metadata'), {
|
|
50
|
+
code:'MCP_OAUTH_CONFIG_INVALID'
|
|
51
|
+
});
|
|
52
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw invalid();
|
|
53
|
+
if (config.type === 'NONE') return {supported_auth:[],oauth_client_params:null};
|
|
54
|
+
if (config.type !== 'OAUTH' ||
|
|
55
|
+
typeof config.authorization_url !== 'string' || !config.authorization_url ||
|
|
56
|
+
typeof config.token_url !== 'string' || !config.token_url) throw invalid();
|
|
57
|
+
// ChatGPT Web puts the discovered OAuth config inside supported_auth.
|
|
58
|
+
// Registration, PKCE, scopes and resource stay exactly as advertised by the server.
|
|
59
|
+
return {supported_auth:[config],oauth_client_params:null};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
exports.createCustomMcpServer = async (options = {}) => {
|
|
63
|
+
const name = nonempty(options.name,'name');
|
|
64
|
+
const target=makeTarget(options);
|
|
65
|
+
let authRequest;
|
|
66
|
+
if (options.authRequest !== undefined) {
|
|
67
|
+
if (options.authentication) throw bad('Use authentication or authRequest, not both');
|
|
68
|
+
if (!options.authRequest || typeof options.authRequest !== 'object' ||
|
|
69
|
+
Array.isArray(options.authRequest) || !Array.isArray(options.authRequest.supported_auth))
|
|
70
|
+
throw bad('authRequest must be a web-compatible object with supported_auth array');
|
|
71
|
+
authRequest=options.authRequest;
|
|
72
|
+
} else if (options.authentication === 'NONE') {
|
|
73
|
+
authRequest={supported_auth:[],oauth_client_params:null};
|
|
74
|
+
} else if (options.authentication === undefined || options.authentication === 'AUTO') {
|
|
75
|
+
// Discovery is safe to repeat and does not create a connector or a link.
|
|
76
|
+
// Never silently downgrade OAuth to NONE on a discovery error.
|
|
77
|
+
const discovery=await exports.getCustomMcpOAuthConfig(options);
|
|
78
|
+
authRequest=authRequestFromDiscovery(discovery);
|
|
79
|
+
} else throw bad('Unknown authentication type; use AUTO, NONE or a configured authRequest');
|
|
80
|
+
if (options.description !== undefined && typeof options.description !== 'string') throw bad('description must be a string');
|
|
81
|
+
if (options.customHeaders !== undefined && !Array.isArray(options.customHeaders)) throw bad('customHeaders must be an array');
|
|
82
|
+
const body = {
|
|
83
|
+
name, ...target,
|
|
84
|
+
description:(options.description || '').trim(),
|
|
85
|
+
logo_url:options.logoUrl == null ? null : logoValue(options.logoUrl),
|
|
86
|
+
auth_request:authRequest
|
|
87
|
+
};
|
|
88
|
+
if (options.customHeaders?.length) body.custom_headers=options.customHeaders;
|
|
89
|
+
return request('POST /backend-api/aip/connectors/mcp',options,{body});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
exports.renameCustomMcpServer = async (options = {}) =>
|
|
93
|
+
request('PATCH /backend-api/aip/connectors/{id}/name',options,{
|
|
94
|
+
pathValues:[nonempty(options.connectorId,'connectorId')],
|
|
95
|
+
body:{name:nonempty(options.name,'name')}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
exports.describeCustomMcpServer = async (options = {}) => {
|
|
99
|
+
if (typeof options.description !== 'string') throw bad('description must be a string');
|
|
100
|
+
return request('PATCH /backend-api/aip/connectors/{id}/description',options,{
|
|
101
|
+
pathValues:[nonempty(options.connectorId,'connectorId')],
|
|
102
|
+
body:{description:options.description}
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
exports.setCustomMcpServerLogo = async (options = {}) =>
|
|
107
|
+
request('PATCH /backend-api/aip/connectors/{id}/logo',options,{
|
|
108
|
+
pathValues:[nonempty(options.connectorId,'connectorId')],
|
|
109
|
+
body:{logo_url:logoValue(options.logoUrl)}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
exports.deleteCustomMcpServer = async (options = {}) =>
|
|
113
|
+
request('DELETE /backend-api/aip/connectors/{id}',options,{
|
|
114
|
+
pathValues:[nonempty(options.connectorId,'connectorId')]
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
exports.getCustomMcpServer = async (options = {}) =>
|
|
118
|
+
request('GET /backend-api/aip/connectors/{id}',options,{
|
|
119
|
+
pathValues:[nonempty(options.connectorId,'connectorId')]
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
exports.listCustomMcpTunnels = async (options = {}) =>
|
|
123
|
+
request('GET /backend-api/aip/connectors/mcp/tunnels',options);
|
|
124
|
+
|
|
125
|
+
exports.getCustomMcpOAuthConfig = async (options = {}, injectedOptions = {}) => {
|
|
126
|
+
// A single URL argument makes `zgpt connector getCustomMcpOAuthConfig https://...` usable on PowerShell.
|
|
127
|
+
if (typeof options === 'string') options={...injectedOptions,mcpUrl:options};
|
|
128
|
+
if (options.customHeaders !== undefined && !Array.isArray(options.customHeaders))
|
|
129
|
+
throw bad('customHeaders must be an array');
|
|
130
|
+
return request('POST /backend-api/aip/connectors/mcp/oauth_config',options,{
|
|
131
|
+
body:{...makeTarget(options),custom_headers:options.customHeaders || []}
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Unlink an account connection without deleting the MCP connector definition.
|
|
136
|
+
// The user must explicitly invoke this destructive operation; never auto-unlink
|
|
137
|
+
// after an auth error, reconnect or action refresh.
|
|
138
|
+
exports.disconnectConnectorLink = async (options = {}, injectedOptions = {}) => {
|
|
139
|
+
if (typeof options === 'string') options = { ...injectedOptions, linkId: options };
|
|
140
|
+
const linkId = nonempty(options.linkId, 'linkId');
|
|
141
|
+
if (!/^link_[A-Za-z0-9_-]+$/.test(linkId))
|
|
142
|
+
throw bad('linkId must be a connector link identifier (link_...)');
|
|
143
|
+
return request('DELETE /backend-api/aip/connectors/links/{id}', options, {
|
|
144
|
+
pathValues: [linkId]
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Refresh uses the linked connection ID, not the connector ID.
|
|
149
|
+
exports.reloadCustomMcpServerActions = async (options = {}, injectedOptions = {}) => {
|
|
150
|
+
// Accept the linkId directly for `zgpt connector reloadCustomMcpServerActions <linkId>`.
|
|
151
|
+
// The legacy options-object form and the SDK's single refresh implementation stay intact.
|
|
152
|
+
if (typeof options === 'string') options = { ...injectedOptions, linkId: options };
|
|
153
|
+
return require('./write').refreshConnectorActions(options);
|
|
154
|
+
};
|
package/src/connector/write.js
CHANGED
|
@@ -21,6 +21,7 @@ exports.refreshConnectorActions = async (options = {}) => {
|
|
|
21
21
|
const target = '/backend-api/aip/connectors/mcp/refresh_actions';
|
|
22
22
|
const requestJson = options.requestJson || direct.requestJson;
|
|
23
23
|
return requireSuccess(await requestJson(target, options.token, options.sessionHeaders, {
|
|
24
|
-
method: 'POST', body: { link_id: options.linkId }, route: target, retry: false
|
|
24
|
+
method: 'POST', body: { link_id: options.linkId }, route: target, retry: false,
|
|
25
|
+
headers:{...options.headers,'OAI-Product-Sku':'CONNECTOR_SETTING'}
|
|
25
26
|
}), target);
|
|
26
27
|
};
|
|
@@ -55,6 +55,35 @@ exports.send = async (id, message, options = {}) => write.sendConversation({
|
|
|
55
55
|
message,
|
|
56
56
|
...(await bindCanonicalContext(options))
|
|
57
57
|
});
|
|
58
|
+
// CLI-friendly management façade matching the verified Zero_LLM command tree.
|
|
59
|
+
// Mutations stay owned by ./write; no copied HTTP routes or parallel auth stack.
|
|
60
|
+
const required = (value, name) => {
|
|
61
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
62
|
+
throw Object.assign(new Error(name + ' is required'), { code: 'BAD_ARGS' });
|
|
63
|
+
}
|
|
64
|
+
return value.trim();
|
|
65
|
+
};
|
|
66
|
+
exports.create = async (message, options = {}) => write.createConversation({
|
|
67
|
+
...(await bindCanonicalContext(options)),
|
|
68
|
+
message: required(message, 'message')
|
|
69
|
+
});
|
|
70
|
+
exports.rename = async (id, title, options = {}) => write.renameConversation({
|
|
71
|
+
...(await bindCanonicalContext(options)),
|
|
72
|
+
conversationId: required(id, 'conversationId'),
|
|
73
|
+
title: required(title, 'title')
|
|
74
|
+
});
|
|
75
|
+
exports.delete = async (id, options = {}) => write.deleteConversation({
|
|
76
|
+
...(await bindCanonicalContext(options)),
|
|
77
|
+
conversationId: required(id, 'conversationId')
|
|
78
|
+
});
|
|
79
|
+
exports.move = async (id, projectId = null, options = {}) => write.moveConversation({
|
|
80
|
+
...(await bindCanonicalContext(options)),
|
|
81
|
+
conversationId: required(id, 'conversationId'),
|
|
82
|
+
projectId: projectId === 'exit' || projectId == null ? null : required(projectId, 'projectId')
|
|
83
|
+
});
|
|
84
|
+
// Compatibility with the Zero_LLM CLI: new/del map to the same owners.
|
|
85
|
+
exports.new = exports.create;
|
|
86
|
+
exports.del = exports.delete;
|
|
58
87
|
exports.temp = {
|
|
59
88
|
new: async (message, options = {}) => temporary.createConversation({
|
|
60
89
|
message,
|
package/src/conversation/read.js
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const direct = require('../transport/direct/http-client');
|
|
4
4
|
|
|
5
|
+
const pageBudget = (maxPages = 1000) => {
|
|
6
|
+
if (!Number.isSafeInteger(maxPages) || maxPages < 1) {
|
|
7
|
+
throw Object.assign(new Error('maxPages must be a positive safe integer'), { code: 'PAGINATION_BUDGET_INVALID' });
|
|
8
|
+
}
|
|
9
|
+
return maxPages;
|
|
10
|
+
};
|
|
11
|
+
const paginationError = (code, message) => Object.assign(new Error(message), { code });
|
|
12
|
+
|
|
5
13
|
const requireOk = (response, target) => {
|
|
6
14
|
if (!response || response.status !== 200 || !response.json) {
|
|
7
15
|
const error = new Error(`ChatGPT request failed (${response?.status || 'no-status'}): ${target}`);
|
|
@@ -52,25 +60,45 @@ exports.listStandalone = async ({
|
|
|
52
60
|
token,
|
|
53
61
|
sessionHeaders,
|
|
54
62
|
requestJson = direct.requestJson,
|
|
55
|
-
onProgress = () => {}
|
|
63
|
+
onProgress = () => {},
|
|
64
|
+
maxPages = 1000
|
|
56
65
|
}) => {
|
|
66
|
+
const budget = pageBudget(maxPages);
|
|
57
67
|
const items = [];
|
|
68
|
+
const seenItems = new Set();
|
|
58
69
|
let offset = 0;
|
|
59
70
|
const limit = 50;
|
|
60
71
|
|
|
61
|
-
for (let pageNumber = 0; pageNumber <
|
|
72
|
+
for (let pageNumber = 0; pageNumber < budget; pageNumber += 1) {
|
|
62
73
|
const target = `/backend-api/conversations?offset=${offset}&limit=${limit}&order=updated&is_archived=false&is_starred=false`;
|
|
63
74
|
const page = requireOk(await requestJson(target, token, sessionHeaders), target);
|
|
64
75
|
const pageItems = Array.isArray(page.items) ? page.items : [];
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
if (page.offset !== undefined && page.offset !== null && Number(page.offset) !== offset) {
|
|
77
|
+
throw paginationError('PAGINATION_STALLED', 'conversation pagination server offset did not advance');
|
|
78
|
+
}
|
|
79
|
+
let added = 0;
|
|
80
|
+
for (const item of pageItems) {
|
|
81
|
+
const id = item?.id || item?.conversation_id;
|
|
82
|
+
const key = id ? `id:${id}` : `row:${JSON.stringify(item)}`;
|
|
83
|
+
if (seenItems.has(key)) continue;
|
|
84
|
+
seenItems.add(key);
|
|
85
|
+
items.push(item);
|
|
86
|
+
added++;
|
|
87
|
+
}
|
|
88
|
+
if (pageItems.length > 0 && added === 0 && pageNumber > 0) {
|
|
89
|
+
throw paginationError('PAGINATION_STALLED', 'conversation pagination returned no new items');
|
|
90
|
+
}
|
|
91
|
+
onProgress({ type: 'conversation-page', count: items.length, pageCount: added, offset });
|
|
67
92
|
|
|
68
93
|
const total = Number(page.total);
|
|
69
94
|
const pageLimit = Number(page.limit) || limit;
|
|
95
|
+
if (!Number.isSafeInteger(pageLimit) || pageLimit <= 0) {
|
|
96
|
+
throw paginationError('PAGINATION_STALLED', 'conversation pagination returned an invalid page limit');
|
|
97
|
+
}
|
|
70
98
|
offset += pageLimit;
|
|
71
99
|
if (!Number.isFinite(total) || offset >= total || pageItems.length === 0) return items;
|
|
72
100
|
}
|
|
73
|
-
throw
|
|
101
|
+
throw paginationError('PAGINATION_LIMIT', `conversation pagination exceeded ${budget} pages`);
|
|
74
102
|
};
|
|
75
103
|
exports.searchGlobal = async ({
|
|
76
104
|
query,
|
|
@@ -186,10 +214,12 @@ exports.getConversationMessagesPage = async ({
|
|
|
186
214
|
exports.findConversationMessage = async ({
|
|
187
215
|
conversationId, messageId, numTurns = 10, includeHasVersions = true,
|
|
188
216
|
token, sessionHeaders, requestJson = direct.requestJson,
|
|
189
|
-
getConversation = exports.getConversation, getMessagesPage = exports.getConversationMessagesPage
|
|
217
|
+
getConversation = exports.getConversation, getMessagesPage = exports.getConversationMessagesPage,
|
|
218
|
+
maxPages = 1000
|
|
190
219
|
}) => {
|
|
191
220
|
if (!conversationId) throw new Error('conversationId is required');
|
|
192
221
|
if (!messageId) throw new Error('messageId is required');
|
|
222
|
+
const budget = pageBudget(maxPages);
|
|
193
223
|
const current = await getConversation({ conversationId, token, sessionHeaders, requestJson });
|
|
194
224
|
let pagesScanned = 1;
|
|
195
225
|
let nodesScanned = Array.isArray(current?.messages) ? current.messages.length : 0;
|
|
@@ -200,6 +230,7 @@ exports.findConversationMessage = async ({
|
|
|
200
230
|
let hasPrevious = pageInfo.has_previous_page === true;
|
|
201
231
|
const seen = new Set();
|
|
202
232
|
while (hasPrevious && before) {
|
|
233
|
+
if (pagesScanned >= budget) throw paginationError('PAGINATION_LIMIT', `conversation message search exceeded ${budget} pages`);
|
|
203
234
|
if (seen.has(before)) throw Object.assign(new Error('conversation history cursor stalled'), { code: 'PAGINATION_STALLED' });
|
|
204
235
|
seen.add(before);
|
|
205
236
|
const page = await getMessagesPage({ conversationId, before, numTurns, includeHasVersions, token, sessionHeaders, requestJson });
|
|
@@ -220,9 +251,11 @@ exports.findConversationMessage = async ({
|
|
|
220
251
|
exports.getConversationHistory = async ({
|
|
221
252
|
conversationId, numTurns = 10, includeHasVersions = true,
|
|
222
253
|
token, sessionHeaders, requestJson = direct.requestJson,
|
|
223
|
-
getConversation = exports.getConversation, getMessagesPage = exports.getConversationMessagesPage
|
|
254
|
+
getConversation = exports.getConversation, getMessagesPage = exports.getConversationMessagesPage,
|
|
255
|
+
maxPages = 1000
|
|
224
256
|
}) => {
|
|
225
257
|
if (!conversationId) throw new Error('conversationId is required');
|
|
258
|
+
const budget = pageBudget(maxPages);
|
|
226
259
|
const current = await getConversation({ conversationId, token, sessionHeaders, requestJson });
|
|
227
260
|
const pages = [{ ...current, kind: 'current_window', before: null }];
|
|
228
261
|
let messageCount = Array.isArray(current?.messages) ? current.messages.length : 0;
|
|
@@ -231,6 +264,7 @@ exports.getConversationHistory = async ({
|
|
|
231
264
|
let hasPrevious = pageInfo.has_previous_page === true;
|
|
232
265
|
const seen = new Set();
|
|
233
266
|
while (hasPrevious && before) {
|
|
267
|
+
if (pages.length >= budget) throw paginationError('PAGINATION_LIMIT', `conversation history exceeded ${budget} pages`);
|
|
234
268
|
if (seen.has(before)) throw Object.assign(new Error('conversation history cursor stalled'), { code: 'PAGINATION_STALLED' });
|
|
235
269
|
seen.add(before);
|
|
236
270
|
const page = await getMessagesPage({ conversationId, before, numTurns, includeHasVersions, token, sessionHeaders, requestJson });
|
package/src/project/index.js
CHANGED
|
@@ -3,4 +3,38 @@
|
|
|
3
3
|
const read = require('./read');
|
|
4
4
|
const write = require('./write');
|
|
5
5
|
|
|
6
|
-
Object.assign(exports, read, write);
|
|
6
|
+
Object.assign(exports, read, write);
|
|
7
|
+
|
|
8
|
+
// Mirror the proven Zero_LLM Project CLI without copying HTTP implementations.
|
|
9
|
+
// All actions use the same canonical Zero SDK session and project owner methods.
|
|
10
|
+
const bindContext = require('../runtime-context').bind;
|
|
11
|
+
const required = (value, name) => {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
13
|
+
throw Object.assign(new Error(name + ' is required'), { code: 'BAD_ARGS' });
|
|
14
|
+
}
|
|
15
|
+
return value.trim();
|
|
16
|
+
};
|
|
17
|
+
exports.list = async (options = {}) => read.listProjects(await bindContext(options));
|
|
18
|
+
exports.get = async (id, options = {}) => read.getProject({
|
|
19
|
+
...(await bindContext(options)), projectId: required(id, 'projectId')
|
|
20
|
+
});
|
|
21
|
+
exports.conversations = async (id, options = {}) => read.listProjectConversations({
|
|
22
|
+
...(await bindContext(options)), projectId: required(id, 'projectId')
|
|
23
|
+
});
|
|
24
|
+
exports.create = async (name, options = {}) => write.createProject({
|
|
25
|
+
...(await bindContext(options)), name: required(name, 'name')
|
|
26
|
+
});
|
|
27
|
+
exports.rename = async (id, name, options = {}) => write.renameProject({
|
|
28
|
+
...(await bindContext(options)), projectId: required(id, 'projectId'), name: required(name, 'name')
|
|
29
|
+
});
|
|
30
|
+
exports.delete = async (id, options = {}) => write.deleteProject({
|
|
31
|
+
...(await bindContext(options)), projectId: required(id, 'projectId')
|
|
32
|
+
});
|
|
33
|
+
exports.save = async (projectId, conversationId, messageId, options = {}) => write.saveProjectMessage({
|
|
34
|
+
...(await bindContext(options)), projectId: required(projectId, 'projectId'),
|
|
35
|
+
conversationId: required(conversationId, 'conversationId'),
|
|
36
|
+
messageId: required(messageId, 'messageId')
|
|
37
|
+
});
|
|
38
|
+
// Keep the legacy LAB's natural command names without duplicating logic.
|
|
39
|
+
exports.new = exports.create;
|
|
40
|
+
exports.del = exports.delete;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
2
|
|
|
3
3
|
const https = require('node:https');
|
|
4
4
|
const fs = require('node:fs');
|
|
@@ -6,6 +6,8 @@ const path = require('node:path');
|
|
|
6
6
|
const paths = require('../../config/paths');
|
|
7
7
|
|
|
8
8
|
const RETRY_AFTER_CAP_MS = 30000;
|
|
9
|
+
const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 30_000;
|
|
10
|
+
const DEFAULT_HTTP_TOTAL_TIMEOUT_MS = 45_000;
|
|
9
11
|
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 30000;
|
|
10
12
|
const MAX_RATE_LIMIT_COOLDOWN_MS = 15 * 60 * 1000;
|
|
11
13
|
|
|
@@ -141,6 +143,14 @@ exports.parseRetryAfterMs = (headers = {}) => {
|
|
|
141
143
|
};
|
|
142
144
|
const safeParse = (text) => { try { return JSON.parse(text); } catch { return null; } };
|
|
143
145
|
const rawRequest = (target, token, sessionHeaders, options, parseBody) => new Promise((resolve, reject) => {
|
|
146
|
+
let settled = false;
|
|
147
|
+
let deadlineTimer = null;
|
|
148
|
+
const finish = (next, value) => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
152
|
+
next(value);
|
|
153
|
+
};
|
|
144
154
|
const payload = options.body === undefined ? null : Buffer.from(JSON.stringify(options.body));
|
|
145
155
|
let headers = exports.buildHeaders(token, sessionHeaders, target, options.route);
|
|
146
156
|
if (options.headers) headers = cleanHeaders({ ...headers, ...options.headers });
|
|
@@ -151,28 +161,47 @@ const rawRequest = (target, token, sessionHeaders, options, parseBody) => new Pr
|
|
|
151
161
|
const aborted = () => {
|
|
152
162
|
const error = new Error(`ChatGPT upstream stream aborted: ${target}`);
|
|
153
163
|
error.code = 'UPSTREAM_ABORTED';
|
|
154
|
-
reject
|
|
164
|
+
finish(reject, error);
|
|
155
165
|
};
|
|
156
166
|
res.on('data', (chunk) => { options.onChunk?.(chunk); chunks.push(chunk); });
|
|
157
167
|
res.on('end', () => {
|
|
158
168
|
ended = true;
|
|
159
169
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
160
170
|
const response = { status: res.statusCode, ...(parseBody ? { json: safeParse(text) } : { text }), contentType: res.headers['content-type'] || null, headers: { ...res.headers } };
|
|
161
|
-
|
|
162
|
-
|
|
171
|
+
try {
|
|
172
|
+
exports.persistResponseHeaders(sessionHeaders, response.headers, options);
|
|
173
|
+
finish(resolve, response);
|
|
174
|
+
} catch (error) { finish(reject, error); }
|
|
163
175
|
});
|
|
164
176
|
res.on('aborted', aborted);
|
|
165
|
-
res.on('error', reject);
|
|
177
|
+
res.on('error', error => finish(reject, error));
|
|
166
178
|
res.on('close', () => { if (!ended) aborted(); });
|
|
167
179
|
});
|
|
168
|
-
req.on('error', reject);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
req.on('error', error => finish(reject, error));
|
|
181
|
+
const idleTimeoutMs = options.idleTimeoutMs === 0 ? 0
|
|
182
|
+
: Number.isFinite(options.idleTimeoutMs) && options.idleTimeoutMs > 0
|
|
183
|
+
? options.idleTimeoutMs : DEFAULT_HTTP_IDLE_TIMEOUT_MS;
|
|
184
|
+
if (idleTimeoutMs && typeof req.setTimeout === 'function') {
|
|
185
|
+
req.setTimeout(idleTimeoutMs, () => {
|
|
186
|
+
const error = new Error(`ChatGPT upstream idle timeout: ${target}`);
|
|
172
187
|
error.code = 'UPSTREAM_IDLE_TIMEOUT';
|
|
173
|
-
req.destroy(error);
|
|
188
|
+
if (typeof req.destroy === 'function') req.destroy(error);
|
|
189
|
+
finish(reject, error);
|
|
174
190
|
});
|
|
175
191
|
}
|
|
192
|
+
// Active SSE streams may run longer than a fixed total deadline; they still
|
|
193
|
+
// have an idle timeout. Ordinary HTTP gets both an idle and a total budget.
|
|
194
|
+
const streaming = typeof options.onChunk === 'function' ||
|
|
195
|
+
/text\/event-stream/i.test(String(options.headers?.accept || ''));
|
|
196
|
+
const totalTimeoutMs = options.totalTimeoutMs === 0 ? 0
|
|
197
|
+
: Number.isFinite(options.totalTimeoutMs) && options.totalTimeoutMs > 0
|
|
198
|
+
? options.totalTimeoutMs : streaming ? 0 : DEFAULT_HTTP_TOTAL_TIMEOUT_MS;
|
|
199
|
+
if (totalTimeoutMs) deadlineTimer = setTimeout(() => {
|
|
200
|
+
const error = new Error(`ChatGPT upstream request deadline exceeded: ${target}`);
|
|
201
|
+
error.code = 'UPSTREAM_TIMEOUT';
|
|
202
|
+
if (typeof req.destroy === 'function') req.destroy(error);
|
|
203
|
+
finish(reject, error);
|
|
204
|
+
}, totalTimeoutMs);
|
|
176
205
|
if (payload) req.write(payload);
|
|
177
206
|
req.end();
|
|
178
207
|
});
|
|
@@ -187,5 +216,4 @@ const guardedRequest = async (target, token, sessionHeaders, options, parseBody)
|
|
|
187
216
|
exports.requestJson = (target, token, sessionHeaders = {}, options = {}) =>
|
|
188
217
|
guardedRequest(target, token, sessionHeaders, options, true);
|
|
189
218
|
exports.requestText = (target, token, sessionHeaders = {}, options = {}) =>
|
|
190
|
-
guardedRequest(target, token, sessionHeaders, options, false);
|
|
191
|
-
|
|
219
|
+
guardedRequest(target, token, sessionHeaders, options, false);
|