dsh-copilot-catalog 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +331 -2
- package/cordis.patch.yml +11 -0
- package/dist/catalog.d.ts +15 -0
- package/dist/catalog.js +191 -0
- package/dist/discovery.d.ts +12 -0
- package/dist/discovery.js +138 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +86 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +30 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +4 -0
- package/package.json +75 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — General discovery and Enterprise routing
|
|
4
|
+
|
|
5
|
+
- Remove the model-ID allowlist and sibling templates. Construct any newly
|
|
6
|
+
discovered chat model from its advertised endpoint, limits, vision, reasoning,
|
|
7
|
+
and billing metadata.
|
|
8
|
+
- Support Responses, Chat Completions, and native Anthropic Messages with
|
|
9
|
+
conservative protocol defaults and deterministic endpoint preference.
|
|
10
|
+
- Stop guessing another model's pricing or reasoning capabilities. Preserve
|
|
11
|
+
upstream descriptors for IDs the bundled catalog already knows.
|
|
12
|
+
- Remove discovery's dependency on a particular model's headers.
|
|
13
|
+
- Derive Enterprise endpoints for OAuth grants and API-key access tokens, honor
|
|
14
|
+
explicit API-key URLs/references, and overlay routing defaults in memory for
|
|
15
|
+
new and bundled models even when listing fails.
|
|
16
|
+
- Add actual DSH-adapter/SDK discovery and inference regressions with synthetic
|
|
17
|
+
Enterprise credentials and SSE responses; no live account calls or secret writes.
|
|
18
|
+
|
|
19
|
+
## 0.1.0 — Initial release preparation
|
|
20
|
+
|
|
21
|
+
- Convert the local Copilot catalog wrapper to strict TypeScript with ESM output
|
|
22
|
+
and generated type declarations.
|
|
23
|
+
- Resolve the original adapter and its own pi-ai catalog using installed npm peers,
|
|
24
|
+
without platform-specific application paths or private OAuth imports.
|
|
25
|
+
- Preserve upstream configuration, credential ownership, and model descriptors.
|
|
26
|
+
- Add two explicit Responses-compatible sibling templates and account filtering.
|
|
27
|
+
- Bound discovery end-to-end and prevent late responses from committing.
|
|
28
|
+
- Harden catalog lookups, HTTP endpoint/redirect handling, and diagnostics.
|
|
29
|
+
- Add DSH bundle metadata, regression tests, peer integration tests, npm package
|
|
30
|
+
allowlist checks, cross-platform CI, and a trusted-publishing release workflow.
|
|
31
|
+
|
|
32
|
+
These entries describe prepared versions; source is available on GitHub, but no
|
|
33
|
+
npm publication is implied.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-copilot-catalog contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,332 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-copilot-catalog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Account-aware **GitHub Copilot model discovery for DeepSeek Harness (DSH)**.
|
|
4
|
+
Written in strict TypeScript; published as ESM JavaScript with type declarations.
|
|
5
|
+
|
|
6
|
+
This experimental, third-party plugin wraps `@deepseek-ai/dsh-llm-pi-ai`. Before the
|
|
7
|
+
adapter mounts, it reads your existing Copilot OAuth grant, fetches your account's
|
|
8
|
+
`/models` catalog, and updates the **same in-memory pi-ai catalog** the adapter uses.
|
|
9
|
+
No hard-coded application paths, credential-file edits, or signed-app modifications.
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
- Shows picker-enabled models whose policy is enabled or absent and which do not
|
|
14
|
+
explicitly reject tool calls.
|
|
15
|
+
- Preserves upstream descriptors for known IDs, including mixed API protocols.
|
|
16
|
+
- Builds descriptors for **any newly discovered model ID**, without a model-name
|
|
17
|
+
allowlist, sibling template, or vendor-name heuristic. Transport selection uses
|
|
18
|
+
the advertised `supported_endpoints`:
|
|
19
|
+
|
|
20
|
+
| Advertised endpoint | pi-ai transport |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `/responses` | `openai-responses` |
|
|
23
|
+
| `/v1/messages` | `anthropic-messages` |
|
|
24
|
+
| `/chat/completions` | `openai-completions` |
|
|
25
|
+
|
|
26
|
+
When multiple supported endpoints are offered, preference is Responses, native
|
|
27
|
+
Messages, then Chat Completions, independent of array order or model name.
|
|
28
|
+
- Uses live positive context/output limits, vision support, advertised reasoning
|
|
29
|
+
efforts, and validated pricing for new models. Only unknown protocols, invalid
|
|
30
|
+
request IDs, non-chat/non-streaming models, or insufficient limits are skipped.
|
|
31
|
+
Missing pricing uses zero-valued **unknown rates**, not another model's prices.
|
|
32
|
+
- Keeps **Enterprise routing for discovery and inference**, including known
|
|
33
|
+
bundled models and API-key auth. A validated account endpoint is applied as an
|
|
34
|
+
in-memory provider default even if the model listing later fails. Explicit
|
|
35
|
+
configured API-key endpoints are preserved; no profile setting is rewritten.
|
|
36
|
+
- Leaves the current catalog untouched if discovery fails or yields no supported
|
|
37
|
+
models. On first startup this means all bundled defaults remain available.
|
|
38
|
+
- Bounds the whole attempt to **10 seconds**, including credential reads, refresh,
|
|
39
|
+
auth resolution, fetch, and body parsing. Timed-out work cannot commit later.
|
|
40
|
+
- Makes no credential-store writes. Expiring grants are refreshed only in memory;
|
|
41
|
+
the original adapter owns normal persisted refresh and its cross-process lock.
|
|
42
|
+
- Requires HTTPS Copilot subdomains, refuses redirects and unusual ports, and
|
|
43
|
+
logs controlled error codes instead of potentially sensitive exception messages.
|
|
44
|
+
|
|
45
|
+
## Compatibility
|
|
46
|
+
|
|
47
|
+
| Component | Supported version |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| DSH and `@deepseek-ai/dsh-llm-pi-ai` | `0.2.0-rc.2` |
|
|
50
|
+
| `@earendil-works/pi-ai` | `0.87.1` |
|
|
51
|
+
| Node.js | `>=22.19.0` |
|
|
52
|
+
| Runtime format | ESM only; no CommonJS entry |
|
|
53
|
+
|
|
54
|
+
DSH and pi-ai are **peer dependencies**, not vendored copies. Runtime resolution
|
|
55
|
+
starts from the adapter's location, so nested or pnpm layouts do not cause the
|
|
56
|
+
plugin to update a different pi-ai instance. Type-only imports are erased by the
|
|
57
|
+
compiler. The public Copilot provider supplies OAuth hooks; private auth modules
|
|
58
|
+
are not imported. Version ranges are deliberately conservative: verify a new DSH
|
|
59
|
+
or pi-ai version before widening them.
|
|
60
|
+
|
|
61
|
+
## Enterprise authentication and endpoint routing
|
|
62
|
+
|
|
63
|
+
This plugin also works around pi-ai's API-key path: an explicit API key bypasses
|
|
64
|
+
OAuth `toAuth()`, so upstream otherwise retains the bundled Individual endpoint.
|
|
65
|
+
The wrapper discovers an account endpoint and supplies it to the original adapter
|
|
66
|
+
as a **runtime-only `baseURL` default**, covering new and bundled models.
|
|
67
|
+
|
|
68
|
+
- **Stored Copilot OAuth grant:** use the provider's public `toAuth()` to derive
|
|
69
|
+
the account endpoint. The original adapter still owns persisted refresh and
|
|
70
|
+
derives request auth normally; Enterprise grants do not become Individual keys.
|
|
71
|
+
- **Copilot access token supplied as an API key:** use its `proxy-ep` metadata to
|
|
72
|
+
derive the endpoint through the same read-only OAuth hook. This applies to stored
|
|
73
|
+
API-key records, explicit `apiKeyEnv` references, and ambient `COPILOT_GITHUB_TOKEN`.
|
|
74
|
+
An explicit reference wins over a different stored OAuth account, as in pi-ai.
|
|
75
|
+
- **Opaque token:** no account endpoint can be inferred safely. Set the existing
|
|
76
|
+
provider `baseURL` field explicitly, or use DSH's normal Copilot OAuth sign-in.
|
|
77
|
+
Discovery reports `MISSING_ENDPOINT` rather than assuming the Individual API.
|
|
78
|
+
- **GitHub PAT:** it is not a Copilot access token. The wrapper does not guess the
|
|
79
|
+
Enterprise GitHub domain or implement a second sign-in/exchange flow; use OAuth.
|
|
80
|
+
|
|
81
|
+
For an explicit Enterprise API-key route, use a credential reference, not a literal
|
|
82
|
+
key in your profile:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- id: llm-pi-ai-catalog
|
|
86
|
+
name: dsh-copilot-catalog
|
|
87
|
+
config:
|
|
88
|
+
providers:
|
|
89
|
+
github-copilot:
|
|
90
|
+
apiKeyEnv: COPILOT_ACCESS_TOKEN
|
|
91
|
+
baseURL: https://api.enterprise.githubcopilot.com
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Use the endpoint belonging to **your** account; do not assume Enterprise seats all
|
|
95
|
+
share a GitHub Enterprise Server domain. Discovery only trusts HTTPS
|
|
96
|
+
`*.githubcopilot.com` origins. Inferred API-key routing is a startup snapshot;
|
|
97
|
+
restart after changing accounts or moving a key to a different endpoint. If no
|
|
98
|
+
endpoint can be identified, the original adapter remains mounted with its original
|
|
99
|
+
configuration, so an opaque Enterprise key still needs an explicit `baseURL`.
|
|
100
|
+
|
|
101
|
+
## Installation
|
|
102
|
+
|
|
103
|
+
> **Adapter replacement, not an add-on adapter.** The included DSH bundle disables
|
|
104
|
+
> the standard `llm-pi-ai` entry and inserts `llm-pi-ai-catalog`, configured for
|
|
105
|
+
> `github-copilot`. Do not enable another adapter for the same provider route.
|
|
106
|
+
>
|
|
107
|
+
> **Existing custom routes/settings are not copied automatically.** Before enabling
|
|
108
|
+
> the bundle, back up your profile patch and copy every provider and override you
|
|
109
|
+
> want to retain to the replacement entry. Its settings namespace is
|
|
110
|
+
> `llm-pi-ai-catalog`; your OAuth grant keeps the original `llm-pi-ai/github-copilot`
|
|
111
|
+
> key. A later profile/home patch that re-enables the original adapter must be fixed.
|
|
112
|
+
|
|
113
|
+
### From npm, after publication
|
|
114
|
+
|
|
115
|
+
Install the bundle through DSH's Plugins page, or use the CLI for your chosen
|
|
116
|
+
profile (for example `desktop` or `web`):
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
dsh plugin --profile <profile> add dsh-copilot-catalog
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Restart DSH after installing or changing this host-side plugin. The code has no
|
|
123
|
+
browser component and does not modify or rebuild DSH's web UI.
|
|
124
|
+
|
|
125
|
+
### From GitHub, after pushing this repository
|
|
126
|
+
|
|
127
|
+
The Git repository includes the TypeScript source and build configuration. Its
|
|
128
|
+
`prepare` script builds the ESM output when the package manager installs from Git.
|
|
129
|
+
Use your actual repository URL in DSH's Plugins page or the CLI:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
dsh plugin --profile <profile> add 'git+https://github.com/1yefuwang1/dsh-copilot-catalog.git'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Local development
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npm ci --ignore-scripts
|
|
139
|
+
npm run build
|
|
140
|
+
npm run verify
|
|
141
|
+
# Only if you choose to enable it in an actual DSH profile:
|
|
142
|
+
dsh plugin --profile <profile> add -w .
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Local linking affects that profile; it is not part of the automated test suite.
|
|
146
|
+
Neither this repository nor its tests automatically edit your active profile.
|
|
147
|
+
|
|
148
|
+
### Preserve additional provider configuration
|
|
149
|
+
|
|
150
|
+
Add a higher-priority override to your own profile patch before enabling the bundle.
|
|
151
|
+
Copy the **full provider configuration** from your original adapter, including
|
|
152
|
+
custom URLs, models, headers and overrides, as needed:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
- id: llm-pi-ai-catalog
|
|
156
|
+
name: dsh-copilot-catalog
|
|
157
|
+
config:
|
|
158
|
+
providers:
|
|
159
|
+
github-copilot: {}
|
|
160
|
+
openai:
|
|
161
|
+
apiKeyEnv: OPENAI_API_KEY
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
All original adapter configuration fields remain valid. The wrapper exports the
|
|
165
|
+
original `Config` schema unchanged; it adds no secret or custom discovery settings.
|
|
166
|
+
An inferred endpoint is overlaid only while the adapter reads the configuration;
|
|
167
|
+
settings persistence, explicit URLs, and other providers are left untouched.
|
|
168
|
+
If your original adapter has a different entry ID, adjust the disabling override
|
|
169
|
+
in your profile accordingly.
|
|
170
|
+
|
|
171
|
+
### Check startup
|
|
172
|
+
|
|
173
|
+
Successful discovery logs `Copilot catalog synced` with the supported model count
|
|
174
|
+
and JSON-escaped unsupported IDs. Failures log one of `TIMEOUT`, `HTTP_ERROR`,
|
|
175
|
+
`INVALID_CATALOG`, `UNTRUSTED_ENDPOINT`, `INVALID_AUTH`, `MISSING_ENDPOINT`,
|
|
176
|
+
`IMMUTABLE_CATALOG`, or `DISCOVERY_FAILED`; startup still delegates to the original
|
|
177
|
+
adapter. Missing Copilot credentials keep the defaults. A validated runtime
|
|
178
|
+
endpoint is logged separately without any token or credential value. Sign in through DSH's normal Copilot authorization
|
|
179
|
+
flow, then restart to perform account discovery.
|
|
180
|
+
|
|
181
|
+
### Rollback
|
|
182
|
+
|
|
183
|
+
Disable or remove this bundle with DSH's Plugins page, remove any profile overrides
|
|
184
|
+
that still enable the replacement, and re-enable the standard `llm-pi-ai` entry if
|
|
185
|
+
your own profile has disabled it. Restore your backed-up provider configuration and
|
|
186
|
+
restart the **whole DSH process** to reset its in-memory catalog. Do not delete the
|
|
187
|
+
OAuth grant. The wrapper does not patch the signed application or store a catalog
|
|
188
|
+
on disk.
|
|
189
|
+
|
|
190
|
+
## Development and tests
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
npm ci --ignore-scripts
|
|
194
|
+
npm run check:secrets # scan Git candidates; never print suspected secret values
|
|
195
|
+
npm run typecheck # strict TypeScript, no emit
|
|
196
|
+
npm run build # dist/*.js plus dist/*.d.ts
|
|
197
|
+
npm test # synthetic unit/lifecycle/security regression tests
|
|
198
|
+
npm run test:integration # real npm peers; no account or network requests
|
|
199
|
+
npm run test:types # a strict TS consumer checks the published declarations
|
|
200
|
+
npm run test:pack # exact npm tarball allowlist, including declarations
|
|
201
|
+
npm run verify # all of the above
|
|
202
|
+
npm pack --dry-run
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The tests use synthetic credentials and response/SSE fixtures. They cover arbitrary
|
|
206
|
+
new IDs, three advertised protocols, prototype-sensitive IDs, reasoning metadata,
|
|
207
|
+
unknown pricing, read-only refresh, endpoint restrictions, deadlines, late responses,
|
|
208
|
+
fail-open delegation, and safe diagnostics. Integration tests mount the **actual DSH
|
|
209
|
+
adapter** and exercise real SDK request construction for OAuth Enterprise grants,
|
|
210
|
+
API-key overrides, stored access tokens, explicit URLs, and failed model listing.
|
|
211
|
+
Every mocked discovery/inference request is asserted to target the Enterprise origin.
|
|
212
|
+
No live Copilot request or authentication flow is run automatically.
|
|
213
|
+
|
|
214
|
+
- [`src/index.ts`](src/index.ts): DSH entry point.
|
|
215
|
+
- [`src/runtime.ts`](src/runtime.ts): shared adapter/pi-ai resolution.
|
|
216
|
+
- [`src/catalog.ts`](src/catalog.ts): pure selection and pricing.
|
|
217
|
+
- [`src/discovery.ts`](src/discovery.ts): bounded read-only discovery.
|
|
218
|
+
- [`src/plugin.ts`](src/plugin.ts): adapter lifecycle and safe logging.
|
|
219
|
+
- [`src/types.ts`](src/types.ts): public interfaces and upstream type integration.
|
|
220
|
+
|
|
221
|
+
For scripts that only need model selection, the `dsh-copilot-catalog/catalog`
|
|
222
|
+
subpath is side-effect-free. Pass an explicit bundled catalog:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { selectAccountModels } from 'dsh-copilot-catalog/catalog';
|
|
226
|
+
import { GITHUB_COPILOT_MODELS } from '@earendil-works/pi-ai/providers/github-copilot.models';
|
|
227
|
+
|
|
228
|
+
const { models, unsupported } = selectAccountModels(response, GITHUB_COPILOT_MODELS);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The package root loads the adapter peers but does not read credentials or perform
|
|
232
|
+
network I/O until `apply()` or `syncCopilotCatalog()` is called. The
|
|
233
|
+
`dsh-copilot-catalog/discovery` helper takes explicit injected dependencies for tests
|
|
234
|
+
and tooling.
|
|
235
|
+
|
|
236
|
+
## Publish to GitHub and npm
|
|
237
|
+
|
|
238
|
+
The package name is `dsh-copilot-catalog`; availability can change before npm
|
|
239
|
+
publication. The GitHub repository is
|
|
240
|
+
[`1yefuwang1/dsh-copilot-catalog`](https://github.com/1yefuwang1/dsh-copilot-catalog),
|
|
241
|
+
and its repository, homepage, and issue-tracker metadata are configured. Personal
|
|
242
|
+
author details remain intentionally unset.
|
|
243
|
+
|
|
244
|
+
1. If publishing a fork, update its repository metadata before publishing:
|
|
245
|
+
|
|
246
|
+
```sh
|
|
247
|
+
npm pkg set 'repository.type=git' \
|
|
248
|
+
'repository.url=git+https://github.com/OWNER/REPOSITORY.git' \
|
|
249
|
+
'homepage=https://github.com/OWNER/REPOSITORY#readme' \
|
|
250
|
+
'bugs.url=https://github.com/OWNER/REPOSITORY/issues'
|
|
251
|
+
# Optional: npm pkg set 'author=Your Name'
|
|
252
|
+
npm install --package-lock-only --ignore-scripts
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
2. Review the MIT license, package name, compatibility pins, bundle replacement,
|
|
256
|
+
and tarball contents. Update the changelog and run `npm run verify`.
|
|
257
|
+
3. Commit and push the source and lockfile. If you have not initialized Git yet:
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
git init -b main
|
|
261
|
+
git add .
|
|
262
|
+
git commit -m 'Initial TypeScript Copilot catalog plugin'
|
|
263
|
+
git remote add origin https://github.com/1yefuwang1/dsh-copilot-catalog.git
|
|
264
|
+
git push -u origin main
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
4. The unclaimed package has no npm settings page yet, so the first GitHub Actions
|
|
268
|
+
release needs a one-time granular npm token. Because the package does not yet
|
|
269
|
+
exist, grant the shortest-lived token the minimum package read/write permission
|
|
270
|
+
that can stage a public package. A stage-only token is sufficient; no 2FA bypass
|
|
271
|
+
is needed. Store it as the `NPM_TOKEN` secret in the GitHub `npm` environment.
|
|
272
|
+
Never commit or paste the token into an issue, log, workflow, or configuration.
|
|
273
|
+
5. Push a matching `vX.Y.Z` tag and publish a GitHub Release for it. The workflow
|
|
274
|
+
validates tag/version and repository metadata, runs the credential audit,
|
|
275
|
+
tests, peer integration, declarations, and tarball allowlist, then runs
|
|
276
|
+
`npm stage publish` from GitHub Actions with provenance. For a new package, npm
|
|
277
|
+
exposes a `0.0.0-stage` placeholder; version contents remain private.
|
|
278
|
+
6. Review `0.2.0` in npm's **Staged Packages** page and approve it with 2FA. Then
|
|
279
|
+
configure npm **Trusted Publisher** for GitHub user `1yefuwang1`, repository
|
|
280
|
+
`dsh-copilot-catalog`, workflow `publish.yml`, environment `npm`, allowing
|
|
281
|
+
`npm stage publish`. Delete the GitHub `NPM_TOKEN` environment secret. Later
|
|
282
|
+
releases authenticate through short-lived GitHub OIDC and still require staged
|
|
283
|
+
review/approval; npm detects OIDC before token fallback.
|
|
284
|
+
|
|
285
|
+
CI runs on Linux, macOS and Windows, on Node 22 and 24. The publish workflow requires
|
|
286
|
+
Node 24/npm 11.5.1+ for trusted publishing and only runs for a published GitHub
|
|
287
|
+
Release, not for pull requests. The workflow stages rather than directly publishes;
|
|
288
|
+
a maintainer approves each release with 2FA. The `NPM_TOKEN` fallback is only for
|
|
289
|
+
the first staging operation and should be deleted after trusted publishing is configured.
|
|
290
|
+
Generated `dist/` files stay out of Git; only the
|
|
291
|
+
compiled runtime, declarations, DSH bundle patch, README, changelog and license
|
|
292
|
+
enter the npm tarball. Review files, tests, dependencies and credentials do not.
|
|
293
|
+
|
|
294
|
+
## Limitations
|
|
295
|
+
|
|
296
|
+
- Discovery is a startup/mount snapshot, not continuous sync. Restart after login,
|
|
297
|
+
account changes, upgrades or rollback. Avoid hot-reloading this wrapper: a
|
|
298
|
+
process-wide catalog mutation can outlive its plugin mount.
|
|
299
|
+
- Account discovery deliberately uses strict picker semantics. Individual accounts
|
|
300
|
+
that mark every picker flag false fall back to bundled defaults; the wrapper
|
|
301
|
+
does not broaden access using policy-only heuristics.
|
|
302
|
+
- A successful sync mutates a shared Copilot catalog in memory. Mount only one
|
|
303
|
+
adapter instance. A failed later mount keeps the current process's catalog, not
|
|
304
|
+
necessarily the original defaults; a full restart resets it.
|
|
305
|
+
- Existing upstream model descriptors are intentionally preserved, not refreshed
|
|
306
|
+
field-by-field. Newly discovered IDs use live metadata and conservative per-protocol
|
|
307
|
+
defaults. Unknown pricing is zero-valued metadata, not a claim that access is free.
|
|
308
|
+
- New models require advertised supported endpoints and valid positive context/output
|
|
309
|
+
limits. No endpoint, family, or protocol is guessed from a model name. Optional
|
|
310
|
+
strict tools, developer-role support, long cache retention and advanced features
|
|
311
|
+
are not assumed; correct a model with the original adapter's `modelOverrides` if
|
|
312
|
+
its gateway needs additional compatibility settings.
|
|
313
|
+
- Only recognized advertised reasoning-effort values are selectable (`none` maps to
|
|
314
|
+
`off`). Absent/unrecognized effort metadata does not invent reasoning settings;
|
|
315
|
+
native Anthropic `thinking: true` without an effort list uses standard budget
|
|
316
|
+
levels. Native Messages effort lists use adaptive thinking. Endpoint metadata is
|
|
317
|
+
not a guarantee that every optional feature works identically on every gateway.
|
|
318
|
+
- Original adapter credential filtering, explicit `models` lists, and
|
|
319
|
+
`modelOverrides` still apply. This plugin neither enables account policies nor
|
|
320
|
+
changes stored `availableModelIds`; an upstream credential allowlist can still
|
|
321
|
+
hide a model until normal refresh/sign-in updates it.
|
|
322
|
+
- Enterprise OAuth endpoints outside HTTPS `*.githubcopilot.com` are rejected by
|
|
323
|
+
discovery; normal adapter behavior remains available as fallback.
|
|
324
|
+
- Upstream catalog structure, protocol compatibility and Copilot billing metadata
|
|
325
|
+
are not stable public contracts. Re-test protocol defaults and Enterprise routing
|
|
326
|
+
after updates. This package is not affiliated with DeepSeek, GitHub, or the pi-ai
|
|
327
|
+
maintainers.
|
|
328
|
+
|
|
329
|
+
## License
|
|
330
|
+
|
|
331
|
+
[MIT](LICENSE). Original DSH and pi-ai packages retain their own licenses; they are
|
|
332
|
+
not redistributed in this tarball.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# This bundle replaces the original adapter; never enable both for the same route.
|
|
2
|
+
# Copy any other provider configuration into the replacement before enabling.
|
|
3
|
+
- id: llm-pi-ai
|
|
4
|
+
name: '@deepseek-ai/dsh-llm-pi-ai'
|
|
5
|
+
disabled: true
|
|
6
|
+
- insert:
|
|
7
|
+
- id: llm-pi-ai-catalog
|
|
8
|
+
name: dsh-copilot-catalog
|
|
9
|
+
config:
|
|
10
|
+
providers:
|
|
11
|
+
github-copilot: {}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModelCost } from '@earendil-works/pi-ai';
|
|
2
|
+
import { type AccountSelection, type ModelCatalog } from './types.js';
|
|
3
|
+
export declare const DEFAULT_COPILOT_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
4
|
+
export interface AccountModelOptions {
|
|
5
|
+
/** Trusted account API root. Startup discovery supplies the validated OAuth origin. */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Copy only provider identity headers; never inherit Authorization or API keys. */
|
|
9
|
+
export declare function copilotHeaders(catalog: ModelCatalog): Record<string, string>;
|
|
10
|
+
/** Convert cents per million tokens only when every required price is valid. */
|
|
11
|
+
export declare function liveCost(billing: unknown, fallback: ModelCost): ModelCost;
|
|
12
|
+
/** Pure selection: preserve known descriptors; build any new ID from endpoint/capability metadata. */
|
|
13
|
+
export declare function selectAccountModels(response: unknown, catalog: ModelCatalog, { baseUrl, }?: AccountModelOptions): AccountSelection;
|
|
14
|
+
/** Validate mutability before touching a shared upstream catalog. */
|
|
15
|
+
export declare function replaceCatalog(catalog: ModelCatalog, models: ModelCatalog): void;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { asRecord } from './types.js';
|
|
2
|
+
export const DEFAULT_COPILOT_BASE_URL = 'https://api.individual.githubcopilot.com';
|
|
3
|
+
// Provider identity defaults match the supported pi-ai Copilot OAuth client.
|
|
4
|
+
// These are protocol defaults, not metadata borrowed from a particular model.
|
|
5
|
+
const DEFAULT_HEADERS = Object.freeze({
|
|
6
|
+
'User-Agent': 'GitHubCopilotChat/0.35.0',
|
|
7
|
+
'Editor-Version': 'vscode/1.107.0',
|
|
8
|
+
'Editor-Plugin-Version': 'copilot-chat/0.35.0',
|
|
9
|
+
'Copilot-Integration-Id': 'vscode-chat',
|
|
10
|
+
});
|
|
11
|
+
const ENDPOINTS = [
|
|
12
|
+
['/responses', 'openai-responses'],
|
|
13
|
+
['/v1/messages', 'anthropic-messages'],
|
|
14
|
+
['/chat/completions', 'openai-completions'],
|
|
15
|
+
];
|
|
16
|
+
const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
17
|
+
const PRICE_PARTS = ['input_price', 'output_price', 'cache_price', 'cache_write_price'];
|
|
18
|
+
const positiveInteger = (value) => Number.isSafeInteger(value) && value > 0;
|
|
19
|
+
const own = (object, key) => Object.hasOwn(object, key) ? object[key] : undefined;
|
|
20
|
+
/** Copy only provider identity headers; never inherit Authorization or API keys. */
|
|
21
|
+
export function copilotHeaders(catalog) {
|
|
22
|
+
const headers = { ...DEFAULT_HEADERS };
|
|
23
|
+
for (const model of Object.values(catalog)) {
|
|
24
|
+
if (model.provider !== 'github-copilot' || !model.headers)
|
|
25
|
+
continue;
|
|
26
|
+
let found = false;
|
|
27
|
+
for (const [name, fallback] of Object.entries(DEFAULT_HEADERS)) {
|
|
28
|
+
const entry = Object.entries(model.headers).find(([key]) => key.toLowerCase() === name.toLowerCase());
|
|
29
|
+
if (entry && entry[1].trim() && !/[\r\n]/.test(entry[1])) {
|
|
30
|
+
headers[name] = entry[1];
|
|
31
|
+
found = true;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
headers[name] = fallback;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (found)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
function validPrices(value) {
|
|
43
|
+
const record = asRecord(value);
|
|
44
|
+
return record !== undefined && PRICE_PARTS.every((key) => typeof record[key] === 'number' &&
|
|
45
|
+
Number.isFinite(record[key]) && record[key] >= 0);
|
|
46
|
+
}
|
|
47
|
+
/** Convert cents per million tokens only when every required price is valid. */
|
|
48
|
+
export function liveCost(billing, fallback) {
|
|
49
|
+
const prices = asRecord(asRecord(billing)?.token_prices);
|
|
50
|
+
const regular = asRecord(prices?.default);
|
|
51
|
+
if (prices?.batch_size !== 1_000_000 || !validPrices(regular))
|
|
52
|
+
return fallback;
|
|
53
|
+
const convert = (value) => ({
|
|
54
|
+
input: value.input_price / 100,
|
|
55
|
+
output: value.output_price / 100,
|
|
56
|
+
cacheRead: value.cache_price / 100,
|
|
57
|
+
cacheWrite: value.cache_write_price / 100,
|
|
58
|
+
});
|
|
59
|
+
const cost = convert(regular);
|
|
60
|
+
if (positiveInteger(regular.context_max) && validPrices(prices.long_context)) {
|
|
61
|
+
cost.tiers = [{ inputTokensAbove: regular.context_max, ...convert(prices.long_context) }];
|
|
62
|
+
}
|
|
63
|
+
return cost;
|
|
64
|
+
}
|
|
65
|
+
function reasoningMetadata(api, supports) {
|
|
66
|
+
const efforts = supports?.reasoning_effort;
|
|
67
|
+
if (Array.isArray(efforts)) {
|
|
68
|
+
const map = {};
|
|
69
|
+
for (const level of THINKING_LEVELS) {
|
|
70
|
+
const wire = level === 'off' && efforts.includes('none') ? 'none' : level;
|
|
71
|
+
map[level] = efforts.includes(wire) ? wire : null;
|
|
72
|
+
}
|
|
73
|
+
const reasoning = THINKING_LEVELS.some((level) => level !== 'off' && typeof map[level] === 'string');
|
|
74
|
+
// A native Messages effort list describes adaptive output_config.effort.
|
|
75
|
+
return { reasoning, thinkingLevelMap: map, adaptiveThinking: api === 'anthropic-messages' && reasoning };
|
|
76
|
+
}
|
|
77
|
+
if (api === 'anthropic-messages' && supports?.thinking === true) {
|
|
78
|
+
// Legacy native thinking advertises a budget rather than effort spellings.
|
|
79
|
+
// Use pi-ai's standard budget levels, without inventing xhigh/max support.
|
|
80
|
+
return { reasoning: true, thinkingLevelMap: { xhigh: null, max: null }, adaptiveThinking: false };
|
|
81
|
+
}
|
|
82
|
+
// Missing/unknown effort metadata must not invent user-selectable reasoning.
|
|
83
|
+
return { reasoning: false, adaptiveThinking: false };
|
|
84
|
+
}
|
|
85
|
+
function protocolCompat(api, reasoning, adaptiveThinking) {
|
|
86
|
+
switch (api) {
|
|
87
|
+
case 'openai-responses': return {
|
|
88
|
+
supportsDeveloperRole: false,
|
|
89
|
+
supportsStrictMode: false,
|
|
90
|
+
supportsLongCacheRetention: false,
|
|
91
|
+
};
|
|
92
|
+
case 'openai-completions': return {
|
|
93
|
+
supportsDeveloperRole: false,
|
|
94
|
+
supportsStrictMode: false,
|
|
95
|
+
supportsStore: false,
|
|
96
|
+
supportsUsageInStreaming: false,
|
|
97
|
+
supportsLongCacheRetention: false,
|
|
98
|
+
supportsReasoningEffort: reasoning,
|
|
99
|
+
thinkingFormat: 'openai',
|
|
100
|
+
};
|
|
101
|
+
case 'anthropic-messages': return {
|
|
102
|
+
forceAdaptiveThinking: adaptiveThinking,
|
|
103
|
+
supportsStrictTools: false,
|
|
104
|
+
supportsTemperature: false,
|
|
105
|
+
supportsLongCacheRetention: false,
|
|
106
|
+
supportsCacheControlOnTools: false,
|
|
107
|
+
supportsEagerToolInputStreaming: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function createNewModel(item, headers, baseUrl) {
|
|
112
|
+
// IDs are opaque; reject malformed request IDs, never infer a vendor/family.
|
|
113
|
+
const endpoints = item.supported_endpoints;
|
|
114
|
+
if (/[\s\u0000-\u001f\u007f]/u.test(item.id) || !Array.isArray(endpoints))
|
|
115
|
+
return undefined;
|
|
116
|
+
const api = ENDPOINTS.find(([endpoint]) => endpoints.includes(endpoint))?.[1];
|
|
117
|
+
if (!api)
|
|
118
|
+
return undefined;
|
|
119
|
+
const capabilities = asRecord(item.capabilities);
|
|
120
|
+
if (capabilities?.type !== undefined && capabilities.type !== 'chat')
|
|
121
|
+
return undefined;
|
|
122
|
+
const limits = asRecord(capabilities?.limits);
|
|
123
|
+
if (!positiveInteger(limits?.max_context_window_tokens) || !positiveInteger(limits.max_output_tokens) ||
|
|
124
|
+
limits.max_output_tokens > limits.max_context_window_tokens)
|
|
125
|
+
return undefined;
|
|
126
|
+
const supports = asRecord(capabilities?.supports);
|
|
127
|
+
if (supports?.streaming === false)
|
|
128
|
+
return undefined;
|
|
129
|
+
const { reasoning, thinkingLevelMap, adaptiveThinking } = reasoningMetadata(api, supports);
|
|
130
|
+
return {
|
|
131
|
+
id: item.id,
|
|
132
|
+
name: typeof item.name === 'string' && item.name.trim() ? item.name : item.id,
|
|
133
|
+
api,
|
|
134
|
+
provider: 'github-copilot',
|
|
135
|
+
baseUrl,
|
|
136
|
+
contextWindow: limits.max_context_window_tokens,
|
|
137
|
+
maxTokens: limits.max_output_tokens,
|
|
138
|
+
input: supports?.vision === true ? ['text', 'image'] : ['text'],
|
|
139
|
+
// Unknown pricing is represented by zero, never a guessed sibling's rates.
|
|
140
|
+
cost: liveCost(item.billing, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }),
|
|
141
|
+
reasoning,
|
|
142
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
143
|
+
compat: protocolCompat(api, reasoning, adaptiveThinking),
|
|
144
|
+
headers: { ...headers },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Pure selection: preserve known descriptors; build any new ID from endpoint/capability metadata. */
|
|
148
|
+
export function selectAccountModels(response, catalog, { baseUrl = DEFAULT_COPILOT_BASE_URL, } = {}) {
|
|
149
|
+
const data = asRecord(response)?.data;
|
|
150
|
+
if (!Array.isArray(data))
|
|
151
|
+
throw new Error('Invalid Copilot /models response');
|
|
152
|
+
if (!catalog || typeof catalog !== 'object')
|
|
153
|
+
throw new TypeError('A bundled model catalog is required');
|
|
154
|
+
const headers = copilotHeaders(catalog);
|
|
155
|
+
const models = Object.create(null);
|
|
156
|
+
const unsupported = new Set();
|
|
157
|
+
for (const raw of data) {
|
|
158
|
+
const item = asRecord(raw);
|
|
159
|
+
const policy = asRecord(item?.policy);
|
|
160
|
+
const supports = asRecord(asRecord(item?.capabilities)?.supports);
|
|
161
|
+
if (!item || typeof item.id !== 'string' || !item.id.trim() || item.model_picker_enabled !== true ||
|
|
162
|
+
(policy?.state !== undefined && policy.state !== 'enabled') || supports?.tool_calls === false ||
|
|
163
|
+
Object.hasOwn(models, item.id))
|
|
164
|
+
continue;
|
|
165
|
+
// Own-property lookup prevents Object.prototype members becoming descriptors.
|
|
166
|
+
const model = own(catalog, item.id) ?? createNewModel({ ...item, id: item.id }, headers, baseUrl);
|
|
167
|
+
if (model) {
|
|
168
|
+
models[item.id] = model;
|
|
169
|
+
unsupported.delete(item.id);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
unsupported.add(item.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(models).length === 0)
|
|
176
|
+
throw new Error('No supported enabled Copilot models found');
|
|
177
|
+
return { models, unsupported: [...unsupported] };
|
|
178
|
+
}
|
|
179
|
+
/** Validate mutability before touching a shared upstream catalog. */
|
|
180
|
+
export function replaceCatalog(catalog, models) {
|
|
181
|
+
const descriptors = Object.getOwnPropertyDescriptors(catalog);
|
|
182
|
+
if (!Object.isExtensible(catalog) || Object.values(descriptors).some((value) => !value.configurable)) {
|
|
183
|
+
throw new Error('The upstream Copilot catalog is not mutable');
|
|
184
|
+
}
|
|
185
|
+
// Plain data properties prevent setters and prototype-sensitive assignment.
|
|
186
|
+
for (const key of Object.keys(descriptors))
|
|
187
|
+
delete catalog[key];
|
|
188
|
+
for (const [key, value] of Object.entries(models)) {
|
|
189
|
+
Object.defineProperty(catalog, key, { value, enumerable: true, configurable: true, writable: true });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type CredentialReader, type DiscoveryOptions, type SyncResult } from './types.js';
|
|
2
|
+
export declare const VERSION_HEADER = "2026-06-01";
|
|
3
|
+
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
4
|
+
export declare class DiscoveryError extends Error {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly name = "DiscoveryError";
|
|
7
|
+
constructor(code: string, message: string);
|
|
8
|
+
}
|
|
9
|
+
/** Do not forward credentials to redirects, unrelated hosts, or unusual ports. */
|
|
10
|
+
export declare function discoveryUrl(baseUrl: unknown): URL;
|
|
11
|
+
/** One read-only, bounded startup discovery. All dependencies are explicit for tests. */
|
|
12
|
+
export declare function syncCopilotCatalog(credentials: CredentialReader | undefined, { catalog, oauth, recordKey, fetcher, timeoutMs, now, baseUrl, resolveApiKey, resolveAmbientApiKey, onEndpoint, }: DiscoveryOptions): Promise<SyncResult>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { copilotHeaders, replaceCatalog, selectAccountModels } from './catalog.js';
|
|
2
|
+
import { asRecord } from './types.js';
|
|
3
|
+
export const VERSION_HEADER = '2026-06-01';
|
|
4
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
5
|
+
export class DiscoveryError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
name = 'DiscoveryError';
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function oauthGrant(record) {
|
|
14
|
+
const entry = asRecord(record);
|
|
15
|
+
const grant = entry?.kind === 'grant' ? asRecord(entry.payload) : undefined;
|
|
16
|
+
return grant?.type === 'oauth' && typeof grant.refresh === 'string' && grant.refresh.trim() &&
|
|
17
|
+
typeof grant.access === 'string' && grant.access.trim() && typeof grant.expires === 'number' && Number.isFinite(grant.expires)
|
|
18
|
+
? structuredClone(grant) : undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Do not forward credentials to redirects, unrelated hosts, or unusual ports. */
|
|
21
|
+
export function discoveryUrl(baseUrl) {
|
|
22
|
+
let base;
|
|
23
|
+
try {
|
|
24
|
+
if (typeof baseUrl !== 'string')
|
|
25
|
+
throw new TypeError();
|
|
26
|
+
base = new URL(baseUrl);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new DiscoveryError('UNTRUSTED_ENDPOINT', 'Unexpected Copilot API endpoint');
|
|
30
|
+
}
|
|
31
|
+
if (base.protocol !== 'https:' || !base.hostname.endsWith('.githubcopilot.com') ||
|
|
32
|
+
base.username || base.password || (base.port && base.port !== '443')) {
|
|
33
|
+
throw new DiscoveryError('UNTRUSTED_ENDPOINT', 'Unexpected Copilot API endpoint');
|
|
34
|
+
}
|
|
35
|
+
return new URL('/models', base);
|
|
36
|
+
}
|
|
37
|
+
/** One read-only, bounded startup discovery. All dependencies are explicit for tests. */
|
|
38
|
+
export async function syncCopilotCatalog(credentials, { catalog, oauth, recordKey, fetcher = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, now = Date.now, baseUrl, resolveApiKey, resolveAmbientApiKey, onEndpoint, }) {
|
|
39
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
|
|
40
|
+
throw new TypeError('timeoutMs must be an integer between 1 and 60000');
|
|
41
|
+
}
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const { signal } = controller;
|
|
44
|
+
// Race the entire operation, including credential reads and OAuth hooks that
|
|
45
|
+
// might not cooperate with abort. A late response may never commit changes.
|
|
46
|
+
let timer;
|
|
47
|
+
const timeout = new Promise((_, reject) => {
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
const error = new DiscoveryError('TIMEOUT', 'Copilot discovery timed out');
|
|
50
|
+
controller.abort(error);
|
|
51
|
+
reject(error);
|
|
52
|
+
}, timeoutMs);
|
|
53
|
+
});
|
|
54
|
+
const work = async () => {
|
|
55
|
+
const record = resolveApiKey ? undefined : await credentials?.readRecord(recordKey);
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
let credential = resolveApiKey ? undefined : oauthGrant(record);
|
|
58
|
+
let apiKey;
|
|
59
|
+
let url;
|
|
60
|
+
const adoptEndpoint = (value) => {
|
|
61
|
+
signal.throwIfAborted();
|
|
62
|
+
const endpoint = discoveryUrl(value);
|
|
63
|
+
onEndpoint?.(endpoint.origin);
|
|
64
|
+
return endpoint;
|
|
65
|
+
};
|
|
66
|
+
if (credential) {
|
|
67
|
+
// Capture routing before refresh/listing: fallback must not send an
|
|
68
|
+
// Enterprise account to the bundled Individual endpoint.
|
|
69
|
+
let auth = await oauth.toAuth(credential);
|
|
70
|
+
url = adoptEndpoint(auth.baseUrl);
|
|
71
|
+
if (now() + 5 * 60_000 >= credential.expires) {
|
|
72
|
+
// No credential-store writes. The original adapter owns locked refresh.
|
|
73
|
+
credential = await oauth.refresh(credential, signal);
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
auth = await oauth.toAuth(credential);
|
|
76
|
+
url = adoptEndpoint(auth.baseUrl);
|
|
77
|
+
}
|
|
78
|
+
apiKey = auth.apiKey;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const entry = asRecord(record);
|
|
82
|
+
const storedKey = entry?.kind === 'api-key' && typeof entry.key === 'string' ? entry.key : undefined;
|
|
83
|
+
apiKey = resolveApiKey ? await resolveApiKey()
|
|
84
|
+
: storedKey ?? (entry?.kind === 'grant' ? undefined : await resolveAmbientApiKey?.());
|
|
85
|
+
signal.throwIfAborted();
|
|
86
|
+
if (typeof apiKey !== 'string' || !apiKey.trim())
|
|
87
|
+
return { status: 'no Copilot credential' };
|
|
88
|
+
if (baseUrl !== undefined) {
|
|
89
|
+
url = adoptEndpoint(baseUrl);
|
|
90
|
+
}
|
|
91
|
+
else if (/(?:^|;)proxy-ep=[^;]+/.test(apiKey)) {
|
|
92
|
+
// toAuth is read-only. It can decode an access token's proxy-ep without
|
|
93
|
+
// requiring a refresh token or synthesizing a persisted OAuth grant.
|
|
94
|
+
const auth = await oauth.toAuth({ type: 'oauth', access: apiKey, refresh: '', expires: 0 });
|
|
95
|
+
url = adoptEndpoint(auth.baseUrl);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
throw new DiscoveryError('MISSING_ENDPOINT', 'API-key auth requires a Copilot proxy endpoint or explicit baseURL');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
signal.throwIfAborted();
|
|
102
|
+
if (typeof apiKey !== 'string' || !apiKey.trim()) {
|
|
103
|
+
throw new DiscoveryError('INVALID_AUTH', 'No usable Copilot API token');
|
|
104
|
+
}
|
|
105
|
+
const headers = new Headers(copilotHeaders(catalog));
|
|
106
|
+
headers.set('Accept', 'application/json');
|
|
107
|
+
headers.set('Authorization', `Bearer ${apiKey}`);
|
|
108
|
+
headers.set('X-GitHub-Api-Version', VERSION_HEADER);
|
|
109
|
+
const response = await fetcher(url, { headers, signal, redirect: 'error' });
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new DiscoveryError('HTTP_ERROR', `Copilot model discovery returned HTTP ${response.status}`);
|
|
113
|
+
}
|
|
114
|
+
const body = await response.json();
|
|
115
|
+
signal.throwIfAborted();
|
|
116
|
+
let selected;
|
|
117
|
+
try {
|
|
118
|
+
selected = selectAccountModels(body, catalog, { baseUrl: url.origin });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new DiscoveryError('INVALID_CATALOG', 'No valid supported account catalog');
|
|
122
|
+
}
|
|
123
|
+
signal.throwIfAborted();
|
|
124
|
+
try {
|
|
125
|
+
replaceCatalog(catalog, selected.models);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new DiscoveryError('IMMUTABLE_CATALOG', 'The upstream Copilot catalog is not mutable');
|
|
129
|
+
}
|
|
130
|
+
return { status: 'synced', count: Object.keys(selected.models).length, unsupported: selected.unsupported };
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
return await Promise.race([work(), timeout]);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CopilotAuthOptions, CredentialReader, OriginalAdapter, PluginConfig, PluginContext } from './types.js';
|
|
2
|
+
export declare const name = "llm-pi-ai";
|
|
3
|
+
export declare const Config: OriginalAdapter['Config'];
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
export declare const apply: (ctx: PluginContext, config: PluginConfig) => Promise<void>;
|
|
6
|
+
export declare const syncCopilotCatalog: (credentials?: CredentialReader, authOptions?: CopilotAuthOptions) => Promise<import("./types.js").SyncResult>;
|
|
7
|
+
export { selectAccountModels } from './catalog.js';
|
|
8
|
+
export type { AccountModelOptions } from './catalog.js';
|
|
9
|
+
export type { AccountSelection, CopilotAuthOptions, CredentialReader, ModelCatalog, SyncResult } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createPlugin } from './plugin.js';
|
|
2
|
+
import { loadRuntime } from './runtime.js';
|
|
3
|
+
const plugin = createPlugin(await loadRuntime());
|
|
4
|
+
export const name = plugin.name;
|
|
5
|
+
export const Config = plugin.Config;
|
|
6
|
+
export const inject = plugin.inject;
|
|
7
|
+
export const apply = (ctx, config) => plugin.apply(ctx, config);
|
|
8
|
+
export const syncCopilotCatalog = (credentials, authOptions) => plugin.sync(credentials, authOptions);
|
|
9
|
+
export { selectAccountModels } from './catalog.js';
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CatalogPlugin, PluginConfig, PluginDependencies } from './types.js';
|
|
2
|
+
/** Add an account endpoint as a runtime default, never as a persisted setting. */
|
|
3
|
+
export declare function withCopilotEndpoint(config: PluginConfig, endpoint: string | undefined): PluginConfig;
|
|
4
|
+
/** Keep integration separate from peer loading so lifecycle behavior is testable. */
|
|
5
|
+
export declare function createPlugin({ original, catalog, oauth, fetcher, timeoutMs }: PluginDependencies): CatalogPlugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
2
|
+
import { DiscoveryError, syncCopilotCatalog } from './discovery.js';
|
|
3
|
+
/** Add an account endpoint as a runtime default, never as a persisted setting. */
|
|
4
|
+
export function withCopilotEndpoint(config, endpoint) {
|
|
5
|
+
if (!endpoint)
|
|
6
|
+
return config;
|
|
7
|
+
const source = config.providers;
|
|
8
|
+
let previous;
|
|
9
|
+
let resolved;
|
|
10
|
+
const get = () => {
|
|
11
|
+
const raw = source.get();
|
|
12
|
+
if (raw === previous && resolved !== undefined)
|
|
13
|
+
return resolved;
|
|
14
|
+
const profile = raw['github-copilot'];
|
|
15
|
+
previous = raw;
|
|
16
|
+
// Explicit user baseURL wins. Do not overwrite other providers or settings.
|
|
17
|
+
resolved = !profile || profile.baseURL !== undefined && profile.baseURL !== null
|
|
18
|
+
? raw : Object.freeze({ ...raw, 'github-copilot': Object.freeze({ ...profile, baseURL: endpoint }) });
|
|
19
|
+
return resolved;
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
...config,
|
|
23
|
+
// Volatile references are frozen. Copy their protocol (including symbols)
|
|
24
|
+
// into a detached facade instead of violating frozen-property Proxy rules.
|
|
25
|
+
providers: Object.freeze({ ...source, get }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Keep integration separate from peer loading so lifecycle behavior is testable. */
|
|
29
|
+
export function createPlugin({ original, catalog, oauth, fetcher, timeoutMs }) {
|
|
30
|
+
if (typeof original?.apply !== 'function' || typeof original.recordKeyFor !== 'function' ||
|
|
31
|
+
!original.Config || !Array.isArray(original.inject) || !catalog ||
|
|
32
|
+
typeof oauth?.refresh !== 'function' || typeof oauth.toAuth !== 'function') {
|
|
33
|
+
throw new Error('Incompatible DSH/pi-ai runtime; see the supported versions in README');
|
|
34
|
+
}
|
|
35
|
+
let accountEndpoint;
|
|
36
|
+
const plugin = {
|
|
37
|
+
name: original.name,
|
|
38
|
+
Config: original.Config,
|
|
39
|
+
inject: [...new Set([...original.inject, 'credentials'])],
|
|
40
|
+
sync: (credentials, authOptions) => {
|
|
41
|
+
accountEndpoint = undefined;
|
|
42
|
+
return syncCopilotCatalog(credentials, {
|
|
43
|
+
catalog, oauth, recordKey: original.recordKeyFor('github-copilot'), fetcher, timeoutMs,
|
|
44
|
+
...authOptions,
|
|
45
|
+
onEndpoint: (endpoint) => { accountEndpoint = endpoint; },
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
async apply(ctx, config) {
|
|
49
|
+
try {
|
|
50
|
+
const credentials = ctx.get('credentials');
|
|
51
|
+
const profile = config.providers.get()['github-copilot'];
|
|
52
|
+
const ref = profile?.apiKeyEnv;
|
|
53
|
+
const readReference = async (value) => {
|
|
54
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
55
|
+
throw new DiscoveryError('INVALID_AUTH', 'Invalid credential reference');
|
|
56
|
+
}
|
|
57
|
+
return (await credentials?.resolve(value))?.value;
|
|
58
|
+
};
|
|
59
|
+
const authOptions = {
|
|
60
|
+
...(typeof profile?.baseURL === 'string' ? { baseUrl: profile.baseURL } : {}),
|
|
61
|
+
...(typeof ref === 'string' ? { resolveApiKey: () => readReference(ref) } : {}),
|
|
62
|
+
resolveAmbientApiKey: async () => (await readReference('COPILOT_GITHUB_TOKEN'))
|
|
63
|
+
?? launchEnvironmentOf(ctx).get('COPILOT_GITHUB_TOKEN')?.value,
|
|
64
|
+
};
|
|
65
|
+
const result = await plugin.sync(credentials, authOptions);
|
|
66
|
+
if (result.status === 'synced') {
|
|
67
|
+
// JSON quoting prevents model IDs from injecting log lines.
|
|
68
|
+
ctx.logger?.info?.('Copilot catalog synced: %d enabled models; unsupported new IDs: %s', result.count, JSON.stringify(result.unsupported));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
ctx.logger?.info?.('Copilot catalog kept bundled defaults: %s', result.status);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// Arbitrary OAuth/network/credential errors may include tokens or bodies.
|
|
76
|
+
// Only our controlled code is safe to log; never log the raw exception.
|
|
77
|
+
const reason = error instanceof DiscoveryError ? error.code : 'DISCOVERY_FAILED';
|
|
78
|
+
ctx.logger?.warn?.('Copilot model discovery failed; keeping current catalog: %s', reason);
|
|
79
|
+
}
|
|
80
|
+
if (accountEndpoint)
|
|
81
|
+
ctx.logger?.info?.('Copilot runtime endpoint: %s', accountEndpoint);
|
|
82
|
+
return original.apply(ctx, withCopilotEndpoint(config, accountEndpoint));
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return plugin;
|
|
86
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createRequire, findPackageJSON } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
/** Resolve pi-ai from the adapter, not the plugin: they must share one catalog. */
|
|
5
|
+
export async function loadRuntime() {
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
let adapterEntry;
|
|
8
|
+
try {
|
|
9
|
+
adapterEntry = require.resolve('@deepseek-ai/dsh-llm-pi-ai');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error('dsh-copilot-catalog requires @deepseek-ai/dsh-llm-pi-ai@0.2.0-rc.2; install it in the same profile');
|
|
13
|
+
}
|
|
14
|
+
const adapterUrl = pathToFileURL(adapterEntry);
|
|
15
|
+
const piManifest = findPackageJSON('@earendil-works/pi-ai', adapterUrl);
|
|
16
|
+
if (!piManifest)
|
|
17
|
+
throw new Error('Cannot resolve the DSH adapter\'s pi-ai dependency');
|
|
18
|
+
const piRoot = dirname(piManifest);
|
|
19
|
+
// These are the public providers/* export targets in supported pi-ai 0.87.1.
|
|
20
|
+
// No private OAuth module or platform-specific application path is imported.
|
|
21
|
+
const [original, { GITHUB_COPILOT_MODELS: catalog }, { githubCopilotProvider }] = await Promise.all([
|
|
22
|
+
import(adapterUrl.href),
|
|
23
|
+
import(pathToFileURL(join(piRoot, 'dist/providers/github-copilot.models.js')).href),
|
|
24
|
+
import(pathToFileURL(join(piRoot, 'dist/providers/github-copilot.js')).href),
|
|
25
|
+
]);
|
|
26
|
+
const oauth = githubCopilotProvider().auth?.oauth;
|
|
27
|
+
if (!oauth)
|
|
28
|
+
throw new Error('The installed pi-ai Copilot provider has no OAuth hooks');
|
|
29
|
+
return { original, catalog: catalog, oauth };
|
|
30
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Api, Model, OAuthAuth, OAuthCredential } from '@earendil-works/pi-ai';
|
|
2
|
+
export type OriginalAdapter = Pick<typeof import('@deepseek-ai/dsh-llm-pi-ai'), 'name' | 'Config' | 'inject' | 'apply' | 'recordKeyFor'>;
|
|
3
|
+
export type PluginContext = Parameters<OriginalAdapter['apply']>[0];
|
|
4
|
+
export type PluginConfig = Parameters<OriginalAdapter['apply']>[1];
|
|
5
|
+
export type CredentialKey = ReturnType<OriginalAdapter['recordKeyFor']>;
|
|
6
|
+
export type ModelCatalog = Record<string, Model<Api>>;
|
|
7
|
+
export type CopilotOAuth = Pick<OAuthAuth, 'refresh' | 'toAuth'>;
|
|
8
|
+
export type CopilotGrant = OAuthCredential;
|
|
9
|
+
/** Only the read seam is accepted; discovery has no credential write capability. */
|
|
10
|
+
export interface CredentialReader {
|
|
11
|
+
readRecord(key: CredentialKey): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface AccountSelection {
|
|
14
|
+
models: ModelCatalog;
|
|
15
|
+
unsupported: string[];
|
|
16
|
+
}
|
|
17
|
+
export type SyncResult = {
|
|
18
|
+
status: 'no Copilot credential';
|
|
19
|
+
} | {
|
|
20
|
+
status: 'synced';
|
|
21
|
+
count: number;
|
|
22
|
+
unsupported: string[];
|
|
23
|
+
};
|
|
24
|
+
export interface CopilotAuthOptions {
|
|
25
|
+
/** Explicit API-key route endpoint; OAuth grants still own their derived endpoint. */
|
|
26
|
+
baseUrl?: string;
|
|
27
|
+
/** An explicit apiKeyEnv reference wins over the stored account grant, as in pi-ai. */
|
|
28
|
+
resolveApiKey?: () => Promise<string | undefined>;
|
|
29
|
+
/** Read-only ambient lookup, consulted only when no OAuth grant owns the route. */
|
|
30
|
+
resolveAmbientApiKey?: () => Promise<string | undefined>;
|
|
31
|
+
}
|
|
32
|
+
export interface DiscoveryOptions extends CopilotAuthOptions {
|
|
33
|
+
catalog: ModelCatalog;
|
|
34
|
+
oauth: CopilotOAuth;
|
|
35
|
+
recordKey: CredentialKey;
|
|
36
|
+
fetcher?: typeof globalThis.fetch;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
/** Routing may adopt a validated account endpoint even if model listing later fails. */
|
|
40
|
+
onEndpoint?: (baseUrl: string) => void;
|
|
41
|
+
}
|
|
42
|
+
export interface PluginDependencies extends Omit<DiscoveryOptions, 'recordKey'> {
|
|
43
|
+
original: OriginalAdapter;
|
|
44
|
+
}
|
|
45
|
+
export interface CatalogPlugin {
|
|
46
|
+
name: OriginalAdapter['name'];
|
|
47
|
+
Config: OriginalAdapter['Config'];
|
|
48
|
+
inject: string[];
|
|
49
|
+
sync(credentials?: CredentialReader, authOptions?: CopilotAuthOptions): Promise<SyncResult>;
|
|
50
|
+
apply(ctx: PluginContext, config: PluginConfig): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
export declare function asRecord(value: unknown): Record<string, unknown> | undefined;
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-copilot-catalog",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Account-aware GitHub Copilot model discovery for DeepSeek Harness, wrapping the existing pi-ai adapter.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
10
|
+
"./catalog": { "types": "./dist/catalog.d.ts", "import": "./dist/catalog.js" },
|
|
11
|
+
"./discovery": { "types": "./dist/discovery.d.ts", "import": "./dist/discovery.js" },
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
24
|
+
"test": "npm run build && node --test test/catalog.test.mjs test/discovery.test.mjs test/plugin.test.mjs test/secrets.test.mjs",
|
|
25
|
+
"check": "npm run typecheck && node scripts/check.mjs",
|
|
26
|
+
"verify": "npm run check:secrets && npm run check && npm test && npm run test:integration && npm run test:types && npm run test:pack",
|
|
27
|
+
"check:secrets": "node scripts/check-secrets.mjs",
|
|
28
|
+
"test:types": "npm run build && tsc -p test/types/tsconfig.json",
|
|
29
|
+
"test:integration": "npm run build && node --test test/integration/runtime.test.mjs test/integration/enterprise.test.mjs",
|
|
30
|
+
"test:pack": "npm run build && node scripts/check-pack.mjs",
|
|
31
|
+
"prepare": "npm run build",
|
|
32
|
+
"prepublishOnly": "npm run verify"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"deepseek-harness",
|
|
36
|
+
"dsh",
|
|
37
|
+
"cordis-plugin",
|
|
38
|
+
"github-copilot",
|
|
39
|
+
"model-catalog",
|
|
40
|
+
"oauth"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/1yefuwang1/dsh-copilot-catalog.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/1yefuwang1/dsh-copilot-catalog#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/1yefuwang1/dsh-copilot-catalog/issues"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.19.0",
|
|
53
|
+
"dsh": "0.2.0-rc.2"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@deepseek-ai/dsh-llm-pi-ai": "0.2.0-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2",
|
|
62
|
+
"@earendil-works/pi-ai": "0.87.1"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@deepseek-ai/dsh-llm-pi-ai": "0.2.0-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2",
|
|
67
|
+
"@earendil-works/pi-ai": "0.87.1",
|
|
68
|
+
"@types/node": "^22.19.0",
|
|
69
|
+
"typescript": "~5.9.3"
|
|
70
|
+
},
|
|
71
|
+
"dsh": {
|
|
72
|
+
"manifestVersion": 1,
|
|
73
|
+
"bundle": {
|
|
74
|
+
"patch": "./cordis.patch.yml"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|