dsh-better-copilot 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/cordis.patch.yml +21 -0
- package/dist/auth.d.ts +31 -0
- package/dist/auth.js +232 -0
- package/dist/catalog-types.d.ts +54 -0
- package/dist/catalog-types.js +4 -0
- package/dist/catalog.d.ts +15 -0
- package/dist/catalog.js +191 -0
- package/dist/config.d.ts +122 -0
- package/dist/config.js +51 -0
- package/dist/discovery.d.ts +12 -0
- package/dist/discovery.js +143 -0
- package/dist/endpoint.d.ts +7 -0
- package/dist/endpoint.js +31 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.js +53 -0
- package/dist/guard-types.d.ts +15 -0
- package/dist/guard-types.js +1 -0
- package/dist/guard.d.ts +3 -0
- package/dist/guard.js +1 -0
- package/dist/identity.d.ts +61 -0
- package/dist/identity.js +276 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +17 -0
- package/dist/plugin.d.ts +37 -0
- package/dist/plugin.js +191 -0
- package/dist/provider.d.ts +10 -0
- package/dist/provider.js +275 -0
- package/dist/refresh.d.ts +13 -0
- package/dist/refresh.js +215 -0
- package/dist/responses.d.ts +43 -0
- package/dist/responses.js +423 -0
- package/dist/runtime.d.ts +25 -0
- package/dist/runtime.js +75 -0
- package/dist/schema.d.ts +28 -0
- package/dist/schema.js +66 -0
- package/dist/search-types.d.ts +40 -0
- package/dist/search-types.js +1 -0
- package/dist/search.d.ts +10 -0
- package/dist/search.js +6 -0
- package/package.json +111 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — consolidated Copilot plugin
|
|
4
|
+
|
|
5
|
+
First release of `dsh-better-copilot`, merging three independently installable
|
|
6
|
+
plugins into one package with one bundle row (`better-copilot`) and one combined
|
|
7
|
+
configuration object:
|
|
8
|
+
|
|
9
|
+
- `dsh-copilot-catalog@0.2.9` — account-aware model discovery, Copilot wire
|
|
10
|
+
identity and the pi-ai adapter wrapper.
|
|
11
|
+
- `dsh-copilot-search@0.1.5` — the `github-copilot-search` Responses web-search
|
|
12
|
+
provider.
|
|
13
|
+
- `dsh-copilot-tool-schema@0.1.3` — the Copilot Gemini tool-declaration guard.
|
|
14
|
+
|
|
15
|
+
Behaviour is preserved:
|
|
16
|
+
|
|
17
|
+
- the wrapper still disables and replaces `llm-pi-ai`, still preserves the
|
|
18
|
+
adapter's own provider schema, and still installs the wire-identity
|
|
19
|
+
interceptor best-effort;
|
|
20
|
+
- search still resolves credentials through the adapter's public pi-ai surface,
|
|
21
|
+
still keeps a search-owned guarded refresh clone, and still refuses untrusted
|
|
22
|
+
origins and redirects;
|
|
23
|
+
- the guard still only adds a missing root `properties` bucket for configured
|
|
24
|
+
provider/model families and leaves every other declaration untouched.
|
|
25
|
+
|
|
26
|
+
Changed:
|
|
27
|
+
|
|
28
|
+
- one plugin row and one config object: `providers` (unchanged adapter schema),
|
|
29
|
+
`search` (was the search plugin's row config) and `toolSchema` (was the guard's
|
|
30
|
+
row config);
|
|
31
|
+
- subpath exports are now `.`, `./catalog`, `./discovery`, `./identity`,
|
|
32
|
+
`./search` and `./guard`; the predecessor subpaths are removed;
|
|
33
|
+
- the colliding `CopilotAuthOptions` type is exported as `CatalogAuthOptions`
|
|
34
|
+
and `SearchAuthOptions`;
|
|
35
|
+
- a missing `web` service, a throwing guard registration, a throwing search
|
|
36
|
+
registration and a throwing identity installer are contained: they degrade
|
|
37
|
+
only their own capability instead of failing the mount.
|
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,132 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-better-copilot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One DeepSeek Harness plugin for GitHub Copilot:
|
|
4
|
+
|
|
5
|
+
- **Account-aware model discovery** — reads the stored Copilot OAuth grant (or an
|
|
6
|
+
explicit API-key reference), lists the account's models, and keeps the bundled
|
|
7
|
+
catalog only as a fallback.
|
|
8
|
+
- **Copilot-compatible wire identity** — installs a refcounted `fetch` header
|
|
9
|
+
interceptor while mounted so the pi-ai adapter presents Copilot client
|
|
10
|
+
identity headers.
|
|
11
|
+
- **Native Responses web search** — registers the `github-copilot-search`
|
|
12
|
+
provider for the ordinary `web_search` tool.
|
|
13
|
+
- **Gemini tool-declaration guard** — adds a missing root `properties` bucket to
|
|
14
|
+
tool parameter schemas for configured Copilot routes.
|
|
15
|
+
|
|
16
|
+
The plugin is a replacement for, and superset of, the earlier separate
|
|
17
|
+
`dsh-copilot-catalog`, `dsh-copilot-search` and `dsh-copilot-tool-schema`
|
|
18
|
+
packages. Their behaviour is consolidated here; see the migration notes below.
|
|
19
|
+
|
|
20
|
+
## Compatibility
|
|
21
|
+
|
|
22
|
+
- DSH and DSH peer packages: `0.2.1-rc.5`.
|
|
23
|
+
- `@earendil-works/pi-ai`: `^1.1.0` (host-aligned peer range; tested at `1.1.0` and `1.3.0`).
|
|
24
|
+
- Node.js: `>=22.19.0`.
|
|
25
|
+
|
|
26
|
+
The plugin resolves the same public pi-ai runtime the DSH adapter uses, including
|
|
27
|
+
nested pnpm layouts. It vendors neither DSH nor pi-ai and does not modify
|
|
28
|
+
signed applications. This is an experimental third-party integration, not
|
|
29
|
+
affiliated with GitHub, DeepSeek or the pi-ai maintainers.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Install or link this leaf package, not the monorepo root. The package is
|
|
34
|
+
published as `dsh-better-copilot`; until a registry release exists, install the
|
|
35
|
+
packed tarball or link the workspace directory. Restart the DSH host after
|
|
36
|
+
installing or removing a host plugin.
|
|
37
|
+
|
|
38
|
+
The bundle patch:
|
|
39
|
+
|
|
40
|
+
- disables the stock `llm-pi-ai` row and mounts this plugin in its place (the
|
|
41
|
+
merged plugin is the adapter wrapper), so never enable two adapters for the
|
|
42
|
+
same route;
|
|
43
|
+
- selects `github-copilot-search` for the `web` row and keeps the HTTP fetch
|
|
44
|
+
provider;
|
|
45
|
+
- inserts one `better-copilot` row whose config names a `github-copilot` provider
|
|
46
|
+
profile (an empty profile, `{}`) plus default `search`/`toolSchema` settings.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
- id: better-copilot
|
|
52
|
+
name: dsh-better-copilot
|
|
53
|
+
config:
|
|
54
|
+
providers:
|
|
55
|
+
# The pi-ai adapter's own provider profile: baseURL, apiKeyEnv, models, ...
|
|
56
|
+
github-copilot: {}
|
|
57
|
+
search:
|
|
58
|
+
model: gpt-6.1-sol
|
|
59
|
+
maxTokens: 4096
|
|
60
|
+
timeoutMs: 60000
|
|
61
|
+
maxResponseBytes: 2097152
|
|
62
|
+
includeSources: true
|
|
63
|
+
searchMode: default
|
|
64
|
+
forceSearch: false
|
|
65
|
+
# apiKeyEnv / baseURL are optional credential and endpoint overrides
|
|
66
|
+
toolSchema:
|
|
67
|
+
providers: [github-copilot]
|
|
68
|
+
families: [gemini]
|
|
69
|
+
logFixes: false
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`providers` is the pi-ai adapter's own schema, reused unchanged, so every
|
|
73
|
+
provider setting behaves exactly as it does with the stock adapter. `search`
|
|
74
|
+
configures the web-search provider; `toolSchema` configures the Gemini guard.
|
|
75
|
+
|
|
76
|
+
Loader patches replace a row's `config` wholesale, not key by key: an override
|
|
77
|
+
must restate `providers`, `search` and `toolSchema` together.
|
|
78
|
+
|
|
79
|
+
## Wire identity
|
|
80
|
+
|
|
81
|
+
While mounted, the plugin installs a refcounted `fetch` header interceptor that
|
|
82
|
+
rewrites Copilot request headers to a VS Code/`copilot-chat` identity. Set
|
|
83
|
+
`DSH_COPILOT_WIRE_IDENTITY=basic` to avoid local VS Code IDs, or `0` to disable
|
|
84
|
+
the interceptor entirely.
|
|
85
|
+
|
|
86
|
+
## Search
|
|
87
|
+
|
|
88
|
+
Search uses the stored `llm-pi-ai/github-copilot` credential — inference may use
|
|
89
|
+
either the stock adapter or this wrapper. Sign in through DSH's normal Copilot
|
|
90
|
+
flow. `web_search` is an ordinary tool independent of the conversation model; the
|
|
91
|
+
existing Web search settings card is DeepSeek-specific and is not a chooser.
|
|
92
|
+
|
|
93
|
+
## Guard
|
|
94
|
+
|
|
95
|
+
The guard is an additive `system-prompt/assemble` listener. For a route whose
|
|
96
|
+
provider is listed and whose model id contains one of the configured families it
|
|
97
|
+
adds an empty root `properties` bucket to object tool schemas that lack one, and
|
|
98
|
+
leaves every other schema byte-identical. It never replaces an adapter or
|
|
99
|
+
another plugin's settings.
|
|
100
|
+
|
|
101
|
+
## Migration from the three predecessor packages
|
|
102
|
+
|
|
103
|
+
| Was | Now |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| package `dsh-copilot-catalog`, row `llm-pi-ai-catalog` | package `dsh-better-copilot`, row `better-copilot` |
|
|
106
|
+
| package `dsh-copilot-search`, row `web-search-copilot` | same row `better-copilot`; `model` became `search.model` |
|
|
107
|
+
| package `dsh-copilot-tool-schema`, row `copilot-tool-schema` | same row `better-copilot`; guard fields moved under `toolSchema.*` |
|
|
108
|
+
| `@deepseek-ai/dsh-llm-pi-ai` disabled by the catalog bundle | still disabled by this bundle |
|
|
109
|
+
| `web.searchProvider: github-copilot-search` | still selected by this bundle |
|
|
110
|
+
|
|
111
|
+
Subpath exports also changed: the predecessor specifiers `…/provider`,
|
|
112
|
+
`…/responses`, `…/auth`, `…/plugin`, `…/schema` and `…/types` are gone. Use the
|
|
113
|
+
merged package root, `dsh-better-copilot/catalog`, `/discovery`, `/identity`,
|
|
114
|
+
`/search` and `/guard` instead. The colliding `CopilotAuthOptions` type is
|
|
115
|
+
exported as `CatalogAuthOptions` and `SearchAuthOptions`.
|
|
116
|
+
|
|
117
|
+
The predecessor npm versions remain published; this repository no longer builds
|
|
118
|
+
them and treats them as superseded.
|
|
119
|
+
|
|
120
|
+
## Limits
|
|
121
|
+
|
|
122
|
+
- Discovery is one read-only, bounded startup request; failures keep the bundled
|
|
123
|
+
catalog and log only a controlled reason code.
|
|
124
|
+
- Search requests never forward credentials to redirects or untrusted origins.
|
|
125
|
+
- The guard only supplies a missing `properties` bucket; it does not rewrite
|
|
126
|
+
references, unions or nested schemas.
|
|
127
|
+
|
|
128
|
+
The full field mapping is: the search plugin's row-level `apiKeyEnv`, `baseURL`,
|
|
129
|
+
`model`, `maxTokens`, `timeoutMs`, `maxResponseBytes`, `includeSources`,
|
|
130
|
+
`searchMode` and `forceSearch` are now `search.*`; the guard's `providers`,
|
|
131
|
+
`families` and `logFixes` are now `toolSchema.*`; and every adapter provider
|
|
132
|
+
setting stays under `providers.*` unchanged.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# This bundle replaces the pi-ai adapter and adds the Copilot search and guard
|
|
2
|
+
# behaviours: never enable another adapter for the same route.
|
|
3
|
+
# Copy any other provider configuration into the replacement before enabling.
|
|
4
|
+
- id: llm-pi-ai
|
|
5
|
+
name: '@deepseek-ai/dsh-llm-pi-ai'
|
|
6
|
+
disabled: true
|
|
7
|
+
# Select the search provider explicitly: the base profile pins DeepSeek search.
|
|
8
|
+
# This does not replace an LLM adapter or change its model/provider configuration.
|
|
9
|
+
- id: web
|
|
10
|
+
name: '@deepseek-ai/dsh-web'
|
|
11
|
+
config:
|
|
12
|
+
searchProvider: github-copilot-search
|
|
13
|
+
fetchProvider: http
|
|
14
|
+
- insert:
|
|
15
|
+
- id: better-copilot
|
|
16
|
+
name: dsh-better-copilot
|
|
17
|
+
config:
|
|
18
|
+
providers:
|
|
19
|
+
github-copilot: {}
|
|
20
|
+
search: {}
|
|
21
|
+
toolSchema: {}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type CredentialKey, type CredentialProvider } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import { WebError } from '@deepseek-ai/dsh-web';
|
|
3
|
+
import type { CredentialStore } from '@earendil-works/pi-ai';
|
|
4
|
+
import type { CopilotAuthOptions, ResolvedCopilotAuth, SearchContext, SearchRuntime } from './search-types.js';
|
|
5
|
+
/** Caller cancellation of the refresh in progress on this async chain, if any. */
|
|
6
|
+
export declare function callerSignal(): AbortSignal | undefined;
|
|
7
|
+
export type CopilotCredentialService = Pick<CredentialProvider, 'readRecord' | 'modifyRecord' | 'resolve'>;
|
|
8
|
+
declare const AUTH_FAILURES: {
|
|
9
|
+
readonly aborted: readonly ["Copilot search authentication aborted", "WEB_ABORTED"];
|
|
10
|
+
readonly invalid: readonly ["The stored Copilot credential is invalid; sign in again", "WEB_PROVIDER_AUTH_INVALID"];
|
|
11
|
+
readonly writeRefused: readonly ["Copilot search only persists refreshes of existing OAuth grants", "WEB_PROVIDER_AUTH_WRITE_REFUSED"];
|
|
12
|
+
readonly badReference: readonly ["Invalid Copilot credential reference", "WEB_PROVIDER_AUTH_INVALID"];
|
|
13
|
+
readonly missingReference: readonly ["The configured Copilot credential reference is unset", "WEB_PROVIDER_CREDENTIAL_MISSING"];
|
|
14
|
+
readonly badToken: readonly ["Copilot authentication returned an unusable token", "WEB_PROVIDER_AUTH_INVALID"];
|
|
15
|
+
readonly missingEndpoint: readonly ["An opaque Copilot API key requires an explicit trusted baseURL", "WEB_PROVIDER_ENDPOINT_MISSING"];
|
|
16
|
+
readonly failed: readonly ["Copilot search authentication failed; check credentials or sign in again", "WEB_PROVIDER_AUTH_ERROR"];
|
|
17
|
+
};
|
|
18
|
+
type AuthFailure = keyof typeof AUTH_FAILURES;
|
|
19
|
+
/** Closed messages, even if an injected dependency constructs or mutates an owned error. */
|
|
20
|
+
export declare class CopilotAuthError extends WebError {
|
|
21
|
+
readonly kind: AuthFailure;
|
|
22
|
+
constructor(kind: AuthFailure);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Share the adapter's serialized, cross-process record lock for refresh only.
|
|
26
|
+
* Search has no login/logout capability and cannot create or replace API-key records.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createCopilotCredentialStore(credentials: Pick<CredentialProvider, 'readRecord' | 'modifyRecord'> | undefined, key: CredentialKey, caller?: AbortSignal): CredentialStore;
|
|
29
|
+
/** Public pi-ai auth resolution on the same DSH credential record used for inference. */
|
|
30
|
+
export declare function createCopilotAuthResolver(ctx: SearchContext, runtime: SearchRuntime): (options: CopilotAuthOptions, signal: AbortSignal) => Promise<ResolvedCopilotAuth | undefined>;
|
|
31
|
+
export {};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { credentialRef, isCredentialRefName } from '@deepseek-ai/dsh-credentials';
|
|
3
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
4
|
+
import { WebError } from '@deepseek-ai/dsh-web';
|
|
5
|
+
import { CopilotEndpointError, trustedCopilotOrigin } from './endpoint.js';
|
|
6
|
+
const PROVIDER = 'github-copilot';
|
|
7
|
+
/** The installed SDK's refresh hook receives its own signal, never the caller's.
|
|
8
|
+
* Carry the caller's cancellation across the public getAuth() call so a cancelled
|
|
9
|
+
* operation cannot dispatch later requests or commit a refreshed grant.
|
|
10
|
+
*/
|
|
11
|
+
const callerSignals = new AsyncLocalStorage();
|
|
12
|
+
/** Caller cancellation of the refresh in progress on this async chain, if any. */
|
|
13
|
+
export function callerSignal() { return callerSignals.getStore(); }
|
|
14
|
+
const AUTH_FAILURES = {
|
|
15
|
+
aborted: ['Copilot search authentication aborted', 'WEB_ABORTED'],
|
|
16
|
+
invalid: ['The stored Copilot credential is invalid; sign in again', 'WEB_PROVIDER_AUTH_INVALID'],
|
|
17
|
+
writeRefused: ['Copilot search only persists refreshes of existing OAuth grants', 'WEB_PROVIDER_AUTH_WRITE_REFUSED'],
|
|
18
|
+
badReference: ['Invalid Copilot credential reference', 'WEB_PROVIDER_AUTH_INVALID'],
|
|
19
|
+
missingReference: ['The configured Copilot credential reference is unset', 'WEB_PROVIDER_CREDENTIAL_MISSING'],
|
|
20
|
+
badToken: ['Copilot authentication returned an unusable token', 'WEB_PROVIDER_AUTH_INVALID'],
|
|
21
|
+
missingEndpoint: ['An opaque Copilot API key requires an explicit trusted baseURL', 'WEB_PROVIDER_ENDPOINT_MISSING'],
|
|
22
|
+
failed: ['Copilot search authentication failed; check credentials or sign in again', 'WEB_PROVIDER_AUTH_ERROR'],
|
|
23
|
+
};
|
|
24
|
+
/** Closed messages, even if an injected dependency constructs or mutates an owned error. */
|
|
25
|
+
export class CopilotAuthError extends WebError {
|
|
26
|
+
kind;
|
|
27
|
+
constructor(kind) {
|
|
28
|
+
const safeKind = typeof kind === 'string' && Object.hasOwn(AUTH_FAILURES, kind) ? kind : 'failed';
|
|
29
|
+
const [message, code] = AUTH_FAILURES[safeKind];
|
|
30
|
+
super(message, code);
|
|
31
|
+
this.kind = safeKind;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function aborted(signal) {
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
throw new CopilotAuthError('aborted');
|
|
37
|
+
}
|
|
38
|
+
function invalidCredential() {
|
|
39
|
+
throw new CopilotAuthError('invalid');
|
|
40
|
+
}
|
|
41
|
+
function oauthCredential(value) {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
|
|
43
|
+
return invalidCredential();
|
|
44
|
+
const grant = value;
|
|
45
|
+
if (grant.type !== 'oauth' || typeof grant.access !== 'string' || !grant.access.trim() || grant.access.length > 16_384 ||
|
|
46
|
+
typeof grant.refresh !== 'string' || !grant.refresh.trim() || grant.refresh.length > 16_384 ||
|
|
47
|
+
/[\u0000-\u0020\u007f-\u009f]/u.test(grant.access) || /[\u0000-\u0020\u007f-\u009f]/u.test(grant.refresh) ||
|
|
48
|
+
typeof grant.expires !== 'number' || !Number.isFinite(grant.expires) || grant.expires < 0)
|
|
49
|
+
return invalidCredential();
|
|
50
|
+
return structuredClone(grant);
|
|
51
|
+
}
|
|
52
|
+
/** Read only the credential family owned by the original pi-ai adapter. */
|
|
53
|
+
function fromRecord(record) {
|
|
54
|
+
if (record === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (record.kind === 'grant')
|
|
57
|
+
return oauthCredential(record.payload);
|
|
58
|
+
if (record.kind !== 'api-key')
|
|
59
|
+
return invalidCredential();
|
|
60
|
+
if (record.key !== undefined && (typeof record.key !== 'string' || !record.key.trim()))
|
|
61
|
+
return invalidCredential();
|
|
62
|
+
if (record.env !== undefined && (typeof record.env !== 'object' || record.env === null ||
|
|
63
|
+
Array.isArray(record.env) || Object.entries(record.env).some(([key, value]) => !isCredentialRefName(key) || typeof value !== 'string'))) {
|
|
64
|
+
return invalidCredential();
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: 'api_key',
|
|
68
|
+
...(record.key === undefined ? {} : { key: record.key }),
|
|
69
|
+
...(record.env === undefined ? {} : { env: { ...record.env } }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Match the adapter's JSON image without laundering nonfinite values or foreign prototypes. */
|
|
73
|
+
function jsonImage(value) {
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
return value.map((entry) => entry === undefined ? null : jsonImage(entry));
|
|
76
|
+
if (value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
77
|
+
return Object.fromEntries(Object.entries(value).filter(([, member]) => member !== undefined)
|
|
78
|
+
.map(([key, member]) => [key, jsonImage(member)]));
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Share the adapter's serialized, cross-process record lock for refresh only.
|
|
84
|
+
* Search has no login/logout capability and cannot create or replace API-key records.
|
|
85
|
+
*/
|
|
86
|
+
export function createCopilotCredentialStore(credentials, key, caller) {
|
|
87
|
+
// Checked before the transaction and again before it commits; the SDK's refresh hook
|
|
88
|
+
// no longer receives the caller's signal, so cancellation must be honoured here.
|
|
89
|
+
const cancelled = () => caller?.aborted === true || callerSignal()?.aborted === true;
|
|
90
|
+
const read = async (providerId, options) => {
|
|
91
|
+
aborted(options?.signal);
|
|
92
|
+
if (providerId !== PROVIDER)
|
|
93
|
+
return undefined;
|
|
94
|
+
const record = await credentials?.readRecord(key);
|
|
95
|
+
aborted(options?.signal);
|
|
96
|
+
return fromRecord(record);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
read,
|
|
100
|
+
async list(options) {
|
|
101
|
+
const current = await read(PROVIDER, options);
|
|
102
|
+
return current === undefined ? [] : [{ providerId: PROVIDER, type: current.type }];
|
|
103
|
+
},
|
|
104
|
+
async modify(providerId, mutate, options) {
|
|
105
|
+
aborted(options?.signal);
|
|
106
|
+
if (providerId !== PROVIDER || credentials === undefined) {
|
|
107
|
+
throw new CopilotAuthError('writeRefused');
|
|
108
|
+
}
|
|
109
|
+
const record = await credentials.modifyRecord(key, async (current) => {
|
|
110
|
+
// DSH's lock wait is not cancellable: check again once it actually acquired the lock.
|
|
111
|
+
aborted(options?.signal);
|
|
112
|
+
// The SDK's refresh hook no longer receives the caller's signal, so a refresh that
|
|
113
|
+
// finished after cancellation must not commit through this managed record write.
|
|
114
|
+
if (cancelled())
|
|
115
|
+
throw new CopilotAuthError('aborted');
|
|
116
|
+
const previous = fromRecord(current);
|
|
117
|
+
// Capture eligibility and account authority before passing a mutable clone to the SDK.
|
|
118
|
+
const wasOAuth = current?.kind === 'grant' && previous?.type === 'oauth';
|
|
119
|
+
const refreshOwner = previous?.type === 'oauth' ? previous.refresh : undefined;
|
|
120
|
+
const enterpriseOwner = previous?.type === 'oauth' ? previous.enterpriseUrl : undefined;
|
|
121
|
+
const next = await mutate(previous);
|
|
122
|
+
aborted(options?.signal);
|
|
123
|
+
// The SDK calls this mutator with the refresh inside it, so gate the commit
|
|
124
|
+
// itself: a refresh that finished after caller cancellation must not persist.
|
|
125
|
+
if (cancelled())
|
|
126
|
+
throw new CopilotAuthError('aborted');
|
|
127
|
+
if (next === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (!wasOAuth || next.type !== 'oauth')
|
|
130
|
+
throw new CopilotAuthError('writeRefused');
|
|
131
|
+
const refreshed = oauthCredential(next);
|
|
132
|
+
if (refreshed.refresh !== refreshOwner || refreshed.enterpriseUrl !== enterpriseOwner) {
|
|
133
|
+
throw new CopilotAuthError('writeRefused');
|
|
134
|
+
}
|
|
135
|
+
const payload = jsonImage(refreshed);
|
|
136
|
+
aborted(options?.signal);
|
|
137
|
+
return { kind: 'grant', payload };
|
|
138
|
+
});
|
|
139
|
+
aborted(options?.signal);
|
|
140
|
+
return fromRecord(record);
|
|
141
|
+
},
|
|
142
|
+
async delete() {
|
|
143
|
+
throw new CopilotAuthError('writeRefused');
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Observe late settlement while caller cancellation releases the request promptly. */
|
|
148
|
+
function abortable(operation, signal) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const onAbort = () => reject(new CopilotAuthError('aborted'));
|
|
151
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
152
|
+
operation.then((value) => {
|
|
153
|
+
signal.removeEventListener('abort', onAbort);
|
|
154
|
+
resolve(value);
|
|
155
|
+
}, (error) => {
|
|
156
|
+
signal.removeEventListener('abort', onAbort);
|
|
157
|
+
reject(error);
|
|
158
|
+
});
|
|
159
|
+
if (signal.aborted)
|
|
160
|
+
onAbort();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** Public pi-ai auth resolution on the same DSH credential record used for inference. */
|
|
164
|
+
export function createCopilotAuthResolver(ctx, runtime) {
|
|
165
|
+
return async (options, signal) => {
|
|
166
|
+
aborted(signal);
|
|
167
|
+
const work = async () => {
|
|
168
|
+
const explicitOrigin = options.baseURL === undefined ? undefined : trustedCopilotOrigin(options.baseURL);
|
|
169
|
+
if (options.apiKeyEnv !== undefined && !isCredentialRefName(options.apiKeyEnv)) {
|
|
170
|
+
throw new CopilotAuthError('badReference');
|
|
171
|
+
}
|
|
172
|
+
// Snapshot both storage and launch environment for this operation, not startup.
|
|
173
|
+
const credentials = ctx.get('credentials');
|
|
174
|
+
const environment = launchEnvironmentOf(ctx);
|
|
175
|
+
const readReference = async (name) => {
|
|
176
|
+
aborted(signal);
|
|
177
|
+
if (!isCredentialRefName(name))
|
|
178
|
+
return undefined;
|
|
179
|
+
const hit = await credentials?.resolve(credentialRef(name));
|
|
180
|
+
aborted(signal);
|
|
181
|
+
return hit?.value ?? environment.get(name)?.value;
|
|
182
|
+
};
|
|
183
|
+
const apiKey = options.apiKeyEnv === undefined ? undefined : await readReference(options.apiKeyEnv);
|
|
184
|
+
aborted(signal);
|
|
185
|
+
if (options.apiKeyEnv !== undefined && (typeof apiKey !== 'string' || !apiKey.trim())) {
|
|
186
|
+
throw new CopilotAuthError('missingReference');
|
|
187
|
+
}
|
|
188
|
+
const models = runtime.createModels({
|
|
189
|
+
credentials: createCopilotCredentialStore(credentials, runtime.recordKeyFor(PROVIDER), signal),
|
|
190
|
+
authContext: { env: readReference, fileExists: async () => false },
|
|
191
|
+
});
|
|
192
|
+
models.setProvider(runtime.provider);
|
|
193
|
+
const resolved = await callerSignals.run(signal, () => models.getAuth(PROVIDER, { signal, ...(apiKey === undefined ? {} : { apiKey }) }));
|
|
194
|
+
aborted(signal);
|
|
195
|
+
if (resolved === undefined)
|
|
196
|
+
return undefined;
|
|
197
|
+
const token = resolved.auth.apiKey;
|
|
198
|
+
if (typeof token !== 'string' || !token.trim() || token.length > 16_384 || /[\u0000-\u0020\u007f-\u009f]/u.test(token)) {
|
|
199
|
+
throw new CopilotAuthError('badToken');
|
|
200
|
+
}
|
|
201
|
+
// OAuth's account endpoint owns routing; an explicit origin is an API-key setting only.
|
|
202
|
+
let baseUrl = resolved.auth.baseUrl ?? explicitOrigin;
|
|
203
|
+
if (baseUrl === undefined && /(?:^|;)proxy-ep=[^;]+/u.test(token)) {
|
|
204
|
+
const derived = await runtime.oauth.toAuth({ type: 'oauth', access: token, refresh: '', expires: 0 });
|
|
205
|
+
aborted(signal);
|
|
206
|
+
baseUrl = derived.baseUrl;
|
|
207
|
+
}
|
|
208
|
+
if (baseUrl === undefined) {
|
|
209
|
+
throw new CopilotAuthError('missingEndpoint');
|
|
210
|
+
}
|
|
211
|
+
const origin = trustedCopilotOrigin(baseUrl);
|
|
212
|
+
aborted(signal);
|
|
213
|
+
return { apiKey: token, baseUrl: origin, headers: runtime.headers };
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
return await abortable(work(), signal);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
aborted(signal);
|
|
220
|
+
// Error classification and getters can themselves throw; never expose that exception.
|
|
221
|
+
let controlled;
|
|
222
|
+
try {
|
|
223
|
+
if (error instanceof CopilotAuthError)
|
|
224
|
+
controlled = new CopilotAuthError(error.kind);
|
|
225
|
+
else if (error instanceof CopilotEndpointError)
|
|
226
|
+
controlled = new CopilotEndpointError();
|
|
227
|
+
}
|
|
228
|
+
catch { /* malformed host dependency errors degrade to the fixed auth failure */ }
|
|
229
|
+
throw controlled ?? new CopilotAuthError('failed');
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
/** Test seam for the process-wide wire-identity installer; defaults to the real interceptor. */
|
|
45
|
+
installIdentity?: () => () => void;
|
|
46
|
+
}
|
|
47
|
+
export interface CatalogPlugin {
|
|
48
|
+
name: OriginalAdapter['name'];
|
|
49
|
+
Config: OriginalAdapter['Config'];
|
|
50
|
+
inject: string[];
|
|
51
|
+
sync(credentials?: CredentialReader, authOptions?: CopilotAuthOptions): Promise<SyncResult>;
|
|
52
|
+
apply(ctx: PluginContext, config: PluginConfig): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export declare function asRecord(value: unknown): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModelCost } from '@earendil-works/pi-ai';
|
|
2
|
+
import { type AccountSelection, type ModelCatalog } from './catalog-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;
|