@alvin0/ai-agent-sdk-provider-copilot 0.1.1
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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/index.d.ts +1297 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2680 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2680 @@
|
|
|
1
|
+
import { AgentSdkError, MISSING_CREDENTIAL_CODE, MODEL_ERROR_CODES, ModelError, safeErrorRecord, waitForSettlement } from "@alvin0/ai-agent-sdk-core";
|
|
2
|
+
import { AgentSdkError as AgentSdkError$1, CREDENTIAL_CAPABILITY_API_VERSION, defineCredentialStore, defineModelProviderPlugin } from "@alvin0/ai-agent-sdk-core/provider";
|
|
3
|
+
import { createRuntimeHttpProvider, defineWireProtocol, observeCredentialOperation } from "@alvin0/ai-agent-sdk-provider-http";
|
|
4
|
+
import { OPENAI_CHAT_COMPLETIONS_PROTOCOL_ID, openAiChatCompletionsProtocol } from "@alvin0/ai-agent-sdk-protocol-openai-chat-completions";
|
|
5
|
+
import { OPENAI_RESPONSES_PROTOCOL_ID, openAiResponsesProtocol } from "@alvin0/ai-agent-sdk-protocol-responses";
|
|
6
|
+
|
|
7
|
+
//#region src/common/error-codes.ts
|
|
8
|
+
/**
|
|
9
|
+
* The Copilot error codes, in the leaf layer so the shared HTTP modules can reach
|
|
10
|
+
* them.
|
|
11
|
+
*
|
|
12
|
+
* The taxonomy BELONGS to `../errors.ts` — that module is the public door, and it
|
|
13
|
+
* re-exports everything here. The definition lives one layer down for a structural
|
|
14
|
+
* reason: `common/` is a leaf, and `common/http.ts` needs
|
|
15
|
+
* `ENDPOINT_ORIGIN_INVALID` and `COPILOT_REDIRECT_REJECTED` to throw. Importing
|
|
16
|
+
* them from a root module would make `common/` depend on the root while the root
|
|
17
|
+
* already depends on `common/`, which is the source-ownership cycle the repo's
|
|
18
|
+
* package-graph check forbids. Duplicating the two strings instead would be worse:
|
|
19
|
+
* a code that exists in two places is a code that can disagree with itself.
|
|
20
|
+
*
|
|
21
|
+
* Read `../errors.ts` for the taxonomy's rationale, including the three situations
|
|
22
|
+
* that deliberately get an EXISTING SDK code rather than a Copilot one.
|
|
23
|
+
*
|
|
24
|
+
* @module ai-agent-sdk/providers/copilot/error-codes
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Stable codes for the failures that are specific to Copilot.
|
|
28
|
+
*
|
|
29
|
+
* Frozen, and flat strings rather than a TS enum, for the same reason the core
|
|
30
|
+
* taxonomy is: a consumer routes on the value, and the value has to survive
|
|
31
|
+
* serialization into a log line.
|
|
32
|
+
*/
|
|
33
|
+
const COPILOT_ERROR_CODES = Object.freeze({
|
|
34
|
+
/** The token-exchange surface rejected the credential: a PAT, or a non-allowlisted OAuth App. */
|
|
35
|
+
CREDENTIAL_REJECTED: "COPILOT_CREDENTIAL_REJECTED",
|
|
36
|
+
/** Token exchange failed for a reason that is not the credential. */
|
|
37
|
+
TOKEN_EXCHANGE_FAILED: "COPILOT_TOKEN_EXCHANGE_FAILED",
|
|
38
|
+
/** The token-exchange response carried no readable `expires_at`, or was not JSON. */
|
|
39
|
+
TOKEN_MALFORMED: "COPILOT_TOKEN_MALFORMED",
|
|
40
|
+
/** A `*.ghe.com` data-residency tenant has no token-exchange surface. */
|
|
41
|
+
TENANT_UNSUPPORTED: "COPILOT_TENANT_UNSUPPORTED",
|
|
42
|
+
/** The endpoint rejected the request for missing `Editor_Headers`. */
|
|
43
|
+
EDITOR_HEADERS_MISSING: "COPILOT_EDITOR_HEADERS_MISSING",
|
|
44
|
+
/** The target URL is not on the same origin as the configured issuer/base URL. */
|
|
45
|
+
ENDPOINT_ORIGIN_INVALID: "COPILOT_ENDPOINT_ORIGIN_INVALID",
|
|
46
|
+
/** The response was a redirect; this SDK does not follow it. */
|
|
47
|
+
REDIRECT_REJECTED: "COPILOT_REDIRECT_REJECTED",
|
|
48
|
+
/** Device flow: the user denied the request. */
|
|
49
|
+
DEVICE_LOGIN_DENIED: "COPILOT_DEVICE_LOGIN_DENIED",
|
|
50
|
+
/** Device flow: the code expired server-side. */
|
|
51
|
+
DEVICE_LOGIN_EXPIRED: "COPILOT_DEVICE_LOGIN_EXPIRED",
|
|
52
|
+
/** Device flow: the absolute 15-minute bound passed without approval. */
|
|
53
|
+
DEVICE_LOGIN_TIMEOUT: "COPILOT_DEVICE_LOGIN_TIMEOUT",
|
|
54
|
+
/** Device flow: failed for any other reason. */
|
|
55
|
+
DEVICE_LOGIN_FAILED: "COPILOT_DEVICE_LOGIN_FAILED",
|
|
56
|
+
/** A credential commit found a revision other than the expected one. */
|
|
57
|
+
CREDENTIAL_REVISION_CONFLICT: "COPILOT_CREDENTIAL_REVISION_CONFLICT",
|
|
58
|
+
/** The `/models` response was the wrong shape at the structural level. */
|
|
59
|
+
CATALOG_MALFORMED: "COPILOT_CATALOG_MALFORMED",
|
|
60
|
+
/** `endpointOverrides` pinned a model to an endpoint that does not exist. */
|
|
61
|
+
ENDPOINT_OVERRIDE_INVALID: "COPILOT_ENDPOINT_OVERRIDE_INVALID"
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/errors.ts
|
|
66
|
+
/**
|
|
67
|
+
* The Copilot error taxonomy: the codes this provider owns, the two error
|
|
68
|
+
* classes that carry a machine-readable classification beside them, and the one
|
|
69
|
+
* door through which every credential-path error is built.
|
|
70
|
+
*
|
|
71
|
+
* ## What is deliberately NOT here
|
|
72
|
+
*
|
|
73
|
+
* Three situations get an EXISTING code rather than a Copilot one, because a
|
|
74
|
+
* second code for the same situation forces every consumer to write a second
|
|
75
|
+
* branch for it:
|
|
76
|
+
*
|
|
77
|
+
* - **No credential at all** — `MISSING_CREDENTIAL_CODE` from `packages/core`,
|
|
78
|
+
* with a message naming the login command (Requirement 13.4).
|
|
79
|
+
* - **Abort** — the SDK's existing abort code, {@link MODEL_ERROR_CODES.ABORTED}
|
|
80
|
+
* (Requirement 4.6). `CopilotDeviceLoginError` with `reason: 'aborted'` maps to
|
|
81
|
+
* it rather than minting a Copilot abort code.
|
|
82
|
+
* - **HTTP failures of the generation/embedding endpoints** — `MODEL_ERROR_CODES`
|
|
83
|
+
* plus `HTTP_PROVIDER_ERROR_CODES`. In particular there is no
|
|
84
|
+
* `COPILOT_RATE_LIMIT`: a 429 from Copilot is `RATE_LIMIT`, the same as from
|
|
85
|
+
* every other provider (Requirements 13.5, 15.5).
|
|
86
|
+
*
|
|
87
|
+
* @module ai-agent-sdk/providers/copilot/errors
|
|
88
|
+
*/
|
|
89
|
+
/**
|
|
90
|
+
* Mirrors `safeProviderFailure` from `provider-http`: a {@link ModelError} keeps
|
|
91
|
+
* its stable code and status and LOSES its message, because a provider message
|
|
92
|
+
* is the one field that can have echoed a request header back at us.
|
|
93
|
+
* @param failure - the serializable twin carried by a {@link ModelError}.
|
|
94
|
+
* @returns a frozen record with no provider-authored text in it.
|
|
95
|
+
*/
|
|
96
|
+
function safeModelFailure(failure) {
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
type: "ModelError",
|
|
99
|
+
message: "provider attempt failed; inspect the stable code and request ID",
|
|
100
|
+
code: failure.code,
|
|
101
|
+
...failure.status === void 0 ? {} : { status: failure.status }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build the inputs for an error on the Copilot credential path.
|
|
106
|
+
*
|
|
107
|
+
* This is the ONLY door: {@link CopilotTokenExchangeError} and
|
|
108
|
+
* {@link CopilotDeviceLoginError} take a {@link CopilotCredentialFailure} rather
|
|
109
|
+
* than a raw `cause`, so there is no code path that can attach an unfiltered
|
|
110
|
+
* value to a credential-path error. `message` is SDK-authored text; a
|
|
111
|
+
* `GitHub_User_Token` or a `Copilot_Api_Token` is never interpolated into it, and
|
|
112
|
+
* response bodies reach `cause` only after the bounded read has replaced every
|
|
113
|
+
* occurrence of the tokens held in memory with `[REDACTED]` (Requirement 13.7).
|
|
114
|
+
* @param message - SDK-authored, actionable text. No token values.
|
|
115
|
+
* @param cause - the caught value, if any; filtered before it is retained.
|
|
116
|
+
* @returns the sanitized pair an error class accepts.
|
|
117
|
+
*/
|
|
118
|
+
function credentialFailure(message, cause) {
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
message,
|
|
121
|
+
cause: cause === void 0 ? void 0 : cause instanceof ModelError ? safeModelFailure(cause.failure) : safeErrorRecord(cause)
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A `Copilot_Token_Exchange` that did not produce a token.
|
|
126
|
+
*
|
|
127
|
+
* `kind` exists because `code` alone does not answer the only question a caller
|
|
128
|
+
* has to answer next: `TOKEN_EXCHANGE_FAILED` covers both a 5xx worth waiting out
|
|
129
|
+
* and a 4xx that will fail identically forever.
|
|
130
|
+
*/
|
|
131
|
+
var CopilotTokenExchangeError = class extends AgentSdkError {
|
|
132
|
+
/** Retry classification for this failure. */
|
|
133
|
+
kind;
|
|
134
|
+
/**
|
|
135
|
+
* @param failure - message and filtered cause from {@link credentialFailure}.
|
|
136
|
+
* @param code - the Copilot code for this row of the classification table.
|
|
137
|
+
* @param kind - whether a retry could ever succeed.
|
|
138
|
+
*/
|
|
139
|
+
constructor(failure, code, kind) {
|
|
140
|
+
super(failure.message, code, failure.cause === void 0 ? void 0 : { cause: failure.cause });
|
|
141
|
+
this.kind = kind;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Codes for the four device-flow outcomes this provider owns.
|
|
146
|
+
*
|
|
147
|
+
* `denied` and `expired` are separate on purpose: "you just declined this" and
|
|
148
|
+
* "the code ran out" lead to different next steps. `aborted` is absent because it
|
|
149
|
+
* maps to the SDK's existing abort code instead.
|
|
150
|
+
*/
|
|
151
|
+
const DEVICE_LOGIN_CODES = Object.freeze({
|
|
152
|
+
denied: COPILOT_ERROR_CODES.DEVICE_LOGIN_DENIED,
|
|
153
|
+
expired: COPILOT_ERROR_CODES.DEVICE_LOGIN_EXPIRED,
|
|
154
|
+
timeout: COPILOT_ERROR_CODES.DEVICE_LOGIN_TIMEOUT,
|
|
155
|
+
failed: COPILOT_ERROR_CODES.DEVICE_LOGIN_FAILED
|
|
156
|
+
});
|
|
157
|
+
/**
|
|
158
|
+
* A device login that ended without a `GitHub_User_Token`.
|
|
159
|
+
*
|
|
160
|
+
* The `code` is derived from `reason` rather than passed in, so the two can never
|
|
161
|
+
* disagree — a caller reading `code` and a caller reading `reason` always see the
|
|
162
|
+
* same outcome.
|
|
163
|
+
*/
|
|
164
|
+
var CopilotDeviceLoginError = class extends AgentSdkError {
|
|
165
|
+
/** The distinguishable reason the flow ended. */
|
|
166
|
+
reason;
|
|
167
|
+
/**
|
|
168
|
+
* @param failure - message and filtered cause from {@link credentialFailure}.
|
|
169
|
+
* @param reason - the outcome; decides the `code`, with `aborted` mapping to
|
|
170
|
+
* {@link MODEL_ERROR_CODES.ABORTED} rather than a Copilot-specific code.
|
|
171
|
+
*/
|
|
172
|
+
constructor(failure, reason) {
|
|
173
|
+
super(failure.message, reason === "aborted" ? MODEL_ERROR_CODES.ABORTED : DEVICE_LOGIN_CODES[reason], failure.cause === void 0 ? void 0 : { cause: failure.cause });
|
|
174
|
+
this.reason = reason;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/auth.ts
|
|
180
|
+
/**
|
|
181
|
+
* The Universal half of `Copilot_Auth`: the in-memory store doubles, the
|
|
182
|
+
* credential snapshot an operation carries, the one function that turns "no
|
|
183
|
+
* credential" into an actionable error, and the pure predicate that decides
|
|
184
|
+
* whether a token exchange is due.
|
|
185
|
+
*
|
|
186
|
+
* Storage is injected. Paths, the filesystem, and the environment belong to the
|
|
187
|
+
* Node auth package, never this Universal one (Requirement 6.1).
|
|
188
|
+
*
|
|
189
|
+
* ## Two tiers, one of which lives here
|
|
190
|
+
*
|
|
191
|
+
* The long-lived `GitHub_User_Token` is what a store persists; the short-lived
|
|
192
|
+
* `Copilot_Api_Token` obtained from it never reaches a store and lives only in
|
|
193
|
+
* the process cache (Requirements 3.1, 3.3). Exchanging does not consume the
|
|
194
|
+
* long-lived token, so the persisted value is left exactly as it was
|
|
195
|
+
* (Requirement 3.4) — this module has no write path at all for that reason.
|
|
196
|
+
*
|
|
197
|
+
* @module ai-agent-sdk/providers/copilot/auth
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* The command that produces a credential, named in every message that tells a
|
|
201
|
+
* caller how to fix a credential problem.
|
|
202
|
+
*
|
|
203
|
+
* Exported so the token-exchange path names the SAME command: a 401 and a 403
|
|
204
|
+
* there both end in "sign in again", and two copies of that string are two
|
|
205
|
+
* strings that can drift apart.
|
|
206
|
+
*/
|
|
207
|
+
const COPILOT_LOGIN_COMMAND = "npm run provider:copilot:login-device";
|
|
208
|
+
/**
|
|
209
|
+
* An in-memory {@link CopilotAuthStore} — the read/write variant (Requirement 6.4).
|
|
210
|
+
*
|
|
211
|
+
* The read/write variant exists for symmetry with Codex; normal runtime
|
|
212
|
+
* composition uses {@link memoryCopilotCredentialStore}, the compare-and-swap
|
|
213
|
+
* variant.
|
|
214
|
+
* @param initial - the file the store starts with, or nothing for an empty store.
|
|
215
|
+
* @returns a store backed by a single mutable slot.
|
|
216
|
+
*/
|
|
217
|
+
function memoryCopilotAuthStore(initial) {
|
|
218
|
+
let current = initial;
|
|
219
|
+
return {
|
|
220
|
+
location: "<memory>",
|
|
221
|
+
read: () => Promise.resolve(current),
|
|
222
|
+
write: (file) => {
|
|
223
|
+
current = file;
|
|
224
|
+
return Promise.resolve();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* An in-memory compare-and-swap store, for deterministic runtime and tests
|
|
230
|
+
* (Requirements 6.2, 6.4).
|
|
231
|
+
*
|
|
232
|
+
* Values are `structuredClone`d in BOTH directions, which is the point of this
|
|
233
|
+
* double: a caller that mutates the object it wrote, or the object it read, must
|
|
234
|
+
* not be able to change what the store holds. Without the clone a test could pass
|
|
235
|
+
* for the wrong reason — the store and the caller sharing one object rather than
|
|
236
|
+
* the store having committed anything.
|
|
237
|
+
*
|
|
238
|
+
* A commit whose `expectedRevision` disagrees with the current revision raises
|
|
239
|
+
* {@link COPILOT_ERROR_CODES.CREDENTIAL_REVISION_CONFLICT} (Requirement 6.3), the
|
|
240
|
+
* Copilot-owned code, so exactly one of two concurrent commits wins and the loser
|
|
241
|
+
* can tell why it lost.
|
|
242
|
+
* @param initial - the file the store starts with, or nothing for an empty store.
|
|
243
|
+
* @returns a compare-and-swap store over a single mutable slot.
|
|
244
|
+
*/
|
|
245
|
+
function memoryCopilotCredentialStore(initial) {
|
|
246
|
+
let current = initial === void 0 ? void 0 : structuredClone(initial);
|
|
247
|
+
let revision = 0;
|
|
248
|
+
return defineCredentialStore({
|
|
249
|
+
id: "copilot-memory-credentials",
|
|
250
|
+
label: "<memory>",
|
|
251
|
+
async read({ signal }) {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
return current === void 0 ? void 0 : {
|
|
254
|
+
value: structuredClone(current),
|
|
255
|
+
revision: String(revision)
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
async commit(input, { signal }) {
|
|
259
|
+
signal.throwIfAborted();
|
|
260
|
+
const expected = current === void 0 ? null : String(revision);
|
|
261
|
+
if (input.expectedRevision !== expected) throw new AgentSdkError("Copilot credential revision changed before commit", COPILOT_ERROR_CODES.CREDENTIAL_REVISION_CONFLICT);
|
|
262
|
+
current = structuredClone(input.value);
|
|
263
|
+
revision++;
|
|
264
|
+
return { revision: String(revision) };
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Require a usable `GitHub_User_Token`, with a message that says how to get one.
|
|
270
|
+
*
|
|
271
|
+
* Three shapes of "there is no credential" — an empty store, a file with no
|
|
272
|
+
* `github` field, and a `github.token` that is the empty string — collapse into
|
|
273
|
+
* the SAME code, because to the person reading the error they are one problem
|
|
274
|
+
* with one fix. That code is the SDK's own {@link MISSING_CREDENTIAL_CODE} rather
|
|
275
|
+
* than a Copilot-specific one, so a consumer does not have to write a second
|
|
276
|
+
* branch for a situation it already handles, and the message carries the command
|
|
277
|
+
* to run `Copilot_Login_Cli` (Requirement 13.4).
|
|
278
|
+
*
|
|
279
|
+
* The token value is never interpolated into the message; only the store's label
|
|
280
|
+
* is (Requirement 13.7).
|
|
281
|
+
* @param file - the credential file, or `undefined` when the store was empty.
|
|
282
|
+
* @param label - the store location named in the diagnostic.
|
|
283
|
+
* @returns the long-lived GitHub token.
|
|
284
|
+
*/
|
|
285
|
+
function requireGitHubToken(file, label) {
|
|
286
|
+
const github = file?.github;
|
|
287
|
+
if (github === void 0 || github === null || typeof github.token !== "string" || github.token.length === 0) throw new AgentSdkError(`no GitHub Copilot credentials at ${label}; run \`${COPILOT_LOGIN_COMMAND}\` to sign in`, MISSING_CREDENTIAL_CODE);
|
|
288
|
+
return github;
|
|
289
|
+
}
|
|
290
|
+
/** Exchange this long before the `Copilot_Api_Token` actually expires. */
|
|
291
|
+
const COPILOT_TOKEN_EXCHANGE_MARGIN_MS = 3e5;
|
|
292
|
+
/**
|
|
293
|
+
* Whether a token exchange has to happen before the next request.
|
|
294
|
+
*
|
|
295
|
+
* A pure function of three values that reads no global clock, so a property test
|
|
296
|
+
* can place `now` at every boundary without a fake timer (Requirements 5.2, 5.3).
|
|
297
|
+
* `undefined` — no token yet — is always `true`.
|
|
298
|
+
*
|
|
299
|
+
* `expires_at` is the authority and `refresh_in` is advisory: the hint may only
|
|
300
|
+
* SHORTEN the refresh moment, never lengthen it. The endpoint is allowed to ask
|
|
301
|
+
* for an earlier exchange; it is not allowed to ask this SDK to hold a token past
|
|
302
|
+
* the expiry it announced itself.
|
|
303
|
+
*
|
|
304
|
+
* There is deliberately no fallback branch. `shouldRefresh` in
|
|
305
|
+
* `provider-codex/src/auth.ts` decodes a JWT for `exp` and falls back to a
|
|
306
|
+
* `last_refresh` age when it cannot; a `Copilot_Api_Token` is not a JWT this SDK
|
|
307
|
+
* has any business reading, and the expiry is stated outright in the exchange
|
|
308
|
+
* response body. With no second source, a fallback would have to invent a
|
|
309
|
+
* lifetime, and an invented lifetime violates the no-inference principle.
|
|
310
|
+
*
|
|
311
|
+
* Because the decision is made BEFORE dispatch, a 401 from the Copilot base URL
|
|
312
|
+
* always means the credential is genuinely dead rather than "the token expired
|
|
313
|
+
* mid-flight" — which is what lets auth failures stay non-retryable
|
|
314
|
+
* (Requirement 5.8).
|
|
315
|
+
* @param api - the cached token, or `undefined` when there is none.
|
|
316
|
+
* @param now - current time in epoch milliseconds.
|
|
317
|
+
* @param marginMs - exchange this long before expiry.
|
|
318
|
+
* @returns true when an exchange is due.
|
|
319
|
+
*/
|
|
320
|
+
function shouldExchange(api, now, marginMs = COPILOT_TOKEN_EXCHANGE_MARGIN_MS) {
|
|
321
|
+
if (api === void 0) return true;
|
|
322
|
+
const advisory = api.refreshInSeconds === void 0 ? Number.POSITIVE_INFINITY : api.expiresAtMs - api.refreshInSeconds * 1e3;
|
|
323
|
+
return Math.min(api.expiresAtMs - marginMs, advisory) <= now;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/common/identity.ts
|
|
328
|
+
/**
|
|
329
|
+
* The two Copilot endpoint constants and the editor-header override type, in the
|
|
330
|
+
* leaf layer so every module that has to speak to the Copilot surface can reach
|
|
331
|
+
* them.
|
|
332
|
+
*
|
|
333
|
+
* They BELONG to `../adapter.ts` — that module is the public door, documents the
|
|
334
|
+
* client-identity tradeoff, and re-exports everything here. The values live one
|
|
335
|
+
* layer down for the same structural reason `COPILOT_ERROR_CODES` does:
|
|
336
|
+
* `../catalog.ts` needs {@link COPILOT_BASE_URL} and `../exchange.ts` needs the
|
|
337
|
+
* two editor headers, while `../adapter.ts` builds the catalog reader and the
|
|
338
|
+
* token cache. Declaring the constants in `../adapter.ts` would make that edge
|
|
339
|
+
* run both ways, which the repo's circular-dependency check forbids. Copying the
|
|
340
|
+
* strings instead would be worse: a client identity that exists in two places is
|
|
341
|
+
* a client identity that can disagree with itself.
|
|
342
|
+
*
|
|
343
|
+
* Read `../adapter.ts` for what these values mean, why they are overridable
|
|
344
|
+
* options rather than hidden constants, and what is still outstanding on each.
|
|
345
|
+
*
|
|
346
|
+
* @module ai-agent-sdk/providers/copilot/identity
|
|
347
|
+
*/
|
|
348
|
+
/** The Copilot API base. Documented on the re-export in `../adapter.ts`. */
|
|
349
|
+
const COPILOT_BASE_URL = "https://api.githubcopilot.com";
|
|
350
|
+
/** Default `Editor-Version`. Documented on the re-export in `../adapter.ts`. */
|
|
351
|
+
const COPILOT_EDITOR_VERSION = "vscode/1.99.0";
|
|
352
|
+
/** Default `Editor-Plugin-Version`. Documented on the re-export in `../adapter.ts`. */
|
|
353
|
+
const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.26.0";
|
|
354
|
+
/**
|
|
355
|
+
* Resolve each editor header from the override first and the exported default
|
|
356
|
+
* second.
|
|
357
|
+
*
|
|
358
|
+
* Per field rather than per object: an override of one header leaves the other at
|
|
359
|
+
* its default instead of dropping it, because a dropped editor header is an HTTP
|
|
360
|
+
* 400 (Requirement 2.4).
|
|
361
|
+
* @param headers - the caller's overrides, when they set any.
|
|
362
|
+
* @returns both header values, neither of them empty.
|
|
363
|
+
*/
|
|
364
|
+
function resolveCopilotEditorHeaders(headers) {
|
|
365
|
+
return Object.freeze({
|
|
366
|
+
editorVersion: headers?.editorVersion ?? "vscode/1.99.0",
|
|
367
|
+
editorPluginVersion: headers?.editorPluginVersion ?? "copilot-chat/0.26.0"
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/common/no-follow.ts
|
|
373
|
+
/**
|
|
374
|
+
* The redirect guard every Copilot HTTP call passes through.
|
|
375
|
+
*
|
|
376
|
+
* `redirect: 'manual'` on the request is only half of a no-follow policy: it
|
|
377
|
+
* stops the runtime from following a hop, but it does not stop the CALLER from
|
|
378
|
+
* treating the result as a normal response. This module is the other half — it
|
|
379
|
+
* turns every shape a redirect can take into a structured error before a second
|
|
380
|
+
* request can be dispatched.
|
|
381
|
+
*
|
|
382
|
+
* There are four shapes, and a check for only one of them is a hole:
|
|
383
|
+
*
|
|
384
|
+
* - **A 3xx status** — the ordinary case, visible because `redirect: 'manual'`
|
|
385
|
+
* surfaces the response instead of following it.
|
|
386
|
+
* - **`type === 'opaqueredirect'`** — what a browser returns instead of the 3xx,
|
|
387
|
+
* with the status flattened to `0` and the headers stripped. A status-only
|
|
388
|
+
* check misses this entirely.
|
|
389
|
+
* - **`redirected === true`** — a hop that was already followed, by a runtime or
|
|
390
|
+
* an intermediary that ignored `redirect: 'manual'`.
|
|
391
|
+
* - **`response.url` differing from the requested URL** — the last resort, for a
|
|
392
|
+
* runtime that reports neither of the flags above but still moved the request.
|
|
393
|
+
*
|
|
394
|
+
* The body is RELEASED before the error is thrown. A rejected response whose body
|
|
395
|
+
* is never cancelled holds a socket open for as long as the runtime keeps the
|
|
396
|
+
* stream alive, so the guard cannot leave that to the caller's `finally`.
|
|
397
|
+
*
|
|
398
|
+
* @module ai-agent-sdk/providers/copilot/no-follow
|
|
399
|
+
*/
|
|
400
|
+
/**
|
|
401
|
+
* Reject every redirect shape Web fetch exposes, before any second request.
|
|
402
|
+
*
|
|
403
|
+
* @param response - the response as returned by a `redirect: 'manual'` fetch.
|
|
404
|
+
* @param requestedUrl - the absolute URL that was requested, for the
|
|
405
|
+
* `response.url` comparison.
|
|
406
|
+
* @param operation - which Copilot call site is refusing the hop.
|
|
407
|
+
* @param teardownTimeoutMs - bound on the body cancellation, so a stream that
|
|
408
|
+
* never settles cannot hold the rejection open forever.
|
|
409
|
+
* @returns nothing when the response is not a redirect in any of its four shapes.
|
|
410
|
+
* @throws AgentSdkError with `COPILOT_REDIRECT_REJECTED` when it is.
|
|
411
|
+
*/
|
|
412
|
+
async function rejectCopilotRedirect(response, requestedUrl, operation, teardownTimeoutMs) {
|
|
413
|
+
const redirectStatus = response.status >= 300 && response.status < 400;
|
|
414
|
+
const responseUrlChanged = response.url.length > 0 && response.url !== requestedUrl;
|
|
415
|
+
if (response.type !== "opaqueredirect" && response.redirected !== true && !redirectStatus && !responseUrlChanged) return;
|
|
416
|
+
if (response.body !== null) await waitForSettlement(response.body.cancel().catch(() => void 0), teardownTimeoutMs);
|
|
417
|
+
throw new AgentSdkError(`Copilot ${operation} rejected a redirect before following it`, COPILOT_ERROR_CODES.REDIRECT_REJECTED);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/common/http.ts
|
|
422
|
+
/**
|
|
423
|
+
* The one HTTP door for every Copilot call: origin pinning, no-follow, bounded
|
|
424
|
+
* reads, and a caller signal that wins immediately.
|
|
425
|
+
*
|
|
426
|
+
* This is the counterpart of `oauthFetch` in `provider-codex/src/oauth.ts`, kept
|
|
427
|
+
* deliberately close to it — the same guarantees, in the same order, so a reader
|
|
428
|
+
* who knows one knows the other. What differs is scope: Codex has one auth
|
|
429
|
+
* issuer, Copilot has THREE origins, and each is its own option.
|
|
430
|
+
*
|
|
431
|
+
* ## Three origins, three independent pins
|
|
432
|
+
*
|
|
433
|
+
* `oauthIssuer` (`https://github.com`), `githubApiBaseUrl`
|
|
434
|
+
* (`https://api.github.com`) and `baseUrl` (`https://api.githubcopilot.com`) are
|
|
435
|
+
* three separate options, pinned separately by three separate calls to {@link
|
|
436
|
+
* issuerOf}. No module may dispatch a request to an origin other than its own
|
|
437
|
+
* pinned one — the device flow cannot reach the Copilot surface, the Copilot
|
|
438
|
+
* surface cannot reach the token exchange. That is why {@link copilotFetch}
|
|
439
|
+
* demands a {@link CopilotOrigin} rather than reading an origin off a shared
|
|
440
|
+
* options bag: there is no options bag that holds all three, so there is no way
|
|
441
|
+
* to pass the wrong one by forgetting which field applies.
|
|
442
|
+
*
|
|
443
|
+
* The pin is compared BEFORE the request is dispatched (Requirement 3.7). A
|
|
444
|
+
* post-hoc check on the response would already have leaked the `Authorization`
|
|
445
|
+
* header to whatever origin the URL named.
|
|
446
|
+
*
|
|
447
|
+
* ## What is bounded, and why each bound exists
|
|
448
|
+
*
|
|
449
|
+
* - **`redirect: 'manual'` plus {@link rejectCopilotRedirect}** — a followed hop
|
|
450
|
+
* re-sends the credential headers to the redirect target (Requirements 3.8, 7.8).
|
|
451
|
+
* - **A per-request deadline** — a server that accepts the connection and then
|
|
452
|
+
* says nothing must not hang a CLI.
|
|
453
|
+
* - **Bytes AND chunk count on every read** — bytes alone still lets a stream of
|
|
454
|
+
* one-byte chunks pin the event loop, so both are checked (Requirements 4.7, 13.6).
|
|
455
|
+
* - **{@link raceAbort}** — `fetch` honours a signal, but a pending read does not
|
|
456
|
+
* necessarily reject the instant it aborts. Racing makes the caller's signal win
|
|
457
|
+
* immediately rather than eventually (Requirement 4.6).
|
|
458
|
+
* - **{@link positiveSafeInteger} on every configured limit** — a `0`, a `NaN` or
|
|
459
|
+
* a float silently disables a bound, which is worse than rejecting the config.
|
|
460
|
+
*
|
|
461
|
+
* @module ai-agent-sdk/providers/copilot/http
|
|
462
|
+
*/
|
|
463
|
+
/** Deadline for one Copilot HTTP request when the caller configures none. */
|
|
464
|
+
const COPILOT_DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
465
|
+
/** Maximum response bytes retained or parsed when the caller configures none. */
|
|
466
|
+
const COPILOT_DEFAULT_MAX_RESPONSE_BYTES = 1048576;
|
|
467
|
+
/** Maximum response chunks accepted when the caller configures none. */
|
|
468
|
+
const COPILOT_DEFAULT_MAX_RESPONSE_CHUNKS = 1e4;
|
|
469
|
+
/** Bound on releasing a body that is being discarded. Never a caller-visible wait. */
|
|
470
|
+
const TEARDOWN_TIMEOUT_MS = 3e4;
|
|
471
|
+
/**
|
|
472
|
+
* Validate and pin one of the three configurable origins.
|
|
473
|
+
*
|
|
474
|
+
* Two rejections, each for a concrete reason:
|
|
475
|
+
*
|
|
476
|
+
* - **Userinfo** (`https://user:pass@host`) — credentials in a URL would be sent
|
|
477
|
+
* as an extra `Authorization` header the caller never wrote, and they end up in
|
|
478
|
+
* logs. There is no legitimate use for them on any of these three origins.
|
|
479
|
+
* - **`http:` without `allowInsecureIssuer`** — see the option's note.
|
|
480
|
+
*
|
|
481
|
+
* @param field - which option is being pinned; appears in the error message.
|
|
482
|
+
* @param configured - the caller's value, or `undefined` to take the default.
|
|
483
|
+
* @param fallback - the exported default for this field.
|
|
484
|
+
* @param options - read for `allowInsecureIssuer` only.
|
|
485
|
+
* @returns the pin to hand to {@link copilotFetch}.
|
|
486
|
+
* @throws AgentSdkError with `COPILOT_ENDPOINT_ORIGIN_INVALID` when the value is
|
|
487
|
+
* unparsable, carries userinfo, or is cleartext without the opt-in.
|
|
488
|
+
*/
|
|
489
|
+
function issuerOf(field, configured, fallback, options = {}) {
|
|
490
|
+
const raw = configured ?? fallback;
|
|
491
|
+
let url;
|
|
492
|
+
try {
|
|
493
|
+
url = new URL(raw);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
throw originError(`Copilot ${field} is not an absolute URL`, error);
|
|
496
|
+
}
|
|
497
|
+
if (url.username.length > 0 || url.password.length > 0) throw originError(`Copilot ${field} must not contain credentials`);
|
|
498
|
+
if (url.protocol !== "https:" && !(options.allowInsecureIssuer === true && url.protocol === "http:")) throw originError(`Copilot ${field} must use https unless allowInsecureIssuer is enabled`);
|
|
499
|
+
return Object.freeze({
|
|
500
|
+
field,
|
|
501
|
+
href: url.href.replace(/\/+$/, ""),
|
|
502
|
+
origin: url.origin
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Build an absolute URL on a pinned origin.
|
|
507
|
+
*
|
|
508
|
+
* The path has to be absolute-and-rooted: a relative path resolved against a base
|
|
509
|
+
* is exactly how a URL quietly ends up somewhere else, and a path that is itself
|
|
510
|
+
* absolute (`//evil.tld/x` or `https://evil.tld/x`) would replace the origin
|
|
511
|
+
* outright.
|
|
512
|
+
* @param pinned - the pin from {@link issuerOf}.
|
|
513
|
+
* @param path - a path beginning with a single `/`.
|
|
514
|
+
* @returns the absolute URL string, guaranteed to be on `pinned.origin`.
|
|
515
|
+
* @throws AgentSdkError with `COPILOT_ENDPOINT_ORIGIN_INVALID` when the path could
|
|
516
|
+
* move the request off the pin.
|
|
517
|
+
*/
|
|
518
|
+
function copilotUrl(pinned, path) {
|
|
519
|
+
if (!path.startsWith("/") || path.startsWith("//")) throw originError(`Copilot ${pinned.field} path must start with a single '/'`);
|
|
520
|
+
const url = new URL(`${pinned.href}${path}`);
|
|
521
|
+
if (url.origin !== pinned.origin) throw originError(`Copilot ${pinned.field} path must stay on the pinned origin`);
|
|
522
|
+
return url.href;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Dispatch one Copilot request with the origin pin, no-follow and deadline applied.
|
|
526
|
+
*
|
|
527
|
+
* Order matters and is the contract: the pin is compared FIRST, so a URL on the
|
|
528
|
+
* wrong origin never receives the headers; then the request goes out with
|
|
529
|
+
* `redirect: 'manual'`; then the response passes the redirect guard before it is
|
|
530
|
+
* handed back. The body is left unread — {@link readCopilotResponseText} is the
|
|
531
|
+
* bounded reader for it.
|
|
532
|
+
* @param request - the pin, the URL, the call site and the init.
|
|
533
|
+
* @param options - signal, fetch implementation and limits.
|
|
534
|
+
* @returns the response, already cleared by the redirect guard.
|
|
535
|
+
* @throws AgentSdkError with `COPILOT_ENDPOINT_ORIGIN_INVALID` when the URL is off
|
|
536
|
+
* the pin, or `COPILOT_REDIRECT_REJECTED` when the response was a redirect.
|
|
537
|
+
*/
|
|
538
|
+
async function copilotFetch(request, options = {}) {
|
|
539
|
+
const url = requestUrl(request);
|
|
540
|
+
const timeoutMs = positiveSafeInteger(options.requestTimeoutMs ?? 3e4, "requestTimeoutMs");
|
|
541
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
542
|
+
const signal = options.signal === void 0 ? timeout : AbortSignal.any([options.signal, timeout]);
|
|
543
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
544
|
+
if (typeof fetchImpl !== "function") throw new TypeError("Copilot HTTP requires fetch");
|
|
545
|
+
const response = await raceAbort(Promise.resolve(fetchImpl(url, {
|
|
546
|
+
...request.init,
|
|
547
|
+
signal,
|
|
548
|
+
redirect: "manual"
|
|
549
|
+
})), signal);
|
|
550
|
+
await rejectCopilotRedirect(response, url, request.operation, TEARDOWN_TIMEOUT_MS);
|
|
551
|
+
return response;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Read a response body as text, bounded on bytes and on chunk count.
|
|
555
|
+
*
|
|
556
|
+
* A declared `content-length` over the limit is refused before a single chunk is
|
|
557
|
+
* read; the running totals then catch a body that lies about its length or sends
|
|
558
|
+
* none. Either way the reader is cancelled rather than abandoned.
|
|
559
|
+
* @param response - a response already cleared by {@link copilotFetch}.
|
|
560
|
+
* @param options - limits and the signal to race the read against.
|
|
561
|
+
* @returns the decoded text, or `''` when there was no body.
|
|
562
|
+
* @throws RangeError when a configured bound is exceeded.
|
|
563
|
+
*/
|
|
564
|
+
async function readCopilotResponseText(response, options = {}) {
|
|
565
|
+
const maxBytes = positiveSafeInteger(options.maxResponseBytes ?? 1048576, "maxResponseBytes");
|
|
566
|
+
const maxChunks = positiveSafeInteger(options.maxResponseChunks ?? 1e4, "maxResponseChunks");
|
|
567
|
+
const declared = Number(response.headers.get("content-length"));
|
|
568
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
569
|
+
if (response.body !== null) await waitForSettlement(response.body.cancel().catch(() => void 0), TEARDOWN_TIMEOUT_MS);
|
|
570
|
+
throw new RangeError(`Copilot HTTP response exceeds the ${maxBytes}-byte limit`);
|
|
571
|
+
}
|
|
572
|
+
if (response.body === null) return "";
|
|
573
|
+
const timeout = AbortSignal.timeout(positiveSafeInteger(options.requestTimeoutMs ?? 3e4, "requestTimeoutMs"));
|
|
574
|
+
const signal = options.signal === void 0 ? timeout : AbortSignal.any([options.signal, timeout]);
|
|
575
|
+
const reader = response.body.getReader();
|
|
576
|
+
const decoder = new TextDecoder();
|
|
577
|
+
let bytes = 0;
|
|
578
|
+
let chunks = 0;
|
|
579
|
+
let result = "";
|
|
580
|
+
try {
|
|
581
|
+
while (true) {
|
|
582
|
+
const next = await raceAbort(reader.read(), signal);
|
|
583
|
+
if (next.done) return result + decoder.decode();
|
|
584
|
+
if (next.value === void 0) continue;
|
|
585
|
+
chunks++;
|
|
586
|
+
bytes += next.value.byteLength;
|
|
587
|
+
if (chunks > maxChunks || bytes > maxBytes) {
|
|
588
|
+
await waitForSettlement(reader.cancel().catch(() => void 0), TEARDOWN_TIMEOUT_MS);
|
|
589
|
+
throw new RangeError("Copilot HTTP response exceeds its configured resource limit");
|
|
590
|
+
}
|
|
591
|
+
result += decoder.decode(next.value, { stream: true });
|
|
592
|
+
}
|
|
593
|
+
} finally {
|
|
594
|
+
reader.releaseLock();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Settle as soon as either the pending work or the signal does.
|
|
599
|
+
*
|
|
600
|
+
* An already-aborted signal rejects synchronously rather than after one turn, so
|
|
601
|
+
* a caller who aborts before the call never dispatches the request at all.
|
|
602
|
+
* @param pending - the work to race.
|
|
603
|
+
* @param signal - the signal that gets to win.
|
|
604
|
+
* @returns the pending value, when it arrives first.
|
|
605
|
+
*/
|
|
606
|
+
function raceAbort(pending, signal) {
|
|
607
|
+
if (signal.aborted) return Promise.reject(abortReason(signal));
|
|
608
|
+
return new Promise((resolve, reject) => {
|
|
609
|
+
const abort = () => {
|
|
610
|
+
cleanup();
|
|
611
|
+
reject(abortReason(signal));
|
|
612
|
+
};
|
|
613
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
614
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
615
|
+
pending.then((value) => {
|
|
616
|
+
cleanup();
|
|
617
|
+
resolve(value);
|
|
618
|
+
}, (error) => {
|
|
619
|
+
cleanup();
|
|
620
|
+
reject(error);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Accept a configured limit only when it can actually bound anything.
|
|
626
|
+
*
|
|
627
|
+
* `0`, a negative, a float and `NaN` all disable a bound silently, so each one is
|
|
628
|
+
* rejected instead of normalized.
|
|
629
|
+
* @param value - the configured number.
|
|
630
|
+
* @param field - the option name, for the message.
|
|
631
|
+
* @returns the value, unchanged.
|
|
632
|
+
* @throws RangeError when the value cannot serve as a bound.
|
|
633
|
+
*/
|
|
634
|
+
function positiveSafeInteger(value, field) {
|
|
635
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`Copilot HTTP ${field} must be a positive safe integer`);
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Resolve the request URL and compare it against the pin, before anything is sent.
|
|
640
|
+
*
|
|
641
|
+
* Userinfo is rejected here as well as in {@link issuerOf}: `URL.origin` ignores
|
|
642
|
+
* it, so a URL on the right origin can still carry credentials the caller never
|
|
643
|
+
* intended to send.
|
|
644
|
+
*/
|
|
645
|
+
function requestUrl(request) {
|
|
646
|
+
let url;
|
|
647
|
+
try {
|
|
648
|
+
url = new URL(request.url);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
throw originError(`Copilot ${request.operation} target is not an absolute URL`, error);
|
|
651
|
+
}
|
|
652
|
+
if (url.username.length > 0 || url.password.length > 0) throw originError(`Copilot ${request.operation} target must not contain credentials`);
|
|
653
|
+
if (url.origin !== request.pinned.origin) throw originError(`Copilot ${request.operation} target origin '${url.origin}' is not the pinned ${request.pinned.field} origin '${request.pinned.origin}'`);
|
|
654
|
+
return url.href;
|
|
655
|
+
}
|
|
656
|
+
function originError(message, cause) {
|
|
657
|
+
return new AgentSdkError(message, COPILOT_ERROR_CODES.ENDPOINT_ORIGIN_INVALID, cause === void 0 ? void 0 : { cause });
|
|
658
|
+
}
|
|
659
|
+
function abortReason(signal) {
|
|
660
|
+
return signal.reason ?? new AgentSdkError("Copilot HTTP request aborted", MODEL_ERROR_CODES.ABORTED);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/catalog.ts
|
|
665
|
+
/**
|
|
666
|
+
* `Copilot_Catalog`: read `GET /models`, then PARTITION what came back.
|
|
667
|
+
*
|
|
668
|
+
* Discovery is the right default for this surface (Requirement 8.1): which models
|
|
669
|
+
* an account may call depends on its plan, on its organisation's policy, and on
|
|
670
|
+
* the editor identity the request presents, so no hardcoded list is correct for
|
|
671
|
+
* two accounts at once. Passing `models` explicitly skips discovery entirely
|
|
672
|
+
* (Requirement 8.5) — that decision belongs to the adapter, which simply does not
|
|
673
|
+
* call this module in that case.
|
|
674
|
+
*
|
|
675
|
+
* ## Two levels of wrongness, two different answers
|
|
676
|
+
*
|
|
677
|
+
* The defensive read runs in a fixed order, and the order IS the contract:
|
|
678
|
+
*
|
|
679
|
+
* ```text
|
|
680
|
+
* 1. redirect (every shape) ⇒ COPILOT_REDIRECT_REJECTED
|
|
681
|
+
* 2. declared content-length over the limit ⇒ RangeError, body cancelled
|
|
682
|
+
* 3. accumulated bytes/chunks over the limit ⇒ RangeError, reader cancelled
|
|
683
|
+
* 4. body is not JSON, root is not an object,
|
|
684
|
+
* or `data` is not an array ⇒ COPILOT_CATALOG_MALFORMED
|
|
685
|
+
* 5. entry count over maxCatalogModels ⇒ COPILOT_CATALOG_MALFORMED
|
|
686
|
+
* 6. entry: id is not a non-empty string ⇒ omitted 'model-id-missing'
|
|
687
|
+
* 7. entry: capabilities.type unrecognized ⇒ omitted 'capability-type-unrecognized'
|
|
688
|
+
* ```
|
|
689
|
+
*
|
|
690
|
+
* Steps 4 and 5 are STRUCTURAL, and a structural mismatch is an error rather than
|
|
691
|
+
* a starting point for a guess (Requirement 8.8): a model list inferred from a
|
|
692
|
+
* body this SDK could not read is a list nobody can be held to. Steps 6 and 7 are
|
|
693
|
+
* at ENTRY level, and there the entry is dropped while the rest of the catalog
|
|
694
|
+
* survives — one unfamiliar entry must not kill every model that still works.
|
|
695
|
+
*
|
|
696
|
+
* Dropping rather than listing-with-a-flag is the same judgement in the other
|
|
697
|
+
* direction (Requirements 9.4, 9.5): listing a model this SDK cannot dispatch is
|
|
698
|
+
* worse than not listing it, because it shows up in a selector and then fails at
|
|
699
|
+
* call time, far from the cause.
|
|
700
|
+
*
|
|
701
|
+
* ## Metadata is translated, never invented
|
|
702
|
+
*
|
|
703
|
+
* Every field of {@link ProviderCatalogModel} is filled only from a field the
|
|
704
|
+
* endpoint actually supplied (Requirement 8.4). The trap is
|
|
705
|
+
* `inputModalities`: with no vision signal at all the field is ABSENT, NOT
|
|
706
|
+
* `['text']`. An explicit list without `image` is a NEGATIVE claim the registry
|
|
707
|
+
* acts on — it projects images to text — so inventing `['text']` would silently
|
|
708
|
+
* strip images from every request to a model that may well accept them. Absent
|
|
709
|
+
* means unknown, and unknown is what the endpoint said.
|
|
710
|
+
*
|
|
711
|
+
* `declaredEndpoint` follows the same rule and stays `undefined` when the catalog
|
|
712
|
+
* discloses nothing. `undefined` is NOT "not supported"; the router treats the two
|
|
713
|
+
* states differently (Requirement 8.6).
|
|
714
|
+
*
|
|
715
|
+
* ## The catalog is advisory
|
|
716
|
+
*
|
|
717
|
+
* `omitted` does not fail anything. A dispatched request to an omitted id still
|
|
718
|
+
* goes out — it just takes the router's default branch — and a real error from the
|
|
719
|
+
* endpoint remains the final word whenever metadata and behaviour disagree
|
|
720
|
+
* (Requirement 8.6).
|
|
721
|
+
*
|
|
722
|
+
* @module ai-agent-sdk/providers/copilot/catalog
|
|
723
|
+
*/
|
|
724
|
+
/** Path of the catalog surface, relative to the pinned Copilot base URL. */
|
|
725
|
+
const COPILOT_CATALOG_PATH = "/models";
|
|
726
|
+
/** Maximum raw catalog bytes when the caller configures none. */
|
|
727
|
+
const COPILOT_DEFAULT_MAX_CATALOG_BYTES = 4194304;
|
|
728
|
+
/** Maximum catalog entries accepted when the caller configures none. */
|
|
729
|
+
const COPILOT_DEFAULT_MAX_CATALOG_MODELS = 2048;
|
|
730
|
+
/** Maximum catalog response chunks accepted when the caller configures none. */
|
|
731
|
+
const COPILOT_DEFAULT_MAX_CATALOG_CHUNKS = 1e4;
|
|
732
|
+
/** Catalog request deadline when the caller configures none. */
|
|
733
|
+
const COPILOT_DEFAULT_CATALOG_TIMEOUT_MS = 3e4;
|
|
734
|
+
/**
|
|
735
|
+
* Resolve the per-read bounds, rejecting a value that cannot bound anything.
|
|
736
|
+
*
|
|
737
|
+
* Validation happens here rather than at the read, so a `0` or a `NaN` in the
|
|
738
|
+
* configuration is a construction-time error instead of a silently disabled limit
|
|
739
|
+
* discovered under load (Requirement 8.2).
|
|
740
|
+
* @param options - the caller's catalog options.
|
|
741
|
+
* @returns the four resolved bounds plus the insecure-HTTP opt-in.
|
|
742
|
+
* @throws RangeError when a configured bound is not a positive safe integer.
|
|
743
|
+
*/
|
|
744
|
+
function resolveCopilotCatalogLimits(options = {}) {
|
|
745
|
+
return Object.freeze({
|
|
746
|
+
maxBytes: positiveSafeInteger(options.maxCatalogBytes ?? 4194304, "maxCatalogBytes"),
|
|
747
|
+
maxModels: positiveSafeInteger(options.maxCatalogModels ?? 2048, "maxCatalogModels"),
|
|
748
|
+
maxChunks: positiveSafeInteger(options.maxCatalogChunks ?? 1e4, "maxCatalogChunks"),
|
|
749
|
+
timeoutMs: positiveSafeInteger(options.catalogTimeoutMs ?? 3e4, "catalogTimeoutMs"),
|
|
750
|
+
...options.allowInsecureHttp === void 0 ? {} : { allowInsecureHttp: options.allowInsecureHttp }
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Forward the three cache-policy options, and only the ones that were set.
|
|
755
|
+
*
|
|
756
|
+
* A conditional spread rather than defaults: `provider-http` owns catalog caching,
|
|
757
|
+
* and a default written here would override the runtime's own without anyone
|
|
758
|
+
* asking for it (Requirement 8.7).
|
|
759
|
+
* @param options - the caller's catalog options.
|
|
760
|
+
* @returns an object carrying only the cache options the caller supplied.
|
|
761
|
+
*/
|
|
762
|
+
function copilotCatalogCacheOptions(options = {}) {
|
|
763
|
+
return {
|
|
764
|
+
...options.catalogTtlMs === void 0 ? {} : { catalogTtlMs: options.catalogTtlMs },
|
|
765
|
+
...options.catalogStaleTtlMs === void 0 ? {} : { catalogStaleTtlMs: options.catalogStaleTtlMs },
|
|
766
|
+
...options.catalogFailureBackoffMs === void 0 ? {} : { catalogFailureBackoffMs: options.catalogFailureBackoffMs }
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Read `GET {baseUrl}/models` and partition it.
|
|
771
|
+
*
|
|
772
|
+
* The base URL is re-pinned here from `context.baseUrl` rather than trusted as a
|
|
773
|
+
* string: the catalog is the first Copilot call an adapter makes, and a pin
|
|
774
|
+
* compared before dispatch is the only check that runs before the resolved
|
|
775
|
+
* `Authorization` header leaves the process.
|
|
776
|
+
* @param context - the discovery context `provider-http` supplies: base URL,
|
|
777
|
+
* already-resolved headers, and the operation's signal.
|
|
778
|
+
* @param limits - bounds from {@link resolveCopilotCatalogLimits}.
|
|
779
|
+
* @param fetchImpl - HTTP implementation, injected for tests and non-browser runtimes.
|
|
780
|
+
* @returns the partitioned snapshot; an empty one when the endpoint answered a
|
|
781
|
+
* non-2xx status, because a catalog that could not be fetched is advisory too.
|
|
782
|
+
* @throws AgentSdkError with `COPILOT_REDIRECT_REJECTED` on any redirect shape, or
|
|
783
|
+
* `COPILOT_CATALOG_MALFORMED` when the response is the wrong shape structurally.
|
|
784
|
+
* @throws RangeError when the response exceeds a configured bound.
|
|
785
|
+
*/
|
|
786
|
+
async function discoverCopilotModels(context, limits, fetchImpl) {
|
|
787
|
+
const pinned = issuerOf("baseUrl", context.baseUrl.href, COPILOT_BASE_URL, { ...limits.allowInsecureHttp === void 0 ? {} : { allowInsecureIssuer: limits.allowInsecureHttp } });
|
|
788
|
+
const url = copilotUrl(pinned, COPILOT_CATALOG_PATH);
|
|
789
|
+
const http = {
|
|
790
|
+
signal: context.signal,
|
|
791
|
+
fetch: fetchImpl,
|
|
792
|
+
requestTimeoutMs: limits.timeoutMs,
|
|
793
|
+
maxResponseBytes: limits.maxBytes,
|
|
794
|
+
maxResponseChunks: limits.maxChunks,
|
|
795
|
+
...limits.allowInsecureHttp === void 0 ? {} : { allowInsecureIssuer: limits.allowInsecureHttp }
|
|
796
|
+
};
|
|
797
|
+
const response = await copilotFetch({
|
|
798
|
+
pinned,
|
|
799
|
+
url,
|
|
800
|
+
operation: "model catalog",
|
|
801
|
+
init: {
|
|
802
|
+
method: "GET",
|
|
803
|
+
headers: context.headers
|
|
804
|
+
}
|
|
805
|
+
}, http);
|
|
806
|
+
if (!response.ok) {
|
|
807
|
+
if (response.body !== null) await response.body.cancel().catch(() => void 0);
|
|
808
|
+
return EMPTY_SNAPSHOT;
|
|
809
|
+
}
|
|
810
|
+
return partitionCopilotCatalog(parseCatalogBody(await readCopilotResponseText(response, http)), limits.maxModels);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Partition an already-read catalog body.
|
|
814
|
+
*
|
|
815
|
+
* Exported separately from the fetch so the partition is testable — and readable —
|
|
816
|
+
* as what it is: a pure function from a parsed body to three lists.
|
|
817
|
+
* @param body - the parsed root object of the catalog response.
|
|
818
|
+
* @param maxModels - entry-count ceiling; exceeding it is structural, not per-entry.
|
|
819
|
+
* @returns the partitioned snapshot.
|
|
820
|
+
* @throws AgentSdkError with `COPILOT_CATALOG_MALFORMED` when `data` is not an
|
|
821
|
+
* array or holds more than `maxModels` entries.
|
|
822
|
+
*/
|
|
823
|
+
function partitionCopilotCatalog(body, maxModels) {
|
|
824
|
+
const data = body.data;
|
|
825
|
+
if (!Array.isArray(data)) throw malformed$1("Copilot model catalog `data` must be an array");
|
|
826
|
+
if (data.length > maxModels) throw malformed$1(`Copilot model catalog exceeds the ${maxModels}-model limit`);
|
|
827
|
+
const generation = [];
|
|
828
|
+
const embedding = [];
|
|
829
|
+
const omitted = [];
|
|
830
|
+
for (const candidate of data) {
|
|
831
|
+
const entry = isRecord(candidate) ? candidate : {};
|
|
832
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
833
|
+
if (id.length === 0) {
|
|
834
|
+
omitted.push({
|
|
835
|
+
id,
|
|
836
|
+
reason: "model-id-missing"
|
|
837
|
+
});
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const type = entry.capabilities?.type;
|
|
841
|
+
if (type === "chat") {
|
|
842
|
+
generation.push(generationModel(id, entry));
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (type === "embeddings") {
|
|
846
|
+
embedding.push(embeddingModel(id, entry));
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
omitted.push({
|
|
850
|
+
id,
|
|
851
|
+
reason: "capability-type-unrecognized"
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
return Object.freeze({
|
|
855
|
+
generation: Object.freeze(generation),
|
|
856
|
+
embedding: Object.freeze(embedding),
|
|
857
|
+
omitted: Object.freeze(omitted)
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/** The snapshot returned when there is nothing to report, frozen and shared. */
|
|
861
|
+
const EMPTY_SNAPSHOT = Object.freeze({
|
|
862
|
+
generation: Object.freeze([]),
|
|
863
|
+
embedding: Object.freeze([]),
|
|
864
|
+
omitted: Object.freeze([])
|
|
865
|
+
});
|
|
866
|
+
/** Modalities claimed when — and only when — a vision signal was actually present. */
|
|
867
|
+
const TEXT_AND_IMAGE = Object.freeze(["text", "image"]);
|
|
868
|
+
/**
|
|
869
|
+
* Translate one `type: 'chat'` entry, filling only what the endpoint supplied.
|
|
870
|
+
*
|
|
871
|
+
* `name` is not defaulted to `id`: a display label the endpoint did not send is a
|
|
872
|
+
* label this layer would be inventing, and the layer that renders a selector
|
|
873
|
+
* already falls back to the id.
|
|
874
|
+
*/
|
|
875
|
+
function generationModel(id, entry) {
|
|
876
|
+
const limits = entry.capabilities?.limits;
|
|
877
|
+
const supports = entry.capabilities?.supports;
|
|
878
|
+
const vision = entry.vision === true || supports?.vision === true;
|
|
879
|
+
const contextWindow = positiveInteger(limits?.max_context_window_tokens);
|
|
880
|
+
const maxTokens = positiveInteger(limits?.max_output_tokens);
|
|
881
|
+
return Object.freeze({
|
|
882
|
+
model: Object.freeze({
|
|
883
|
+
id,
|
|
884
|
+
...typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {},
|
|
885
|
+
...contextWindow === void 0 ? {} : { contextWindow },
|
|
886
|
+
...maxTokens === void 0 ? {} : { maxTokens },
|
|
887
|
+
...vision ? { inputModalities: TEXT_AND_IMAGE } : {}
|
|
888
|
+
}),
|
|
889
|
+
declaredEndpoint: declaredEndpointOf$1(supports)
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
/** Translate one `type: 'embeddings'` entry, under the same fill-only-what-was-said rule. */
|
|
893
|
+
function embeddingModel(id, entry) {
|
|
894
|
+
const capabilities = entry.capabilities;
|
|
895
|
+
const limits = capabilities?.limits;
|
|
896
|
+
const maxInputTokens = positiveInteger(limits?.max_context_window_tokens);
|
|
897
|
+
const maxInputs = positiveInteger(limits?.max_inputs);
|
|
898
|
+
const dimensions = capabilities?.supports?.dimensions;
|
|
899
|
+
return Object.freeze({
|
|
900
|
+
id,
|
|
901
|
+
...typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {},
|
|
902
|
+
...typeof capabilities?.family === "string" && capabilities.family.length > 0 ? { family: capabilities.family } : {},
|
|
903
|
+
...maxInputTokens === void 0 ? {} : { maxInputTokens },
|
|
904
|
+
...maxInputs === void 0 ? {} : { maxInputs },
|
|
905
|
+
...typeof dimensions === "boolean" ? { supportsDimensions: dimensions } : {}
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Read the endpoint disclosure, and only a disclosure.
|
|
910
|
+
*
|
|
911
|
+
* `supports.responses === true` says `/responses`; `false` says `/chat/completions`
|
|
912
|
+
* — the endpoint stated something either way. Anything else, including the field
|
|
913
|
+
* being absent or holding a non-boolean, is UNKNOWN and stays `undefined`, which
|
|
914
|
+
* is a different state from "not supported" (Requirement 8.6).
|
|
915
|
+
*/
|
|
916
|
+
function declaredEndpointOf$1(supports) {
|
|
917
|
+
const responses = supports?.responses;
|
|
918
|
+
if (responses === true) return "responses";
|
|
919
|
+
if (responses === false) return "chat-completions";
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Parse the catalog body, treating an unreadable body as structural.
|
|
923
|
+
*
|
|
924
|
+
* Both failures land on the same code because they are the same problem: the
|
|
925
|
+
* response is not a catalog, and there is nothing here to guess a model list from
|
|
926
|
+
* (Requirement 8.8).
|
|
927
|
+
*/
|
|
928
|
+
function parseCatalogBody(text) {
|
|
929
|
+
let parsed;
|
|
930
|
+
try {
|
|
931
|
+
parsed = JSON.parse(text);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
throw malformed$1("Copilot model catalog is not valid JSON", error);
|
|
934
|
+
}
|
|
935
|
+
if (!isRecord(parsed)) throw malformed$1("Copilot model catalog must be a JSON object");
|
|
936
|
+
return parsed;
|
|
937
|
+
}
|
|
938
|
+
/** Accept a numeric metadata field only when it can serve as a capacity. */
|
|
939
|
+
function positiveInteger(value) {
|
|
940
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
941
|
+
}
|
|
942
|
+
/** A JSON object, excluding arrays — `data` being at the root is not a catalog. */
|
|
943
|
+
function isRecord(value) {
|
|
944
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
945
|
+
}
|
|
946
|
+
function malformed$1(message, cause) {
|
|
947
|
+
return new AgentSdkError(message, COPILOT_ERROR_CODES.CATALOG_MALFORMED, cause === void 0 ? void 0 : { cause });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/common/store-capture.ts
|
|
952
|
+
/**
|
|
953
|
+
* Store capture: decide which credential store variant the caller passed, and
|
|
954
|
+
* take a snapshot of its identity and methods.
|
|
955
|
+
*
|
|
956
|
+
* The counterpart of `captureCodexStore`, and it holds the same two lines:
|
|
957
|
+
*
|
|
958
|
+
* - **No accessors.** Every property is read through
|
|
959
|
+
* `Object.getOwnPropertyDescriptor`, and a descriptor without a `value` is
|
|
960
|
+
* REJECTED rather than invoked. Telling the two variants apart must not run a
|
|
961
|
+
* line of the caller's code, because a getter here would run during provider
|
|
962
|
+
* construction, in an order the caller cannot see.
|
|
963
|
+
* - **No I/O.** Methods are captured, not called. Nothing touches storage at
|
|
964
|
+
* construction time; the first read happens when an operation asks for a
|
|
965
|
+
* credential.
|
|
966
|
+
*
|
|
967
|
+
* Methods are invoked through `Reflect.apply` with the original object as the
|
|
968
|
+
* receiver, so a store written against `this` keeps working after capture.
|
|
969
|
+
*
|
|
970
|
+
* @module ai-agent-sdk/providers/copilot/store-capture
|
|
971
|
+
*/
|
|
972
|
+
/** Capture store identity and methods without invoking accessors or doing storage I/O. */
|
|
973
|
+
function captureCopilotStore(value) {
|
|
974
|
+
try {
|
|
975
|
+
if (value === null || typeof value !== "object") throw new TypeError("store must be an object");
|
|
976
|
+
const marker = dataValue(value, "kind", false);
|
|
977
|
+
if (marker === void 0) return captureLegacy(value);
|
|
978
|
+
if (marker !== "credential-store" || dataValue(value, "apiVersion") !== CREDENTIAL_CAPABILITY_API_VERSION) throw new TypeError("unsupported credential-store marker");
|
|
979
|
+
const id = boundedString(dataValue(value, "id"), 128, "credential store id");
|
|
980
|
+
const label = boundedString(dataValue(value, "label"), 256, "credential store label");
|
|
981
|
+
const read = capturedMethod(value, "read");
|
|
982
|
+
const commit = capturedMethod(value, "commit");
|
|
983
|
+
return Object.freeze({
|
|
984
|
+
kind: "versioned",
|
|
985
|
+
label,
|
|
986
|
+
store: Object.freeze({
|
|
987
|
+
kind: "credential-store",
|
|
988
|
+
apiVersion: CREDENTIAL_CAPABILITY_API_VERSION,
|
|
989
|
+
id,
|
|
990
|
+
label,
|
|
991
|
+
read,
|
|
992
|
+
commit
|
|
993
|
+
})
|
|
994
|
+
});
|
|
995
|
+
} catch (error) {
|
|
996
|
+
throw new AgentSdkError$1("Copilot authStore credential store is invalid", "CREDENTIAL_STORE_INVALID", { cause: error });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function captureLegacy(source) {
|
|
1000
|
+
const location = boundedString(dataValue(source, "location"), 1024, "Copilot auth store location");
|
|
1001
|
+
const read = capturedMethod(source, "read");
|
|
1002
|
+
const write = capturedMethod(source, "write");
|
|
1003
|
+
return Object.freeze({
|
|
1004
|
+
kind: "legacy",
|
|
1005
|
+
label: location,
|
|
1006
|
+
store: Object.freeze({
|
|
1007
|
+
location,
|
|
1008
|
+
read,
|
|
1009
|
+
write
|
|
1010
|
+
})
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function capturedMethod(source, key) {
|
|
1014
|
+
const method = dataValue(source, key);
|
|
1015
|
+
if (typeof method !== "function") throw new TypeError(`${String(key)} must be a function`);
|
|
1016
|
+
return (...args) => Reflect.apply(method, source, args);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Read an own-or-inherited DATA property. An accessor anywhere on the prototype
|
|
1020
|
+
* chain is an error: reading it would run caller code during construction.
|
|
1021
|
+
*/
|
|
1022
|
+
function dataValue(source, key, required = true) {
|
|
1023
|
+
let owner = source;
|
|
1024
|
+
while (owner !== null) {
|
|
1025
|
+
const descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
|
1026
|
+
if (descriptor !== void 0) {
|
|
1027
|
+
if (!("value" in descriptor)) throw new TypeError(`${String(key)} must not be an accessor`);
|
|
1028
|
+
return descriptor.value;
|
|
1029
|
+
}
|
|
1030
|
+
owner = Object.getPrototypeOf(owner);
|
|
1031
|
+
}
|
|
1032
|
+
if (!required) return void 0;
|
|
1033
|
+
throw new TypeError(`missing ${String(key)}`);
|
|
1034
|
+
}
|
|
1035
|
+
function boundedString(value, maxLength, label) {
|
|
1036
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) throw new TypeError(`${label} must be a bounded non-empty string`);
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/dual-protocol.ts
|
|
1042
|
+
/** Protocol id the HTTP layer reports for every Copilot request. */
|
|
1043
|
+
const COPILOT_DUAL_PROTOCOL_ID = "copilot-dual";
|
|
1044
|
+
/**
|
|
1045
|
+
* Conservative defaults, matching each sub-protocol's own defaults where the two
|
|
1046
|
+
* agree.
|
|
1047
|
+
*
|
|
1048
|
+
* `store: false` because retaining prompts on someone else's server is an explicit
|
|
1049
|
+
* decision, `include` carries `reasoning.encrypted_content` because without it a
|
|
1050
|
+
* reasoning model loses its chain of thought across a tool call, and
|
|
1051
|
+
* `parallelToolCalls: false` because older gateways reject the field outright.
|
|
1052
|
+
*/
|
|
1053
|
+
const COPILOT_DEFAULT_DIALECT = Object.freeze({
|
|
1054
|
+
sampling: true,
|
|
1055
|
+
maxOutputTokens: true,
|
|
1056
|
+
structuredOutputs: true,
|
|
1057
|
+
tools: true,
|
|
1058
|
+
store: false,
|
|
1059
|
+
include: Object.freeze(["reasoning.encrypted_content"]),
|
|
1060
|
+
reasoningSummary: "auto",
|
|
1061
|
+
streamUsage: true,
|
|
1062
|
+
systemRole: "system",
|
|
1063
|
+
parallelToolCalls: false
|
|
1064
|
+
});
|
|
1065
|
+
/**
|
|
1066
|
+
* Project the Copilot dialect onto the Responses dialect.
|
|
1067
|
+
*
|
|
1068
|
+
* PURE and TOTAL: every {@link CopilotDialect} flag has exactly one destination
|
|
1069
|
+
* here or none at all. `tools`, `streamUsage`, `systemRole` and
|
|
1070
|
+
* `parallelToolCalls` have no Responses destination and are DROPPED rather than
|
|
1071
|
+
* bent into a nearby flag — Responses declares tools from the request itself and
|
|
1072
|
+
* has no `reasoning_effort`-style neighbour worth guessing at.
|
|
1073
|
+
*
|
|
1074
|
+
* `reasoningSummary: 'none'` is expressed by ABSENCE, because that is how the
|
|
1075
|
+
* Responses serializer spells "ask for no summary" (`summary` is only sent when
|
|
1076
|
+
* the knob is defined). {@link resolvedResponsesDialect} therefore drops the
|
|
1077
|
+
* sub-protocol's own `reasoningSummary` default before merging, so this branch of
|
|
1078
|
+
* the projection is not overwritten by it.
|
|
1079
|
+
* @param dialect - the resolved Copilot dialect for this request.
|
|
1080
|
+
* @returns the Responses knobs this dialect determines, and only those.
|
|
1081
|
+
*/
|
|
1082
|
+
function toResponsesDialect(dialect) {
|
|
1083
|
+
return {
|
|
1084
|
+
sampling: dialect.sampling,
|
|
1085
|
+
maxOutputTokens: dialect.maxOutputTokens,
|
|
1086
|
+
structuredOutputs: dialect.structuredOutputs,
|
|
1087
|
+
store: dialect.store,
|
|
1088
|
+
include: [...dialect.include],
|
|
1089
|
+
...dialect.reasoningSummary === "none" ? {} : { reasoningSummary: dialect.reasoningSummary },
|
|
1090
|
+
...dialect.promptCacheKey === void 0 ? {} : { promptCacheKey: dialect.promptCacheKey }
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Project the Copilot dialect onto the Chat Completions dialect.
|
|
1095
|
+
*
|
|
1096
|
+
* PURE and TOTAL, same rule as {@link toResponsesDialect}: `store`, `include` and
|
|
1097
|
+
* `reasoningSummary` have no Chat Completions destination and are dropped —
|
|
1098
|
+
* `reasoningEffort` is a different knob (how hard to think, not whether to report
|
|
1099
|
+
* a summary), so mapping onto it would be a guess dressed as a translation.
|
|
1100
|
+
*
|
|
1101
|
+
* Two flags change type on the way across, and each mapping is total:
|
|
1102
|
+
*
|
|
1103
|
+
* | Copilot | Chat Completions |
|
|
1104
|
+
* | --- | --- |
|
|
1105
|
+
* | `maxOutputTokens: true` | `maxTokensField: 'max_tokens'` |
|
|
1106
|
+
* | `maxOutputTokens: false` | `maxTokensField: false` |
|
|
1107
|
+
* | `structuredOutputs: true` | `structuredOutputs: 'json-schema'` |
|
|
1108
|
+
* | `structuredOutputs: false` | `structuredOutputs: false` |
|
|
1109
|
+
*
|
|
1110
|
+
* The accepted cost of the first row: a caller cannot reach
|
|
1111
|
+
* `'max_completion_tokens'` through {@link CopilotDialect}. Copilot's
|
|
1112
|
+
* `/chat/completions` takes `max_tokens`, and the models that demand the newer
|
|
1113
|
+
* spelling are the ones the router sends to `/responses` anyway, so the boolean
|
|
1114
|
+
* buys a flag a caller can reason about and costs a spelling no Copilot model has
|
|
1115
|
+
* been observed to need.
|
|
1116
|
+
* @param dialect - the resolved Copilot dialect for this request.
|
|
1117
|
+
* @returns the Chat Completions knobs this dialect determines, and only those.
|
|
1118
|
+
*/
|
|
1119
|
+
function toChatCompletionsDialect(dialect) {
|
|
1120
|
+
return {
|
|
1121
|
+
sampling: dialect.sampling,
|
|
1122
|
+
maxTokensField: dialect.maxOutputTokens ? "max_tokens" : false,
|
|
1123
|
+
structuredOutputs: dialect.structuredOutputs ? "json-schema" : false,
|
|
1124
|
+
tools: dialect.tools,
|
|
1125
|
+
streamUsage: dialect.streamUsage,
|
|
1126
|
+
systemRole: dialect.systemRole,
|
|
1127
|
+
parallelToolCalls: dialect.parallelToolCalls,
|
|
1128
|
+
...dialect.promptCacheKey === void 0 ? {} : { promptCacheKey: dialect.promptCacheKey }
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Build the composite protocol.
|
|
1133
|
+
*
|
|
1134
|
+
* @param options - the router, the two sub-protocols, and the optional observer.
|
|
1135
|
+
* @returns a `RuntimeWireProtocol<CopilotDialect>` with id `'copilot-dual'`.
|
|
1136
|
+
*/
|
|
1137
|
+
function copilotDualProtocol(options) {
|
|
1138
|
+
const { router, responses, chat } = options;
|
|
1139
|
+
const onDecision = options.onDecision;
|
|
1140
|
+
/**
|
|
1141
|
+
* The Responses dialect for one request.
|
|
1142
|
+
*
|
|
1143
|
+
* Merged with the SUB-PROTOCOL's defaults, not the composite's: the composite's
|
|
1144
|
+
* defaults are already inside `dialect` by the time the runtime calls us, and
|
|
1145
|
+
* what is missing is everything the Responses dialect knows about but Copilot
|
|
1146
|
+
* does not expose (`messagePhase`). `reasoningSummary` is dropped from the base
|
|
1147
|
+
* because the projection owns that key outright — see {@link toResponsesDialect}.
|
|
1148
|
+
*/
|
|
1149
|
+
const resolvedResponsesDialect = (dialect) => {
|
|
1150
|
+
const { reasoningSummary: _ownedByProjection, ...base } = responses.defaultDialect;
|
|
1151
|
+
return Object.freeze({
|
|
1152
|
+
...base,
|
|
1153
|
+
...toResponsesDialect(dialect)
|
|
1154
|
+
});
|
|
1155
|
+
};
|
|
1156
|
+
/**
|
|
1157
|
+
* The Chat Completions dialect for one request.
|
|
1158
|
+
*
|
|
1159
|
+
* Same rule; the sub-protocol supplies `path`, `stop`, `seed` and
|
|
1160
|
+
* `reasoningEffort`, which {@link CopilotDialect} deliberately does not expose.
|
|
1161
|
+
*/
|
|
1162
|
+
const resolvedChatDialect = (dialect) => Object.freeze({
|
|
1163
|
+
...chat.defaultDialect,
|
|
1164
|
+
...toChatCompletionsDialect(dialect)
|
|
1165
|
+
});
|
|
1166
|
+
/** Route one request, reporting the decision on the way through. */
|
|
1167
|
+
const decide = (request) => {
|
|
1168
|
+
const decision = router.decide(request.model.id);
|
|
1169
|
+
report(decision);
|
|
1170
|
+
return decision;
|
|
1171
|
+
};
|
|
1172
|
+
/** Hand the decision to the observer, swallowing whatever it does with it. */
|
|
1173
|
+
const report = (decision) => {
|
|
1174
|
+
if (onDecision === void 0) return;
|
|
1175
|
+
try {
|
|
1176
|
+
onDecision(decision);
|
|
1177
|
+
} catch {}
|
|
1178
|
+
};
|
|
1179
|
+
return defineWireProtocol({
|
|
1180
|
+
id: COPILOT_DUAL_PROTOCOL_ID,
|
|
1181
|
+
defaultDialect: COPILOT_DEFAULT_DIALECT,
|
|
1182
|
+
endpointPath: (request, dialect) => decide(request).endpoint === "responses" ? responses.endpointPath(request, resolvedResponsesDialect(dialect)) : chat.endpointPath(request, resolvedChatDialect(dialect)),
|
|
1183
|
+
...protocolHeadersOf(responses, chat),
|
|
1184
|
+
serialize: (request, dialect) => router.decide(request.model.id).endpoint === "responses" ? responses.serialize(request, resolvedResponsesDialect(dialect)) : chat.serialize(request, resolvedChatDialect(dialect)),
|
|
1185
|
+
translate: (events, request, displayName) => router.decide(request.model.id).endpoint === "responses" ? responses.translate(events, request, displayName) : chat.translate(events, request, displayName)
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* The composite's `protocolHeaders`, or nothing.
|
|
1190
|
+
*
|
|
1191
|
+
* This is the one method whose signature carries NO `ProtocolRequest`, so the
|
|
1192
|
+
* routing key that makes the other three work is absent here. Two things follow.
|
|
1193
|
+
* The union of both branches' headers is wrong — it would put a header belonging
|
|
1194
|
+
* to the branch NOT taken on the wire. And a guess is wrong for the same reason.
|
|
1195
|
+
* So the composite exposes a header set only when both branches produce the SAME
|
|
1196
|
+
* one, in which case that set is the selected branch's set whichever branch is
|
|
1197
|
+
* selected; when they diverge, it exposes none, and a protocol header that only
|
|
1198
|
+
* one branch needs has to travel through the adapter's request-scoped header path
|
|
1199
|
+
* where the model id is in hand.
|
|
1200
|
+
*
|
|
1201
|
+
* Today neither sub-protocol declares `protocolHeaders` — Copilot's mandatory
|
|
1202
|
+
* headers are endpoint identity (`editor-version`, `editor-plugin-version`), not
|
|
1203
|
+
* protocol facts — so this returns nothing and the branch above is the
|
|
1204
|
+
* forward-looking half of the rule.
|
|
1205
|
+
* @param responses - the `/responses` sub-protocol.
|
|
1206
|
+
* @param chat - the `/chat/completions` sub-protocol.
|
|
1207
|
+
* @returns a one-key spread carrying `protocolHeaders`, or an empty one.
|
|
1208
|
+
*/
|
|
1209
|
+
function protocolHeadersOf(responses, chat) {
|
|
1210
|
+
const fromResponses = responses.protocolHeaders;
|
|
1211
|
+
const fromChat = chat.protocolHeaders;
|
|
1212
|
+
if (fromResponses === void 0 && fromChat === void 0) return {};
|
|
1213
|
+
return { protocolHeaders: (dialect) => {
|
|
1214
|
+
const left = fromResponses?.(Object.freeze({
|
|
1215
|
+
...responses.defaultDialect,
|
|
1216
|
+
...toResponsesDialect(dialect)
|
|
1217
|
+
})) ?? {};
|
|
1218
|
+
return sameHeaders(left, fromChat?.(Object.freeze({
|
|
1219
|
+
...chat.defaultDialect,
|
|
1220
|
+
...toChatCompletionsDialect(dialect)
|
|
1221
|
+
})) ?? {}) ? Object.freeze({ ...left }) : Object.freeze({});
|
|
1222
|
+
} };
|
|
1223
|
+
}
|
|
1224
|
+
/** Whether two header maps are equal name-for-name and value-for-value. */
|
|
1225
|
+
function sameHeaders(left, right) {
|
|
1226
|
+
const names = Object.keys(left);
|
|
1227
|
+
if (names.length !== Object.keys(right).length) return false;
|
|
1228
|
+
return names.every((name) => left[name] === right[name]);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/exchange.ts
|
|
1233
|
+
/**
|
|
1234
|
+
* `Copilot_Token_Exchange`: turn the long-lived `GitHub_User_Token` into the
|
|
1235
|
+
* short-lived `Copilot_Api_Token` the Copilot surface accepts.
|
|
1236
|
+
*
|
|
1237
|
+
* ```text
|
|
1238
|
+
* GET https://api.github.com/copilot_internal/v2/token
|
|
1239
|
+
* Authorization: Bearer ghu_…
|
|
1240
|
+
* Accept: application/json
|
|
1241
|
+
* Editor-Version / Editor-Plugin-Version
|
|
1242
|
+
* → 200 { token, expires_at, refresh_in?, endpoints?: { api?: string }, … }
|
|
1243
|
+
* ```
|
|
1244
|
+
*
|
|
1245
|
+
* ## The classification order is the contract
|
|
1246
|
+
*
|
|
1247
|
+
* Nine rows, in this order, each for a concrete reason:
|
|
1248
|
+
*
|
|
1249
|
+
* ```text
|
|
1250
|
+
* 1. host is 'ghe.com' or ends with a '.ghe.com' label ⇒ TENANT_UNSUPPORTED (before any I/O)
|
|
1251
|
+
* 2. origin is not the pinned githubApiBaseUrl origin ⇒ ENDPOINT_ORIGIN_INVALID (before any I/O)
|
|
1252
|
+
* 3. the response is a redirect, in any of its shapes ⇒ REDIRECT_REJECTED
|
|
1253
|
+
* 4. HTTP 404 ⇒ TENANT_UNSUPPORTED
|
|
1254
|
+
* 5. HTTP 401 ⇒ CREDENTIAL_REJECTED (permanent)
|
|
1255
|
+
* 6. HTTP 403 ⇒ CREDENTIAL_REJECTED (permanent)
|
|
1256
|
+
* 7. HTTP 429, HTTP 5xx, a network error, or a timeout ⇒ TOKEN_EXCHANGE_FAILED (transient)
|
|
1257
|
+
* 8. any remaining 4xx ⇒ TOKEN_EXCHANGE_FAILED (permanent)
|
|
1258
|
+
* 9. body is not JSON, or expires_at is unreadable ⇒ TOKEN_MALFORMED
|
|
1259
|
+
* ```
|
|
1260
|
+
*
|
|
1261
|
+
* Rows 1 and 2 run BEFORE a request is dispatched. A data-residency tenant has no
|
|
1262
|
+
* token-exchange surface at all, so asking it is pointless; and an origin check
|
|
1263
|
+
* performed after the fact would already have handed the bearer token to whatever
|
|
1264
|
+
* origin the URL named (Requirements 3.6, 3.7).
|
|
1265
|
+
*
|
|
1266
|
+
* Row 1 detects the tenant by DOMAIN LABEL SUFFIX, never by substring: with
|
|
1267
|
+
* `includes('ghe.com')`, `ghe.com.evil.tld` and `notghe.com` would both be
|
|
1268
|
+
* misread as data-residency tenants, one of which is an attacker-chosen host.
|
|
1269
|
+
*
|
|
1270
|
+
* Row 6 does the most work of the nine. The endpoint answers 403 both for a
|
|
1271
|
+
* personal access token and for a token minted by an OAuth App that is not on
|
|
1272
|
+
* GitHub's allowlist, and the response does not distinguish the two — so the
|
|
1273
|
+
* message names BOTH possibilities alongside the single instruction that helps in
|
|
1274
|
+
* either case (Requirements 3.5, 13.2).
|
|
1275
|
+
*
|
|
1276
|
+
* ## What is read from the body, and what is refused
|
|
1277
|
+
*
|
|
1278
|
+
* `expires_at` is MANDATORY and has to be a positive finite number: without it
|
|
1279
|
+
* there is no second source for the lifetime, and an invented TTL is exactly the
|
|
1280
|
+
* inference this SDK does not make. `refresh_in` is advisory and a bad value is
|
|
1281
|
+
* dropped rather than fatal — it can only shorten the refresh moment, so losing
|
|
1282
|
+
* it costs nothing. `endpoints.api` is read and exposed for diagnostics but is
|
|
1283
|
+
* NEVER used as the base URL: a server-designated base URL is a redirect under
|
|
1284
|
+
* another name, and this SDK does not follow provider-controlled redirects
|
|
1285
|
+
* (DD-6, Requirements 3.8, 7.8).
|
|
1286
|
+
*
|
|
1287
|
+
* @module ai-agent-sdk/providers/copilot/exchange
|
|
1288
|
+
*/
|
|
1289
|
+
/** GitHub's API base, where the token-exchange surface lives. */
|
|
1290
|
+
const DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
|
|
1291
|
+
/** Path of the token-exchange surface. */
|
|
1292
|
+
const COPILOT_TOKEN_EXCHANGE_PATH = "/copilot_internal/v2/token";
|
|
1293
|
+
/** Marker used in place of a credential value that appeared in a response body. */
|
|
1294
|
+
const REDACTED$1 = "[REDACTED]";
|
|
1295
|
+
/**
|
|
1296
|
+
* Exchange a `GitHub_User_Token` for a `Copilot_Api_Token`.
|
|
1297
|
+
*
|
|
1298
|
+
* The long-lived credential is NOT consumed: nothing here writes to a store, and
|
|
1299
|
+
* the persisted value is left exactly as it was (Requirement 3.4).
|
|
1300
|
+
* @param github - the long-lived GitHub user token. Its value never reaches an
|
|
1301
|
+
* error message, and any occurrence of it in a response body is redacted before
|
|
1302
|
+
* the body is retained as a cause (Requirement 13.7).
|
|
1303
|
+
* @param options - base URL override, injected fetch, signal, and the read bounds.
|
|
1304
|
+
* @returns the short-lived token plus its expiry and the advisory fields.
|
|
1305
|
+
* @throws AgentSdkError with `COPILOT_ENDPOINT_ORIGIN_INVALID` or
|
|
1306
|
+
* `COPILOT_REDIRECT_REJECTED`, or {@link CopilotTokenExchangeError} with
|
|
1307
|
+
* `COPILOT_TENANT_UNSUPPORTED`, `COPILOT_CREDENTIAL_REJECTED`,
|
|
1308
|
+
* `COPILOT_TOKEN_EXCHANGE_FAILED` or `COPILOT_TOKEN_MALFORMED`, per the
|
|
1309
|
+
* classification order in the module note.
|
|
1310
|
+
*/
|
|
1311
|
+
async function exchangeCopilotToken(github, options = {}) {
|
|
1312
|
+
rejectDataResidencyTenant(options.githubApiBaseUrl);
|
|
1313
|
+
const pinned = issuerOf("githubApiBaseUrl", options.githubApiBaseUrl, DEFAULT_GITHUB_API_BASE_URL, options);
|
|
1314
|
+
const url = copilotUrl(pinned, COPILOT_TOKEN_EXCHANGE_PATH);
|
|
1315
|
+
const host = new URL(pinned.origin).hostname;
|
|
1316
|
+
let response;
|
|
1317
|
+
try {
|
|
1318
|
+
response = await copilotFetch({
|
|
1319
|
+
pinned,
|
|
1320
|
+
url,
|
|
1321
|
+
operation: "token exchange",
|
|
1322
|
+
init: {
|
|
1323
|
+
method: "GET",
|
|
1324
|
+
headers: exchangeHeaders(github, options.editorHeaders)
|
|
1325
|
+
}
|
|
1326
|
+
}, options);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
throw transportFailure(error, host, options);
|
|
1329
|
+
}
|
|
1330
|
+
const secrets = [github.token, ...options.additionalSecrets ?? []];
|
|
1331
|
+
if (!response.ok) throw await statusFailure(response, host, secrets, options);
|
|
1332
|
+
return readApiToken(await readCopilotResponseText(response, options), secrets);
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Row 1: refuse a `*.ghe.com` tenant by domain label, before anything is sent.
|
|
1336
|
+
*
|
|
1337
|
+
* An unparsable value is left alone rather than reported here — {@link issuerOf}
|
|
1338
|
+
* owns that message, and reporting it as a tenant problem would name the wrong
|
|
1339
|
+
* cause.
|
|
1340
|
+
* @param configured - the caller's `githubApiBaseUrl`, when they set one.
|
|
1341
|
+
* @throws CopilotTokenExchangeError with `COPILOT_TENANT_UNSUPPORTED`, naming the
|
|
1342
|
+
* detected domain (Requirement 13.3).
|
|
1343
|
+
*/
|
|
1344
|
+
function rejectDataResidencyTenant(configured) {
|
|
1345
|
+
if (configured === void 0) return;
|
|
1346
|
+
let host;
|
|
1347
|
+
try {
|
|
1348
|
+
host = new URL(configured).hostname;
|
|
1349
|
+
} catch {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (!isDataResidencyHost(host)) return;
|
|
1353
|
+
throw new CopilotTokenExchangeError(credentialFailure(tenantMessage(host)), COPILOT_ERROR_CODES.TENANT_UNSUPPORTED, "permanent");
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Whether a hostname belongs to the `ghe.com` data-residency namespace.
|
|
1357
|
+
*
|
|
1358
|
+
* Matched on DOMAIN LABELS, which is the whole point: `ghe.com.evil.tld` and
|
|
1359
|
+
* `notghe.com` are not data-residency hosts, and a substring test would call both
|
|
1360
|
+
* of them one.
|
|
1361
|
+
* @param host - a hostname, without a port.
|
|
1362
|
+
* @returns true for `ghe.com` itself and for any host under it.
|
|
1363
|
+
*/
|
|
1364
|
+
function isDataResidencyHost(host) {
|
|
1365
|
+
const normalized = host.toLowerCase().replace(/\.$/, "");
|
|
1366
|
+
return normalized === "ghe.com" || normalized.endsWith(".ghe.com");
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Headers for the exchange request.
|
|
1370
|
+
*
|
|
1371
|
+
* Both editor headers are mandatory: with either one missing the endpoint answers
|
|
1372
|
+
* HTTP 400 and the request never runs. An override of one leaves the other at its
|
|
1373
|
+
* exported default rather than dropping it.
|
|
1374
|
+
* @param github - the credential whose value goes in `Authorization`.
|
|
1375
|
+
* @param headers - per-call overrides for the editor identity.
|
|
1376
|
+
* @returns the header map for the request init.
|
|
1377
|
+
*/
|
|
1378
|
+
function exchangeHeaders(github, headers) {
|
|
1379
|
+
return {
|
|
1380
|
+
authorization: `Bearer ${github.token}`,
|
|
1381
|
+
accept: "application/json",
|
|
1382
|
+
"editor-version": headers?.editorVersion ?? "vscode/1.99.0",
|
|
1383
|
+
"editor-plugin-version": headers?.editorPluginVersion ?? "copilot-chat/0.26.0"
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Rows 3 and 7 on the dispatch path: keep the structural refusals, classify the
|
|
1388
|
+
* rest as transient.
|
|
1389
|
+
*
|
|
1390
|
+
* Four kinds of failure pass through UNCHANGED, because wrapping each one would
|
|
1391
|
+
* replace a precise diagnosis with a vaguer one:
|
|
1392
|
+
*
|
|
1393
|
+
* - the origin refusal and the redirect refusal, which are rows 2 and 3 and
|
|
1394
|
+
* already carry their own codes;
|
|
1395
|
+
* - a caller abort, which keeps the SDK's abort code rather than becoming a
|
|
1396
|
+
* Copilot failure the caller did not ask about (Requirement 4.6);
|
|
1397
|
+
* - a `RangeError` from a bound, which names the limit that was exceeded and is
|
|
1398
|
+
* neither a network fault nor a server fault (Requirement 13.6).
|
|
1399
|
+
*
|
|
1400
|
+
* Everything else — DNS, connection reset, TLS, and the per-request deadline —
|
|
1401
|
+
* is transient: it is exactly the class of failure that a later attempt can win.
|
|
1402
|
+
* @param error - the caught value.
|
|
1403
|
+
* @param host - the host that was contacted, for the message.
|
|
1404
|
+
* @param options - read for the caller's signal only.
|
|
1405
|
+
* @returns the value to throw.
|
|
1406
|
+
*/
|
|
1407
|
+
function transportFailure(error, host, options) {
|
|
1408
|
+
if (options.signal?.aborted === true) return error;
|
|
1409
|
+
if (error instanceof RangeError) return error;
|
|
1410
|
+
if (error instanceof AgentSdkError && (error.code === COPILOT_ERROR_CODES.ENDPOINT_ORIGIN_INVALID || error.code === COPILOT_ERROR_CODES.REDIRECT_REJECTED)) return error;
|
|
1411
|
+
return new CopilotTokenExchangeError(credentialFailure(`Copilot token exchange could not reach ${host}; the request failed before a response`, error), COPILOT_ERROR_CODES.TOKEN_EXCHANGE_FAILED, "transient");
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Rows 4 through 8: classify a response that arrived but was not a success.
|
|
1415
|
+
*
|
|
1416
|
+
* The body is read through the bounded reader first, so the cause carries the
|
|
1417
|
+
* endpoint's own words — with every occurrence of the credential replaced —
|
|
1418
|
+
* rather than nothing at all (Requirements 13.6, 13.7). A read that fails is not
|
|
1419
|
+
* allowed to hide the status: the classification stands either way.
|
|
1420
|
+
* @param response - a non-ok response, already cleared by the redirect guard.
|
|
1421
|
+
* @param host - the host that answered, for the tenant message.
|
|
1422
|
+
* @param secrets - every live credential value to redact out of the body.
|
|
1423
|
+
* @param options - the read bounds and the signal.
|
|
1424
|
+
* @returns the classified error to throw.
|
|
1425
|
+
*/
|
|
1426
|
+
async function statusFailure(response, host, secrets, options) {
|
|
1427
|
+
const body = await readFailureBody(response, secrets, options);
|
|
1428
|
+
const cause = body === void 0 ? void 0 : new Error(body);
|
|
1429
|
+
if (response.status === 404) return new CopilotTokenExchangeError(credentialFailure(tenantMessage(host), cause), COPILOT_ERROR_CODES.TENANT_UNSUPPORTED, "permanent");
|
|
1430
|
+
if (response.status === 401) return new CopilotTokenExchangeError(credentialFailure(`the Copilot token-exchange surface rejected the stored GitHub credential (HTTP 401); run \`${COPILOT_LOGIN_COMMAND}\` to sign in again`, cause), COPILOT_ERROR_CODES.CREDENTIAL_REJECTED, "permanent");
|
|
1431
|
+
if (response.status === 403) return new CopilotTokenExchangeError(credentialFailure(`the Copilot token-exchange surface refused this credential type (HTTP 403). It accepts only a token minted by an OAuth App on GitHub's allowlist: a personal access token cannot be used here, and neither can a token from an OAuth App that is not allowlisted. Run \`${COPILOT_LOGIN_COMMAND}\` to sign in with the supported client.`, cause), COPILOT_ERROR_CODES.CREDENTIAL_REJECTED, "permanent");
|
|
1432
|
+
const transient = response.status >= 500 || response.status === 429;
|
|
1433
|
+
return new CopilotTokenExchangeError(credentialFailure(`Copilot token exchange failed (HTTP ${response.status})`, cause), COPILOT_ERROR_CODES.TOKEN_EXCHANGE_FAILED, transient ? "transient" : "permanent");
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Read an error body within the configured bounds, redacted, or give up quietly.
|
|
1437
|
+
*
|
|
1438
|
+
* Giving up quietly is deliberate: the status has already decided the
|
|
1439
|
+
* classification, and a body that could not be read must not turn a precise 403
|
|
1440
|
+
* into a read error.
|
|
1441
|
+
* @param response - the non-ok response.
|
|
1442
|
+
* @param secrets - every live credential value to redact.
|
|
1443
|
+
* @param options - the read bounds and the signal.
|
|
1444
|
+
* @returns the redacted text, or `undefined` when it could not be read.
|
|
1445
|
+
*/
|
|
1446
|
+
async function readFailureBody(response, secrets, options) {
|
|
1447
|
+
try {
|
|
1448
|
+
const text = await readCopilotResponseText(response, options);
|
|
1449
|
+
return text.length === 0 ? void 0 : redact(text, secrets);
|
|
1450
|
+
} catch {
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Row 9: read the success body, requiring exactly what cannot be inferred.
|
|
1456
|
+
*
|
|
1457
|
+
* `token` and `expires_at` are mandatory; everything else is advisory and a
|
|
1458
|
+
* useless value is dropped rather than raised, because a dropped hint changes
|
|
1459
|
+
* nothing about correctness while a raised one would fail an exchange that
|
|
1460
|
+
* actually produced a usable token.
|
|
1461
|
+
* @param raw - the bounded response text.
|
|
1462
|
+
* @param secrets - every live credential value to redact out of the cause.
|
|
1463
|
+
* @returns the token this exchange produced.
|
|
1464
|
+
* @throws CopilotTokenExchangeError with `COPILOT_TOKEN_MALFORMED` when the body
|
|
1465
|
+
* is not a JSON object, `token` is not a non-empty string, or `expires_at` is
|
|
1466
|
+
* not a positive finite number.
|
|
1467
|
+
*/
|
|
1468
|
+
function readApiToken(raw, secrets) {
|
|
1469
|
+
let parsed;
|
|
1470
|
+
try {
|
|
1471
|
+
parsed = JSON.parse(raw);
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
throw malformed("the Copilot token-exchange response was not JSON", error);
|
|
1474
|
+
}
|
|
1475
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw malformed("the Copilot token-exchange response was not a JSON object");
|
|
1476
|
+
const body = parsed;
|
|
1477
|
+
const token = body["token"];
|
|
1478
|
+
if (typeof token !== "string" || token.length === 0) throw malformed("the Copilot token-exchange response carried no token");
|
|
1479
|
+
const expiresAt = body["expires_at"];
|
|
1480
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) throw malformed("the Copilot token-exchange response carried no readable expires_at; this SDK does not invent a token lifetime", new Error(redact(raw, secrets)));
|
|
1481
|
+
const refreshIn = body["refresh_in"];
|
|
1482
|
+
const declared = declaredEndpointOf(body["endpoints"]);
|
|
1483
|
+
return Object.freeze({
|
|
1484
|
+
token,
|
|
1485
|
+
expiresAtMs: expiresAt * 1e3,
|
|
1486
|
+
...typeof refreshIn === "number" && Number.isFinite(refreshIn) && refreshIn > 0 ? { refreshInSeconds: refreshIn } : {},
|
|
1487
|
+
...declared === void 0 ? {} : { declaredApiEndpoint: declared }
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Read `endpoints.api` for diagnostics only.
|
|
1492
|
+
*
|
|
1493
|
+
* Never returned as something to request against — see {@link
|
|
1494
|
+
* CopilotApiToken.declaredApiEndpoint} and DD-6.
|
|
1495
|
+
* @param endpoints - the `endpoints` member of the response, unvalidated.
|
|
1496
|
+
* @returns the declared API endpoint, when it is a non-empty string.
|
|
1497
|
+
*/
|
|
1498
|
+
function declaredEndpointOf(endpoints) {
|
|
1499
|
+
if (typeof endpoints !== "object" || endpoints === null) return void 0;
|
|
1500
|
+
const api = endpoints["api"];
|
|
1501
|
+
return typeof api === "string" && api.length > 0 ? api : void 0;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* The one tenant message, so the two paths that reach row 1 and row 4 say the
|
|
1505
|
+
* same thing.
|
|
1506
|
+
* @param host - the detected host, named in the message (Requirement 13.3).
|
|
1507
|
+
* @returns the message text.
|
|
1508
|
+
*/
|
|
1509
|
+
function tenantMessage(host) {
|
|
1510
|
+
return `'${host}' is a GitHub data-residency tenant, which does not provide the Copilot token-exchange surface; use a github.com account for this provider`;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Replace every occurrence of every live credential value in text with
|
|
1514
|
+
* {@link REDACTED}.
|
|
1515
|
+
*
|
|
1516
|
+
* A response body is endpoint-authored text, and an endpoint that echoes the
|
|
1517
|
+
* `Authorization` header back is exactly how a token ends up in a log. The list
|
|
1518
|
+
* is plural because Requirement 13.7 covers BOTH tokens: this request carries one
|
|
1519
|
+
* of them, and the other arrives through
|
|
1520
|
+
* {@link CopilotExchangeOptions.additionalSecrets}.
|
|
1521
|
+
* @param text - the body text.
|
|
1522
|
+
* @param secrets - the credential values; empty entries are ignored.
|
|
1523
|
+
* @returns the text with every value removed.
|
|
1524
|
+
*/
|
|
1525
|
+
function redact(text, secrets) {
|
|
1526
|
+
let result = text;
|
|
1527
|
+
for (const secret of secrets) {
|
|
1528
|
+
if (secret.length === 0) continue;
|
|
1529
|
+
result = result.split(secret).join(REDACTED$1);
|
|
1530
|
+
}
|
|
1531
|
+
return result;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Build the row-9 error.
|
|
1535
|
+
* @param message - SDK-authored text.
|
|
1536
|
+
* @param cause - the caught value or the redacted body, when there is one.
|
|
1537
|
+
* @returns the malformed-token error.
|
|
1538
|
+
*/
|
|
1539
|
+
function malformed(message, cause) {
|
|
1540
|
+
return new CopilotTokenExchangeError(credentialFailure(message, cause), COPILOT_ERROR_CODES.TOKEN_MALFORMED, "permanent");
|
|
1541
|
+
}
|
|
1542
|
+
/** Provider name recorded on the credential-operation observation by default. */
|
|
1543
|
+
const COPILOT_PROVIDER_ID = "copilot";
|
|
1544
|
+
/**
|
|
1545
|
+
* Build a token cache over one set of exchange settings.
|
|
1546
|
+
*
|
|
1547
|
+
* ## The mistake this is written to avoid
|
|
1548
|
+
*
|
|
1549
|
+
* The shared exchange gets its OWN `AbortController` plus its own deadline, and
|
|
1550
|
+
* NEVER any single caller's signal. Were the caller's signal handed to it, the
|
|
1551
|
+
* first caller to abort would cancel the exchange every other caller is waiting
|
|
1552
|
+
* on, and those callers would fail for a reason that has nothing to do with them.
|
|
1553
|
+
* Instead each caller — the one that started the exchange included — races the
|
|
1554
|
+
* shared promise against its OWN signal: an aborted caller leaves, and the
|
|
1555
|
+
* exchange still completes for everyone else (Property 18).
|
|
1556
|
+
*
|
|
1557
|
+
* The observation therefore counts exchanges actually DISPATCHED rather than
|
|
1558
|
+
* callers served, which is what makes the coalescing observable instead of merely
|
|
1559
|
+
* claimed (Property 52). It is recorded with the `'refresh'` operation name: that
|
|
1560
|
+
* parameter's union is closed at `'resolve' | 'refresh' | 'login'`, widening it
|
|
1561
|
+
* would change a public type of `provider-http`, and Requirement 18.4 forbids
|
|
1562
|
+
* that — see DD-7.
|
|
1563
|
+
*
|
|
1564
|
+
* ## Two paths deliberately absent
|
|
1565
|
+
*
|
|
1566
|
+
* There is no revision-conflict recovery, unlike `provider-codex`. That path
|
|
1567
|
+
* exists there because a Codex refresh token rotates and is single-use, so a lost
|
|
1568
|
+
* race destroys a credential. A `GitHub_User_Token` does not rotate and an
|
|
1569
|
+
* exchange does not consume it, so two racing processes simply exchange twice —
|
|
1570
|
+
* and a branch no situation reaches is a branch nothing verifies (DD-8).
|
|
1571
|
+
*
|
|
1572
|
+
* Nothing here writes to a store. The `Copilot_Api_Token` is never persisted: it
|
|
1573
|
+
* lives ~25 minutes, so persisting it would add a second secret on disk, a second
|
|
1574
|
+
* write path, and a new state to reason about, to save one request inside a
|
|
1575
|
+
* 25-minute window (DD-9, Requirement 3.3).
|
|
1576
|
+
*
|
|
1577
|
+
* A failure is returned to every waiting caller as-is and never retried here — an
|
|
1578
|
+
* endpoint that rejected the credential will reject it again, and this layer has
|
|
1579
|
+
* no way to change that (Requirement 5.8, Property 19).
|
|
1580
|
+
* @param options - exchange settings, the observation provider name, the margin
|
|
1581
|
+
* and the clock. `options.signal` is deliberately IGNORED for the exchange
|
|
1582
|
+
* itself; per-caller cancellation travels through `operation.signal`.
|
|
1583
|
+
* @returns a cache over a single credential slot.
|
|
1584
|
+
*/
|
|
1585
|
+
function createCopilotTokenCache(options = {}) {
|
|
1586
|
+
const provider = options.providerId ?? "copilot";
|
|
1587
|
+
const now = options.now ?? (() => Date.now());
|
|
1588
|
+
const marginMs = options.marginMs === void 0 ? COPILOT_TOKEN_EXCHANGE_MARGIN_MS : positiveSafeInteger(options.marginMs, "marginMs");
|
|
1589
|
+
let entry;
|
|
1590
|
+
let inflight;
|
|
1591
|
+
let inflightToken;
|
|
1592
|
+
let ticket = 0;
|
|
1593
|
+
return {
|
|
1594
|
+
async acquire(source, operation, context) {
|
|
1595
|
+
operation.signal.throwIfAborted();
|
|
1596
|
+
const github = requireGitHubToken(source.file, source.label);
|
|
1597
|
+
const cached = entry;
|
|
1598
|
+
if (cached !== void 0 && cached.sourceToken === github.token && cached.sourceRevision === source.revision && !shouldExchange(cached.api, now(), marginMs)) return cached.api;
|
|
1599
|
+
if (inflight !== void 0 && inflightToken === github.token) return await raceAbort(inflight, operation.signal);
|
|
1600
|
+
ticket++;
|
|
1601
|
+
const pending = runExchange(ticket);
|
|
1602
|
+
inflight = pending;
|
|
1603
|
+
inflightToken = github.token;
|
|
1604
|
+
return await raceAbort(pending, operation.signal);
|
|
1605
|
+
/**
|
|
1606
|
+
* Dispatch the one shared exchange and record its result.
|
|
1607
|
+
* @param slot - this exchange's ticket, so a later exchange's teardown does
|
|
1608
|
+
* not clear a newer in-flight one.
|
|
1609
|
+
* @returns the exchanged token.
|
|
1610
|
+
*/
|
|
1611
|
+
async function runExchange(slot) {
|
|
1612
|
+
const held = entry?.api.token;
|
|
1613
|
+
try {
|
|
1614
|
+
const api = await observeCredentialOperation(context, provider, "refresh", () => exchangeCopilotToken(github, {
|
|
1615
|
+
...options,
|
|
1616
|
+
...held === void 0 ? {} : { additionalSecrets: [...options.additionalSecrets ?? [], held] },
|
|
1617
|
+
signal: sharedExchangeSignal(options)
|
|
1618
|
+
}));
|
|
1619
|
+
entry = Object.freeze({
|
|
1620
|
+
api,
|
|
1621
|
+
sourceToken: github.token,
|
|
1622
|
+
sourceRevision: source.revision
|
|
1623
|
+
});
|
|
1624
|
+
return api;
|
|
1625
|
+
} finally {
|
|
1626
|
+
if (ticket === slot) {
|
|
1627
|
+
inflight = void 0;
|
|
1628
|
+
inflightToken = void 0;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
},
|
|
1633
|
+
invalidate() {
|
|
1634
|
+
entry = void 0;
|
|
1635
|
+
}
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* The shared exchange's own cancellation source: one controller, driven by one
|
|
1640
|
+
* deadline, and reachable by no caller.
|
|
1641
|
+
*
|
|
1642
|
+
* The deadline is what makes the controller more than ceremony. `copilotFetch`
|
|
1643
|
+
* bounds its own dispatch, but the bounded body read afterwards races only the
|
|
1644
|
+
* signal it was given — so without a deadline on this signal a stalled read would
|
|
1645
|
+
* hold the in-flight slot open indefinitely and every coalesced caller with it.
|
|
1646
|
+
* @param options - read for `requestTimeoutMs`.
|
|
1647
|
+
* @returns a signal that aborts on the exchange deadline and on nothing else.
|
|
1648
|
+
* @throws RangeError when `requestTimeoutMs` cannot serve as a bound.
|
|
1649
|
+
*/
|
|
1650
|
+
function sharedExchangeSignal(options) {
|
|
1651
|
+
const controller = new AbortController();
|
|
1652
|
+
const deadline = AbortSignal.timeout(positiveSafeInteger(options.requestTimeoutMs ?? 3e4, "requestTimeoutMs"));
|
|
1653
|
+
deadline.addEventListener("abort", () => {
|
|
1654
|
+
controller.abort(deadline.reason);
|
|
1655
|
+
}, { once: true });
|
|
1656
|
+
return controller.signal;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/router.ts
|
|
1661
|
+
/**
|
|
1662
|
+
* `Copilot_Endpoint_Router`: decide, ONCE per model id, which endpoint a
|
|
1663
|
+
* generation request is dispatched to.
|
|
1664
|
+
*
|
|
1665
|
+
* ## The decision order, and what is deliberately missing from it
|
|
1666
|
+
*
|
|
1667
|
+
* ```text
|
|
1668
|
+
* 1. endpointOverrides[modelId] ⇒ source 'override'
|
|
1669
|
+
* 2. catalog disclosure (via learn()) ⇒ source 'catalog'
|
|
1670
|
+
* 3. responses-model prefix allowlist ⇒ source 'allowlist'
|
|
1671
|
+
* 4. /chat/completions ⇒ source 'default'
|
|
1672
|
+
* ```
|
|
1673
|
+
*
|
|
1674
|
+
* What is missing is a PROBE. Trying `/responses` to find out whether a model
|
|
1675
|
+
* accepts it was rejected (DD-4): a probe is a real request that spends real
|
|
1676
|
+
* quota and needs a real prompt, so it has an observable side effect on the
|
|
1677
|
+
* user's account purely to answer a metadata question. Its result is not safely
|
|
1678
|
+
* cacheable across accounts either, since which models an account may call
|
|
1679
|
+
* depends on its plan.
|
|
1680
|
+
*
|
|
1681
|
+
* ## Why the default is `/chat/completions`
|
|
1682
|
+
*
|
|
1683
|
+
* The two ways of guessing wrong are ASYMMETRIC:
|
|
1684
|
+
*
|
|
1685
|
+
* | Guessed wrong | Consequence |
|
|
1686
|
+
* | --- | --- |
|
|
1687
|
+
* | Model supports `/responses`, we sent `/chat/completions` | works, minus some Responses-specific features |
|
|
1688
|
+
* | Model does not support `/responses`, we sent `/responses` | HTTP 400, dead request |
|
|
1689
|
+
*
|
|
1690
|
+
* Losing a feature is recoverable at the next call; losing the call is not. So
|
|
1691
|
+
* the fallback leans to the endpoint every Copilot generation model answers.
|
|
1692
|
+
*
|
|
1693
|
+
* ## Why `decisions` is append-only
|
|
1694
|
+
*
|
|
1695
|
+
* Requirement 9.7 asks that the endpoint chosen for a `Logical_Call` hold for
|
|
1696
|
+
* that whole call, retries included. The catalog has a TTL and may refresh
|
|
1697
|
+
* between two retries, so a router that recomputed could answer `/responses` on
|
|
1698
|
+
* the first attempt and `/chat/completions` on the second — one logical call
|
|
1699
|
+
* split across two wire protocols, with a serialized body that no longer matches
|
|
1700
|
+
* the endpoint it is going to.
|
|
1701
|
+
*
|
|
1702
|
+
* This module makes that STRUCTURALLY impossible rather than conventionally
|
|
1703
|
+
* avoided: once a model id has a decision, no code path rewrites it.
|
|
1704
|
+
* {@link CopilotEndpointRouter.learn} only adds keys that have no decision yet,
|
|
1705
|
+
* so a later catalog refresh returning different metadata changes nothing.
|
|
1706
|
+
*
|
|
1707
|
+
* The accepted cost (DD-5): a model misclassified on the first call keeps that
|
|
1708
|
+
* classification for the lifetime of the adapter instance. `endpointOverrides`
|
|
1709
|
+
* is the instant fix, `--models` is the discovery path, and rebuilding the
|
|
1710
|
+
* runtime is the reset. The trade is an invariant with no exceptions instead of
|
|
1711
|
+
* an invariant that holds "unless the catalog refreshed".
|
|
1712
|
+
*
|
|
1713
|
+
* @module ai-agent-sdk/providers/copilot/router
|
|
1714
|
+
*/
|
|
1715
|
+
/**
|
|
1716
|
+
* Model id prefixes dispatched to `/responses` when the catalog says nothing.
|
|
1717
|
+
*
|
|
1718
|
+
* Exported and overridable for the same reason `COPILOT_EDITOR_VERSION` is: this
|
|
1719
|
+
* is a fact about a remote endpoint that WILL go stale, and a user has to be able
|
|
1720
|
+
* to correct it without waiting for a release.
|
|
1721
|
+
* `CopilotProviderOptions.responsesModelPrefixes` ADDS to this list rather than
|
|
1722
|
+
* replacing it, so an override cannot silently drop the prefixes shipped here.
|
|
1723
|
+
*
|
|
1724
|
+
* Kept deliberately short. A prefix that matches too much pushes models toward
|
|
1725
|
+
* the endpoint where guessing wrong costs the request (see the module note), so
|
|
1726
|
+
* an absent prefix is the cheaper error.
|
|
1727
|
+
*/
|
|
1728
|
+
const COPILOT_RESPONSES_MODEL_PREFIXES = Object.freeze(["codex-", "gpt-5"]);
|
|
1729
|
+
/** The two endpoints, so an override value can be checked against something. */
|
|
1730
|
+
const COPILOT_ENDPOINTS = Object.freeze(["responses", "chat-completions"]);
|
|
1731
|
+
/** Protocol id per endpoint, the one place the two are tied together. */
|
|
1732
|
+
const PROTOCOL_IDS = Object.freeze({
|
|
1733
|
+
"responses": OPENAI_RESPONSES_PROTOCOL_ID,
|
|
1734
|
+
"chat-completions": OPENAI_CHAT_COMPLETIONS_PROTOCOL_ID
|
|
1735
|
+
});
|
|
1736
|
+
/**
|
|
1737
|
+
* Build a router for one adapter instance.
|
|
1738
|
+
*
|
|
1739
|
+
* Overrides are validated HERE, not at dispatch: a typo in
|
|
1740
|
+
* `endpointOverrides` is a configuration mistake, and a configuration mistake
|
|
1741
|
+
* that surfaces while building the provider is cheaper than one that surfaces on
|
|
1742
|
+
* the first request to one particular model.
|
|
1743
|
+
* @param options - overrides and the merged prefix allowlist.
|
|
1744
|
+
* @returns a router whose `decisions` map only ever grows.
|
|
1745
|
+
* @throws AgentSdkError with `COPILOT_ENDPOINT_OVERRIDE_INVALID` when an
|
|
1746
|
+
* override pins an endpoint that does not exist.
|
|
1747
|
+
*/
|
|
1748
|
+
function createCopilotEndpointRouter(options = {}) {
|
|
1749
|
+
const overrides = validateOverrides(options.overrides ?? {});
|
|
1750
|
+
const prefixes = normalizePrefixes(options.prefixes ?? COPILOT_RESPONSES_MODEL_PREFIXES);
|
|
1751
|
+
const decisions = /* @__PURE__ */ new Map();
|
|
1752
|
+
/** Write a decision for a key that has none. The single mutation point. */
|
|
1753
|
+
const record = (modelId, endpoint, source) => {
|
|
1754
|
+
const decision = Object.freeze({
|
|
1755
|
+
model: modelId,
|
|
1756
|
+
endpoint,
|
|
1757
|
+
protocolId: PROTOCOL_IDS[endpoint],
|
|
1758
|
+
source
|
|
1759
|
+
});
|
|
1760
|
+
decisions.set(modelId, decision);
|
|
1761
|
+
return decision;
|
|
1762
|
+
};
|
|
1763
|
+
/**
|
|
1764
|
+
* The decision order for a key with no recorded decision.
|
|
1765
|
+
*
|
|
1766
|
+
* `declared` is `undefined` for {@link CopilotEndpointRouter.decide}, because a
|
|
1767
|
+
* bare dispatch carries no catalog metadata — a disclosure only arrives through
|
|
1768
|
+
* {@link CopilotEndpointRouter.learn}. `undefined` is UNKNOWN, so it falls
|
|
1769
|
+
* through to the allowlist; `'chat-completions'` is a stated fact and stops
|
|
1770
|
+
* there with `source: 'catalog'`.
|
|
1771
|
+
*/
|
|
1772
|
+
const resolve = (modelId, declared) => {
|
|
1773
|
+
const override = overrides[modelId];
|
|
1774
|
+
if (override !== void 0) return record(modelId, override, "override");
|
|
1775
|
+
if (declared !== void 0) return record(modelId, declared, "catalog");
|
|
1776
|
+
if (matchesPrefix(modelId, prefixes)) return record(modelId, "responses", "allowlist");
|
|
1777
|
+
return record(modelId, "chat-completions", "default");
|
|
1778
|
+
};
|
|
1779
|
+
return Object.freeze({
|
|
1780
|
+
decide(modelId) {
|
|
1781
|
+
return decisions.get(modelId) ?? resolve(modelId, void 0);
|
|
1782
|
+
},
|
|
1783
|
+
learn(models) {
|
|
1784
|
+
for (const entry of models) {
|
|
1785
|
+
const modelId = entry.model.id;
|
|
1786
|
+
if (decisions.has(modelId)) continue;
|
|
1787
|
+
resolve(modelId, entry.declaredEndpoint);
|
|
1788
|
+
}
|
|
1789
|
+
},
|
|
1790
|
+
snapshot() {
|
|
1791
|
+
return Object.freeze([...decisions.values()]);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Copy the overrides and reject any value that is not an endpoint.
|
|
1797
|
+
*
|
|
1798
|
+
* A copy rather than the caller's object, so a later mutation of what was passed
|
|
1799
|
+
* in cannot introduce an unvalidated endpoint after construction.
|
|
1800
|
+
*/
|
|
1801
|
+
function validateOverrides(overrides) {
|
|
1802
|
+
const validated = Object.create(null);
|
|
1803
|
+
for (const [modelId, endpoint] of Object.entries(overrides)) {
|
|
1804
|
+
if (!COPILOT_ENDPOINTS.includes(endpoint)) throw new AgentSdkError(`Copilot endpoint override for model '${modelId}' must be one of ${COPILOT_ENDPOINTS.map((value) => `'${value}'`).join(", ")}`, COPILOT_ERROR_CODES.ENDPOINT_OVERRIDE_INVALID);
|
|
1805
|
+
validated[modelId] = endpoint;
|
|
1806
|
+
}
|
|
1807
|
+
return Object.freeze(validated);
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Lower-case the prefixes and drop the ones that cannot select anything.
|
|
1811
|
+
*
|
|
1812
|
+
* An empty string is dropped rather than honoured: as a prefix it matches every
|
|
1813
|
+
* model id, which would route the whole catalog to `/responses` — the direction
|
|
1814
|
+
* where guessing wrong costs the request. Dropping it leaves the shipped
|
|
1815
|
+
* prefixes intact, which is what a caller adding to the list asked for.
|
|
1816
|
+
*/
|
|
1817
|
+
function normalizePrefixes(prefixes) {
|
|
1818
|
+
const normalized = [];
|
|
1819
|
+
for (const prefix of prefixes) {
|
|
1820
|
+
if (typeof prefix !== "string" || prefix.length === 0) continue;
|
|
1821
|
+
const lower = prefix.toLowerCase();
|
|
1822
|
+
if (!normalized.includes(lower)) normalized.push(lower);
|
|
1823
|
+
}
|
|
1824
|
+
return Object.freeze(normalized);
|
|
1825
|
+
}
|
|
1826
|
+
/** Case-insensitive prefix match; Copilot model ids are lower-case in practice. */
|
|
1827
|
+
function matchesPrefix(modelId, prefixes) {
|
|
1828
|
+
const lower = modelId.toLowerCase();
|
|
1829
|
+
return prefixes.some((prefix) => lower.startsWith(prefix));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/adapter.ts
|
|
1834
|
+
/**
|
|
1835
|
+
* The Copilot provider: the Copilot API surface, authenticated with a GitHub user
|
|
1836
|
+
* token this project's own credential store holds.
|
|
1837
|
+
*
|
|
1838
|
+
* ## Configured, not subclassed
|
|
1839
|
+
*
|
|
1840
|
+
* `copilotAdapter` is built with `createRuntimeHttpProvider` and extends nothing
|
|
1841
|
+
* (Requirement 7.1). Everything Copilot needs beyond a plain API-key provider —
|
|
1842
|
+
* a two-tier credential, a token exchange with its own cache, two wire protocols
|
|
1843
|
+
* on one route, endpoint-driven discovery — is expressed as DATA:
|
|
1844
|
+
* `auth: { kind: 'dynamic' }` for the credential path, a composite protocol for
|
|
1845
|
+
* the two endpoints, `discoverModels` for the catalog. That is the point of the
|
|
1846
|
+
* exercise: the configuration path is proven by the provider with the most
|
|
1847
|
+
* demanding requirements in this repository, not by the simplest one.
|
|
1848
|
+
*
|
|
1849
|
+
* ## Client identity
|
|
1850
|
+
*
|
|
1851
|
+
* `COPILOT_EDITOR_VERSION` and `COPILOT_EDITOR_PLUGIN_VERSION` are two of the
|
|
1852
|
+
* three `Client_Identity_Constants` in this package; the third is
|
|
1853
|
+
* `COPILOT_OAUTH_CLIENT_ID` in `./oauth.ts`. Their defaults make this SDK
|
|
1854
|
+
* identify itself AS AN EDITOR CLIENT on every request to the Copilot surface.
|
|
1855
|
+
*
|
|
1856
|
+
* They are EXPORTED, OVERRIDABLE constants — not hidden values — precisely
|
|
1857
|
+
* because of that: presenting as another client is something the caller should be
|
|
1858
|
+
* able to read off the source and change without forking, so each one is a named
|
|
1859
|
+
* option (`editorHeaders`) with a visible default. Same reason
|
|
1860
|
+
* `CODEX_CLIENT_VERSION` is an exported constant in `provider-codex`. All three
|
|
1861
|
+
* values will also go stale, which is a second reason to keep them where a caller
|
|
1862
|
+
* can reach them.
|
|
1863
|
+
*
|
|
1864
|
+
* Use your own account, and prefer a provider's official first-party surface for
|
|
1865
|
+
* production. The README and the "Client identity" section of the docs carry the
|
|
1866
|
+
* full tradeoff.
|
|
1867
|
+
*
|
|
1868
|
+
* @module ai-agent-sdk/providers/copilot/adapter
|
|
1869
|
+
*/
|
|
1870
|
+
/** Registry id, provider family, and observation label when the caller sets none. */
|
|
1871
|
+
const COPILOT_ROUTE_ID = "copilot";
|
|
1872
|
+
/** Display name reported by the adapter and the plugin. */
|
|
1873
|
+
const COPILOT_DISPLAY_NAME = "GitHub Copilot";
|
|
1874
|
+
/** Never-aborting logger sink for a resolve that arrives without a context. */
|
|
1875
|
+
const NULL_LOGGER$1 = Object.freeze({
|
|
1876
|
+
child: () => NULL_LOGGER$1,
|
|
1877
|
+
trace: () => void 0,
|
|
1878
|
+
debug: () => void 0,
|
|
1879
|
+
info: () => void 0,
|
|
1880
|
+
warn: () => void 0,
|
|
1881
|
+
error: () => void 0,
|
|
1882
|
+
fatal: () => void 0
|
|
1883
|
+
});
|
|
1884
|
+
/** Marker written in place of a token value that appeared in a response body. */
|
|
1885
|
+
const REDACTED = "[REDACTED]";
|
|
1886
|
+
/**
|
|
1887
|
+
* The two transport-owned headers, sent on every request (Requirement 2.3).
|
|
1888
|
+
*
|
|
1889
|
+
* `content-type` cannot come from the auth layer — `provider-http` owns the name
|
|
1890
|
+
* at the transport layer and refuses a second owner — so it is declared here,
|
|
1891
|
+
* where it is allowed and where it is visible.
|
|
1892
|
+
*/
|
|
1893
|
+
const COPILOT_TRANSPORT_HEADERS = Object.freeze({
|
|
1894
|
+
"content-type": "application/json",
|
|
1895
|
+
accept: "text/event-stream"
|
|
1896
|
+
});
|
|
1897
|
+
/** Bytes read from a non-success response when the caller configures no bound. */
|
|
1898
|
+
const DEFAULT_MAX_ERROR_BODY_BYTES = 1048576;
|
|
1899
|
+
function copilotAdapter(options) {
|
|
1900
|
+
return buildCopilotAdapter(options, captureCopilotStore(options?.authStore));
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* The one adapter body, shared by both store variants and by the plugin.
|
|
1904
|
+
*
|
|
1905
|
+
* Four things are worth reading closely.
|
|
1906
|
+
*
|
|
1907
|
+
* **`auth.resolve` is the only place a token enters a request.** It reads the
|
|
1908
|
+
* store, demands a long-lived token, then asks the cache — which decides on its
|
|
1909
|
+
* own whether an exchange is due. `provider-http` calls `resolve` ONCE PER
|
|
1910
|
+
* OPERATION (Requirement 7.2), not once per retry, so the number of store reads
|
|
1911
|
+
* equals the number of operations and every attempt of one operation carries the
|
|
1912
|
+
* credential and the endpoint from a single snapshot.
|
|
1913
|
+
*
|
|
1914
|
+
* **`requireGitHubToken` runs BEFORE `cache.acquire`.** With no credential the
|
|
1915
|
+
* failure is `MISSING_CREDENTIAL` naming the login command, rather than an HTTP
|
|
1916
|
+
* error from an exchange that never had anything to exchange (Requirement 13.4).
|
|
1917
|
+
*
|
|
1918
|
+
* **`x-request-id` is the CLIENT's id, not the server's.** It exists to line up
|
|
1919
|
+
* two logs and carries nothing about the user.
|
|
1920
|
+
*
|
|
1921
|
+
* **`router.learn` only ADDS.** A catalog refresh never rewrites a decision that
|
|
1922
|
+
* already exists, which is how Requirement 9.7 holds structurally rather than by
|
|
1923
|
+
* convention.
|
|
1924
|
+
*
|
|
1925
|
+
* Every absent option is spread away instead of passed as `undefined`: a key
|
|
1926
|
+
* carrying `undefined` still overrides the runtime's own default, which turns
|
|
1927
|
+
* "I did not configure this" into "I configured this to nothing"
|
|
1928
|
+
* (Requirement 8.7).
|
|
1929
|
+
* @param options - the caller's options, either store variant.
|
|
1930
|
+
* @param captured - the already-captured store.
|
|
1931
|
+
* @returns the configured adapter.
|
|
1932
|
+
*/
|
|
1933
|
+
function buildCopilotAdapter(options, captured) {
|
|
1934
|
+
const router = createCopilotEndpointRouter({
|
|
1935
|
+
overrides: options.endpointOverrides ?? {},
|
|
1936
|
+
prefixes: [...COPILOT_RESPONSES_MODEL_PREFIXES, ...options.responsesModelPrefixes ?? []]
|
|
1937
|
+
});
|
|
1938
|
+
const editorHeaders = resolveCopilotEditorHeaders(options.editorHeaders);
|
|
1939
|
+
const secrets = createCopilotSecrets();
|
|
1940
|
+
const catalogLimits = resolveCopilotCatalogLimits(options);
|
|
1941
|
+
const providerId = options.id ?? "copilot";
|
|
1942
|
+
const cache = options.tokenCache ?? createCopilotTokenCache({
|
|
1943
|
+
providerId,
|
|
1944
|
+
editorHeaders,
|
|
1945
|
+
...options.githubApiBaseUrl === void 0 ? {} : { githubApiBaseUrl: options.githubApiBaseUrl },
|
|
1946
|
+
...options.exchangeMarginMs === void 0 ? {} : { marginMs: options.exchangeMarginMs },
|
|
1947
|
+
...options.requestTimeoutMs === void 0 ? {} : { requestTimeoutMs: options.requestTimeoutMs },
|
|
1948
|
+
...options.maxResponseBytes === void 0 ? {} : { maxResponseBytes: options.maxResponseBytes },
|
|
1949
|
+
...options.maxResponseChunks === void 0 ? {} : { maxResponseChunks: options.maxResponseChunks },
|
|
1950
|
+
...options.allowInsecureHttp === void 0 ? {} : { allowInsecureIssuer: options.allowInsecureHttp },
|
|
1951
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch }
|
|
1952
|
+
});
|
|
1953
|
+
const sessionId = options.dialect?.promptCacheKey ?? randomId();
|
|
1954
|
+
return createRuntimeHttpProvider({
|
|
1955
|
+
displayName: COPILOT_DISPLAY_NAME,
|
|
1956
|
+
protocol: copilotDualProtocol({
|
|
1957
|
+
router,
|
|
1958
|
+
responses: openAiResponsesProtocol,
|
|
1959
|
+
chat: openAiChatCompletionsProtocol,
|
|
1960
|
+
...options.onEndpointDecision === void 0 ? {} : { onDecision: options.onEndpointDecision }
|
|
1961
|
+
}),
|
|
1962
|
+
baseUrl: options.baseUrl ?? "https://api.githubcopilot.com",
|
|
1963
|
+
...options.allowInsecureHttp === void 0 ? {} : { allowInsecureHttp: options.allowInsecureHttp },
|
|
1964
|
+
dialect: {
|
|
1965
|
+
...options.dialect,
|
|
1966
|
+
promptCacheKey: sessionId
|
|
1967
|
+
},
|
|
1968
|
+
auth: {
|
|
1969
|
+
kind: "dynamic",
|
|
1970
|
+
resolve: async ({ signal, context }) => {
|
|
1971
|
+
const operation = {
|
|
1972
|
+
signal,
|
|
1973
|
+
logger: context?.logger ?? NULL_LOGGER$1
|
|
1974
|
+
};
|
|
1975
|
+
const snapshot = await readCopilotSnapshot(captured, operation);
|
|
1976
|
+
const github = requireGitHubToken(snapshot.file, snapshot.label);
|
|
1977
|
+
secrets.remember("github", github.token);
|
|
1978
|
+
const api = await cache.acquire(snapshot, operation, context);
|
|
1979
|
+
secrets.remember("api", api.token);
|
|
1980
|
+
return {
|
|
1981
|
+
authorization: `Bearer ${api.token}`,
|
|
1982
|
+
"editor-version": editorHeaders.editorVersion,
|
|
1983
|
+
"editor-plugin-version": editorHeaders.editorPluginVersion,
|
|
1984
|
+
"x-request-id": randomId()
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
...options.models === void 0 ? { discoverModels: async (context) => {
|
|
1989
|
+
const snapshot = await discoverCopilotModels(context, catalogLimits, options.fetch ?? globalThis.fetch);
|
|
1990
|
+
router.learn(snapshot.generation);
|
|
1991
|
+
return snapshot.generation.map((entry) => entry.model);
|
|
1992
|
+
} } : { models: options.models },
|
|
1993
|
+
...copilotCatalogCacheOptions(options),
|
|
1994
|
+
...options.maxCatalogModels === void 0 ? {} : { maxCatalogModels: options.maxCatalogModels },
|
|
1995
|
+
...options.maxCatalogBytes === void 0 ? {} : { maxCatalogBytes: options.maxCatalogBytes },
|
|
1996
|
+
...options.defaultMaxTokens === void 0 ? {} : { defaultMaxTokens: options.defaultMaxTokens },
|
|
1997
|
+
...options.defaultContextWindow === void 0 ? {} : { defaultContextWindow: options.defaultContextWindow },
|
|
1998
|
+
...transportLimits(options),
|
|
1999
|
+
baseHeaders: COPILOT_TRANSPORT_HEADERS,
|
|
2000
|
+
...options.retryPolicy === void 0 ? {} : { retryPolicy: options.retryPolicy },
|
|
2001
|
+
...options.requestLogger === void 0 ? {} : { requestLogger: options.requestLogger },
|
|
2002
|
+
errorCode: (status, detail) => isMissingEditorHeaderFailure(status, detail) ? COPILOT_ERROR_CODES.EDITOR_HEADERS_MISSING : void 0,
|
|
2003
|
+
fetch: copilotProviderFetch(options, secrets)
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* The transactional plugin for installing the Copilot provider.
|
|
2008
|
+
*
|
|
2009
|
+
* Composition follows `codexPlugin`: `id` defaults to {@link COPILOT_ROUTE_ID},
|
|
2010
|
+
* `family` is `'copilot'`, `routes` defaults to `[id]`, and a string
|
|
2011
|
+
* `defaultModel` requires exactly one route so the model target's provider can be
|
|
2012
|
+
* inferred (Requirements 7.4, 7.5).
|
|
2013
|
+
*
|
|
2014
|
+
* One difference from Codex: there is no overload per store variant. The
|
|
2015
|
+
* compare-and-swap store is the main path here, the read/write variant exists for
|
|
2016
|
+
* symmetry, and {@link copilotAdapter} is where it is accepted (Requirement 6.2).
|
|
2017
|
+
* The marker is checked at construction rather than at setup so a wrong store is
|
|
2018
|
+
* reported while the runtime is being composed, not on the first generation.
|
|
2019
|
+
* @param options - the same options {@link copilotAdapter} takes, CAS store only.
|
|
2020
|
+
* @returns a composable plugin registering one Copilot adapter.
|
|
2021
|
+
* @throws TypeError when `authStore` is not the compare-and-swap variant, or when
|
|
2022
|
+
* a string `defaultModel` is paired with anything but exactly one route.
|
|
2023
|
+
*/
|
|
2024
|
+
function copilotPlugin(options) {
|
|
2025
|
+
if (!isCredentialStoreInput(options?.authStore)) throw new TypeError("copilotPlugin requires a Copilot credential store (the CAS variant)");
|
|
2026
|
+
const id = options.id ?? "copilot";
|
|
2027
|
+
const routes = Object.freeze([...options.routes ?? [id]]);
|
|
2028
|
+
return defineModelProviderPlugin({
|
|
2029
|
+
id,
|
|
2030
|
+
family: "copilot",
|
|
2031
|
+
displayName: COPILOT_DISPLAY_NAME,
|
|
2032
|
+
routes,
|
|
2033
|
+
...runtimeDefaultModel(options.defaultModel, routes),
|
|
2034
|
+
setup(registrar) {
|
|
2035
|
+
const adapter = buildCopilotAdapter(options, captureCopilotStore(options.authStore));
|
|
2036
|
+
const remove = registrar.registerAdapter(adapter);
|
|
2037
|
+
return () => {
|
|
2038
|
+
remove();
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Marker inspection only: no accessor is invoked and no method is captured.
|
|
2045
|
+
*
|
|
2046
|
+
* Full capture stays deferred to {@link buildCopilotAdapter}, so this check
|
|
2047
|
+
* cannot be the thing that runs the caller's code.
|
|
2048
|
+
* @param value - the `authStore` as passed in.
|
|
2049
|
+
* @returns true when it carries the credential-store marker.
|
|
2050
|
+
*/
|
|
2051
|
+
function isCredentialStoreInput(value) {
|
|
2052
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2053
|
+
const marker = Object.getOwnPropertyDescriptor(value, "kind");
|
|
2054
|
+
return marker !== void 0 && "value" in marker && marker.value === "credential-store";
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Resolve `defaultModel`, demanding one route for the string form.
|
|
2058
|
+
*
|
|
2059
|
+
* A string names a model but not a provider, and the provider is inferred from
|
|
2060
|
+
* the route. With two routes there is no answer, and picking the first would
|
|
2061
|
+
* install a default nobody chose (Requirement 7.5).
|
|
2062
|
+
* @param value - the caller's default model, when they set one.
|
|
2063
|
+
* @param routes - the routes this plugin installs.
|
|
2064
|
+
* @returns a one-key spread carrying `defaultModel`, or an empty one.
|
|
2065
|
+
* @throws TypeError when a string is paired with anything but exactly one route.
|
|
2066
|
+
*/
|
|
2067
|
+
function runtimeDefaultModel(value, routes) {
|
|
2068
|
+
if (value === void 0) return {};
|
|
2069
|
+
if (typeof value !== "string") return { defaultModel: value };
|
|
2070
|
+
if (routes.length !== 1) throw new TypeError("A string defaultModel requires exactly one Copilot route");
|
|
2071
|
+
return { defaultModel: Object.freeze({
|
|
2072
|
+
provider: routes[0] ?? "copilot",
|
|
2073
|
+
id: value
|
|
2074
|
+
}) };
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Read the credential store once, through whichever variant was captured.
|
|
2078
|
+
*
|
|
2079
|
+
* The read/write variant has no revisions, so its snapshot revision is `null` —
|
|
2080
|
+
* which the token cache compares just as strictly as a real revision, it simply
|
|
2081
|
+
* never changes on its own.
|
|
2082
|
+
* @param captured - the captured store.
|
|
2083
|
+
* @param operation - the calling operation, whose signal bounds the read.
|
|
2084
|
+
* @returns the file, its revision and the store label, as one snapshot.
|
|
2085
|
+
* @throws AgentSdkError with the SDK's missing-credential code when the store is
|
|
2086
|
+
* empty (Requirement 13.4).
|
|
2087
|
+
*/
|
|
2088
|
+
async function readCopilotSnapshot(captured, operation) {
|
|
2089
|
+
const record = captured.kind === "versioned" ? await captured.store.read(operation) : {
|
|
2090
|
+
value: await captured.store.read(),
|
|
2091
|
+
revision: null
|
|
2092
|
+
};
|
|
2093
|
+
return Object.freeze({
|
|
2094
|
+
file: requireCopilotFile(record?.value, captured.label),
|
|
2095
|
+
revision: record?.revision ?? null,
|
|
2096
|
+
label: captured.label
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Demand a credential file, reusing the one message that says how to get one.
|
|
2101
|
+
*
|
|
2102
|
+
* `requireGitHubToken` owns the message and the code for all three shapes of "no
|
|
2103
|
+
* credential", so it is asked first. The throw after it is UNREACHABLE — an
|
|
2104
|
+
* absent file already failed there — and exists only so the type narrows without
|
|
2105
|
+
* a non-null assertion.
|
|
2106
|
+
* @param file - the file the store returned, or `undefined` for an empty store.
|
|
2107
|
+
* @param label - the store location named in the diagnostic.
|
|
2108
|
+
* @returns the file.
|
|
2109
|
+
*/
|
|
2110
|
+
function requireCopilotFile(file, label) {
|
|
2111
|
+
requireGitHubToken(file, label);
|
|
2112
|
+
if (file === void 0) throw new AgentSdkError(`no GitHub Copilot credentials at ${label}`, MISSING_CREDENTIAL_CODE);
|
|
2113
|
+
return file;
|
|
2114
|
+
}
|
|
2115
|
+
/** Build the two-slot secret registry. */
|
|
2116
|
+
function createCopilotSecrets() {
|
|
2117
|
+
let github = "";
|
|
2118
|
+
let api = "";
|
|
2119
|
+
return {
|
|
2120
|
+
remember(kind, value) {
|
|
2121
|
+
if (value.length === 0) return;
|
|
2122
|
+
if (kind === "github") github = value;
|
|
2123
|
+
else api = value;
|
|
2124
|
+
},
|
|
2125
|
+
redact(text) {
|
|
2126
|
+
let result = text;
|
|
2127
|
+
for (const secret of [github, api]) {
|
|
2128
|
+
if (secret.length === 0) continue;
|
|
2129
|
+
result = result.split(secret).join(REDACTED);
|
|
2130
|
+
}
|
|
2131
|
+
return result;
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* The fetch the provider dispatches through: identical to the injected one,
|
|
2137
|
+
* except that an error body is redacted — and, for the one case the endpoint is
|
|
2138
|
+
* known to be unhelpful about, explained — before anything retains it.
|
|
2139
|
+
*
|
|
2140
|
+
* Why here and not in an error mapper: `provider-http` puts the raw error body
|
|
2141
|
+
* into the failure's `cause`, and by the time a mapper sees it the text is
|
|
2142
|
+
* already retained. Redacting at the transport is the only point that runs BEFORE
|
|
2143
|
+
* that, and an endpoint echoing the `Authorization` header back in an error body
|
|
2144
|
+
* is something that has actually happened (Requirement 13.7).
|
|
2145
|
+
*
|
|
2146
|
+
* What is deliberately NOT touched:
|
|
2147
|
+
*
|
|
2148
|
+
* - **Successful responses.** The body is a live SSE stream and must reach the
|
|
2149
|
+
* pipeline unread and unwrapped.
|
|
2150
|
+
* - **Redirects, in every shape.** Rebuilding a `Response` loses `type`,
|
|
2151
|
+
* `redirected` and `url` — the three signals the transport's redirect guard
|
|
2152
|
+
* reads — so anything that is not a 4xx/5xx passes through untouched and the
|
|
2153
|
+
* guard still sees the original (Requirement 7.8).
|
|
2154
|
+
* @param options - read for the injected fetch and the error-body bound.
|
|
2155
|
+
* @param secrets - the live token values to redact.
|
|
2156
|
+
* @returns a fetch implementation to hand to the runtime provider.
|
|
2157
|
+
*/
|
|
2158
|
+
function copilotProviderFetch(options, secrets) {
|
|
2159
|
+
const inner = options.fetch ?? globalThis.fetch;
|
|
2160
|
+
const maxBytes = options.maxErrorBodyBytes ?? DEFAULT_MAX_ERROR_BODY_BYTES;
|
|
2161
|
+
return async (...args) => {
|
|
2162
|
+
const response = await inner(...args);
|
|
2163
|
+
if (response.status < 400 || response.type === "opaqueredirect" || response.redirected) return response;
|
|
2164
|
+
let raw;
|
|
2165
|
+
try {
|
|
2166
|
+
raw = await readErrorBody(response, maxBytes);
|
|
2167
|
+
} catch {
|
|
2168
|
+
return response;
|
|
2169
|
+
}
|
|
2170
|
+
const redacted = secrets.redact(raw);
|
|
2171
|
+
const body = isMissingEditorHeaderFailure(response.status, redacted) ? editorHeaderDiagnostic(redacted) : redacted;
|
|
2172
|
+
const headers = new Headers(response.headers);
|
|
2173
|
+
headers.delete("content-length");
|
|
2174
|
+
return new Response(body, {
|
|
2175
|
+
status: response.status,
|
|
2176
|
+
statusText: response.statusText,
|
|
2177
|
+
headers
|
|
2178
|
+
});
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Read an error body up to a byte bound, marking a truncation rather than hiding it.
|
|
2183
|
+
* @param response - the non-success response.
|
|
2184
|
+
* @param maxBytes - the configured bound (Requirement 13.6).
|
|
2185
|
+
* @returns the decoded text, truncated with a note when it hit the bound.
|
|
2186
|
+
*/
|
|
2187
|
+
async function readErrorBody(response, maxBytes) {
|
|
2188
|
+
if (response.body === null) return "";
|
|
2189
|
+
const reader = response.body.getReader();
|
|
2190
|
+
const decoder = new TextDecoder();
|
|
2191
|
+
let bytes = 0;
|
|
2192
|
+
let text = "";
|
|
2193
|
+
try {
|
|
2194
|
+
while (true) {
|
|
2195
|
+
const next = await reader.read();
|
|
2196
|
+
if (next.done) return text + decoder.decode();
|
|
2197
|
+
if (next.value === void 0) continue;
|
|
2198
|
+
const remaining = maxBytes - bytes;
|
|
2199
|
+
if (remaining <= 0 || next.value.byteLength > remaining) {
|
|
2200
|
+
const kept = remaining <= 0 ? void 0 : next.value.subarray(0, remaining);
|
|
2201
|
+
const partial = kept === void 0 ? "" : decoder.decode(kept, { stream: true });
|
|
2202
|
+
await reader.cancel().catch(() => void 0);
|
|
2203
|
+
return `${text}${partial}${decoder.decode()}\n[error body truncated at ${maxBytes} bytes]`;
|
|
2204
|
+
}
|
|
2205
|
+
bytes += next.value.byteLength;
|
|
2206
|
+
text += decoder.decode(next.value, { stream: true });
|
|
2207
|
+
}
|
|
2208
|
+
} finally {
|
|
2209
|
+
reader.releaseLock();
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Whether a failure looks like the endpoint refusing a request for a missing
|
|
2214
|
+
* editor header.
|
|
2215
|
+
*
|
|
2216
|
+
* Matched BROADLY on purpose. The endpoint's wording is not a contract — it is
|
|
2217
|
+
* one sentence that can be rephrased at any time — so this looks for the header
|
|
2218
|
+
* names in any plausible spelling, or for the word "editor" beside a complaint
|
|
2219
|
+
* about a header. It is also bounded to status 400: a 400 that says nothing about
|
|
2220
|
+
* editors keeps `REQUEST_INVALID` from the shared mapping rather than being
|
|
2221
|
+
* relabelled into a Copilot-specific failure it is not (Requirement 2.5).
|
|
2222
|
+
* @param status - the response status.
|
|
2223
|
+
* @param detail - the provider's error text, joined by the shared parser.
|
|
2224
|
+
* @returns true when the missing-header diagnosis is warranted.
|
|
2225
|
+
*/
|
|
2226
|
+
function isMissingEditorHeaderFailure(status, detail) {
|
|
2227
|
+
if (status !== 400) return false;
|
|
2228
|
+
if (/editor[\s_-]*(?:plugin[\s_-]*)?version/i.test(detail)) return true;
|
|
2229
|
+
return /\beditor\b/i.test(detail) && /(missing|required|absent|invalid|unsupported|unrecogni[sz]ed|header)/i.test(detail);
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Wrap the endpoint's 400 in a body that names both headers and how to set them.
|
|
2233
|
+
*
|
|
2234
|
+
* The endpoint's own text is kept beside it rather than replaced: it is the
|
|
2235
|
+
* evidence, and the shared classifier reads it too.
|
|
2236
|
+
* @param endpointText - the endpoint's error body, already redacted.
|
|
2237
|
+
* @returns a JSON error body carrying the SDK-authored diagnosis.
|
|
2238
|
+
*/
|
|
2239
|
+
function editorHeaderDiagnostic(endpointText) {
|
|
2240
|
+
return JSON.stringify({ error: {
|
|
2241
|
+
code: COPILOT_ERROR_CODES.EDITOR_HEADERS_MISSING,
|
|
2242
|
+
message: `the Copilot endpoint rejected this request for a missing or unaccepted editor header. Both \`Editor-Version\` and \`Editor-Plugin-Version\` are mandatory; configure them with the \`editorHeaders\` option (\`editorVersion\`, \`editorPluginVersion\`), whose defaults are the exported COPILOT_EDITOR_VERSION and COPILOT_EDITOR_PLUGIN_VERSION constants. The endpoint said: ${endpointText}`
|
|
2243
|
+
} });
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Forward every transport bound the caller set, and only those.
|
|
2247
|
+
* @param options - the caller's options.
|
|
2248
|
+
* @returns an object carrying the configured transport limits.
|
|
2249
|
+
*/
|
|
2250
|
+
function transportLimits(options) {
|
|
2251
|
+
return {
|
|
2252
|
+
...options.streamIdleTimeoutMs === void 0 ? {} : { streamIdleTimeoutMs: options.streamIdleTimeoutMs },
|
|
2253
|
+
...options.requestTimeoutMs === void 0 ? {} : { requestTimeoutMs: options.requestTimeoutMs },
|
|
2254
|
+
...options.maxRequestBytes === void 0 ? {} : { maxRequestBytes: options.maxRequestBytes },
|
|
2255
|
+
...options.maxResponseBytes === void 0 ? {} : { maxResponseBytes: options.maxResponseBytes },
|
|
2256
|
+
...options.maxResponseChunks === void 0 ? {} : { maxResponseChunks: options.maxResponseChunks },
|
|
2257
|
+
...options.maxSseEvents === void 0 ? {} : { maxSseEvents: options.maxSseEvents },
|
|
2258
|
+
...options.maxSseEventChars === void 0 ? {} : { maxSseEventChars: options.maxSseEventChars },
|
|
2259
|
+
...options.maxErrorBodyBytes === void 0 ? {} : { maxErrorBodyBytes: options.maxErrorBodyBytes },
|
|
2260
|
+
...options.requestLoggerTimeoutMs === void 0 ? {} : { requestLoggerTimeoutMs: options.requestLoggerTimeoutMs }
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
/** A client-side correlation id; carries nothing about the account or the prompt. */
|
|
2264
|
+
function randomId() {
|
|
2265
|
+
return globalThis.crypto?.randomUUID?.() ?? `sdk-${Date.now().toString(36)}`;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
//#endregion
|
|
2269
|
+
//#region src/oauth.ts
|
|
2270
|
+
/** GitHub's OAuth issuer. */
|
|
2271
|
+
const DEFAULT_COPILOT_OAUTH_ISSUER = "https://github.com";
|
|
2272
|
+
/**
|
|
2273
|
+
* Default OAuth client id. Public, not a secret.
|
|
2274
|
+
*
|
|
2275
|
+
* This is the client id published in GitHub's own editor-plugin sources (the
|
|
2276
|
+
* value `copilot.vim` and the other Copilot editor integrations ship in the
|
|
2277
|
+
* clear), which is why it is on the allowlist that
|
|
2278
|
+
* `copilot_internal/v2/token` checks. Sending it means this SDK signs in AS that
|
|
2279
|
+
* editor client. See the module note for why that makes it a named option
|
|
2280
|
+
* instead of a hidden constant.
|
|
2281
|
+
*
|
|
2282
|
+
* ⚠ UNVERIFIED against a live account. Recorded 2026-09-10 from public editor
|
|
2283
|
+
* integration sources only; no sign-in against a real Copilot account has
|
|
2284
|
+
* confirmed THIS client id.
|
|
2285
|
+
*
|
|
2286
|
+
* The 2026-09-10 live run that confirmed the two editor headers did NOT confirm
|
|
2287
|
+
* this value, and could not: it was handed an existing `ghu_` user-to-server token
|
|
2288
|
+
* out of band, so it exercised the EXCHANGE (which answered 200 for that token)
|
|
2289
|
+
* while never running the device flow that would put this `client_id` on the wire.
|
|
2290
|
+
* What that run does establish is the shape of the claim still outstanding — the
|
|
2291
|
+
* exchange endpoint and the allowlist check are live and reachable, and the only
|
|
2292
|
+
* untested link is whether they accept a token minted by this particular app.
|
|
2293
|
+
*
|
|
2294
|
+
* TODO(copilot-identity): confirm on a real Copilot account, then replace this
|
|
2295
|
+
* warning with the confirmation date. To confirm: run the device flow against
|
|
2296
|
+
* `https://github.com/login/device/code` with this `client_id` and
|
|
2297
|
+
* `scope=read:user`, approve it on a Copilot-enabled account, then exchange the
|
|
2298
|
+
* resulting user token at `GET https://api.github.com/copilot_internal/v2/token`.
|
|
2299
|
+
* The client id is confirmed when that exchange returns a Copilot token rather
|
|
2300
|
+
* than 401/403. A non-allowlisted client id fails at the exchange, not at
|
|
2301
|
+
* sign-in, so the device flow succeeding on its own proves nothing — and equally,
|
|
2302
|
+
* an exchange that succeeds for a token this flow did not mint proves nothing
|
|
2303
|
+
* about this constant.
|
|
2304
|
+
*/
|
|
2305
|
+
const COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
2306
|
+
/** Requested scope; enough to exchange a token and read identity, no more. */
|
|
2307
|
+
const COPILOT_OAUTH_SCOPE = "read:user";
|
|
2308
|
+
/**
|
|
2309
|
+
* The absolute ceiling on one device login, INDEPENDENT of the server's
|
|
2310
|
+
* `expires_in`.
|
|
2311
|
+
*
|
|
2312
|
+
* `expires_in` is honoured when it is shorter — there is no point polling a code
|
|
2313
|
+
* the server has already retired. It is not honoured when it is longer: a server
|
|
2314
|
+
* that answers `expires_in: 86400` would otherwise hang a CLI for a day, and this
|
|
2315
|
+
* SDK is not the right place to hold that terminal hostage (Requirement 4.3).
|
|
2316
|
+
*/
|
|
2317
|
+
const COPILOT_DEVICE_CODE_MAX_WAIT_MS = 9e5;
|
|
2318
|
+
/** Poll interval used when the device-code response states none. */
|
|
2319
|
+
const COPILOT_DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
2320
|
+
/**
|
|
2321
|
+
* Seconds added on every `slow_down`, per RFC 8628 §3.5.
|
|
2322
|
+
*
|
|
2323
|
+
* The increment is what makes the wait STRICTLY increase even when the server
|
|
2324
|
+
* repeats `slow_down` without a new `interval`. Without it, a server that only
|
|
2325
|
+
* ever says "slow down" would be polled at exactly the rate it just objected to.
|
|
2326
|
+
*/
|
|
2327
|
+
const COPILOT_SLOW_DOWN_INCREMENT_SECONDS = 5;
|
|
2328
|
+
/**
|
|
2329
|
+
* The warning shown beside the user code, worded exactly as the Codex device
|
|
2330
|
+
* prompt words it.
|
|
2331
|
+
*
|
|
2332
|
+
* A device code is a bearer of authorization that the user types into a page they
|
|
2333
|
+
* navigated to themselves. The one attack that works is getting somebody to type
|
|
2334
|
+
* an attacker's code, so the prompt has to say so; and it lives here rather than
|
|
2335
|
+
* in the CLI so every front end that renders a Copilot prompt renders the same
|
|
2336
|
+
* sentence.
|
|
2337
|
+
*/
|
|
2338
|
+
const COPILOT_DEVICE_LOGIN_WARNING = "Only continue if YOU started this login. If someone sent you this code, stop.";
|
|
2339
|
+
/** GitHub's device-authorization leg. */
|
|
2340
|
+
const DEVICE_CODE_PATH = "/login/device/code";
|
|
2341
|
+
/** GitHub's device-token leg. */
|
|
2342
|
+
const DEVICE_TOKEN_PATH = "/login/oauth/access_token";
|
|
2343
|
+
/** The device-code grant type, spelled as RFC 8628 requires. */
|
|
2344
|
+
const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
2345
|
+
/** The command that produces a credential, named when a login ends without one. */
|
|
2346
|
+
const COPILOT_LOGIN_COMMAND$1 = "npm run provider:copilot:login-device";
|
|
2347
|
+
const NEVER_ABORTED_SIGNAL = new AbortController().signal;
|
|
2348
|
+
const NULL_LOGGER = Object.freeze({
|
|
2349
|
+
child: () => NULL_LOGGER,
|
|
2350
|
+
trace: () => void 0,
|
|
2351
|
+
debug: () => void 0,
|
|
2352
|
+
info: () => void 0,
|
|
2353
|
+
warn: () => void 0,
|
|
2354
|
+
error: () => void 0,
|
|
2355
|
+
fatal: () => void 0
|
|
2356
|
+
});
|
|
2357
|
+
/** The real scheduler: `setTimeout`, `clearTimeout` and `Date.now`. */
|
|
2358
|
+
const DEFAULT_COPILOT_TIMER = Object.freeze({
|
|
2359
|
+
setTimeout: (handler, ms) => setTimeout(handler, ms),
|
|
2360
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
2361
|
+
now: () => Date.now()
|
|
2362
|
+
});
|
|
2363
|
+
/**
|
|
2364
|
+
* Start a device authorization.
|
|
2365
|
+
*
|
|
2366
|
+
* `Accept: application/json` is set here as well as on the token leg. It is
|
|
2367
|
+
* load-bearing on the token leg (see {@link pollForCopilotToken}) and harmless
|
|
2368
|
+
* here, and setting it on both keeps the pair from drifting into "one of the two
|
|
2369
|
+
* legs parses JSON".
|
|
2370
|
+
* @param options - issuer, client id, scope, cancellation and read bounds.
|
|
2371
|
+
* @returns the code, the URL and the timings to show the user.
|
|
2372
|
+
* @throws CopilotDeviceLoginError with `reason: 'aborted'` when the caller's
|
|
2373
|
+
* signal aborts, or `reason: 'failed'` when the endpoint answers with anything
|
|
2374
|
+
* other than a usable device authorization.
|
|
2375
|
+
*/
|
|
2376
|
+
async function requestCopilotDeviceCode(options = {}) {
|
|
2377
|
+
const pinned = issuerOf("oauthIssuer", options.oauthIssuer, DEFAULT_COPILOT_OAUTH_ISSUER, options);
|
|
2378
|
+
const body = await deviceJson({
|
|
2379
|
+
pinned,
|
|
2380
|
+
url: copilotUrl(pinned, DEVICE_CODE_PATH),
|
|
2381
|
+
operation: "device code",
|
|
2382
|
+
init: {
|
|
2383
|
+
method: "POST",
|
|
2384
|
+
headers: {
|
|
2385
|
+
accept: "application/json",
|
|
2386
|
+
"content-type": "application/json"
|
|
2387
|
+
},
|
|
2388
|
+
body: JSON.stringify({
|
|
2389
|
+
client_id: options.clientId ?? "Iv1.b507a08c87ecfe98",
|
|
2390
|
+
scope: options.scope ?? "read:user"
|
|
2391
|
+
})
|
|
2392
|
+
}
|
|
2393
|
+
}, "the device-code endpoint", options);
|
|
2394
|
+
if (!body.ok) throw deviceFailure(`the device-code endpoint failed (HTTP ${body.status})`, "failed", body.parseError);
|
|
2395
|
+
return Object.freeze({
|
|
2396
|
+
verificationUrl: verificationUrlOf(body.json, options),
|
|
2397
|
+
userCode: requireDeviceString(body.json, "user_code"),
|
|
2398
|
+
deviceCode: requireDeviceString(body.json, "device_code"),
|
|
2399
|
+
intervalSeconds: positiveSecondsOf(body.json.interval, 5),
|
|
2400
|
+
expiresInSeconds: positiveSecondsOf(body.json.expires_in, COPILOT_DEVICE_CODE_MAX_WAIT_MS / 1e3)
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
async function runCopilotDeviceLogin(store, options = {}, progress = {}) {
|
|
2404
|
+
const captured = captureCopilotStore(store);
|
|
2405
|
+
const operation = {
|
|
2406
|
+
signal: options.signal ?? NEVER_ABORTED_SIGNAL,
|
|
2407
|
+
logger: NULL_LOGGER
|
|
2408
|
+
};
|
|
2409
|
+
const initial = await readStore(captured, operation);
|
|
2410
|
+
const code = await requestCopilotDeviceCode(options);
|
|
2411
|
+
notify(() => progress.onPrompt?.(code));
|
|
2412
|
+
const token = await pollForCopilotToken(code, options, progress);
|
|
2413
|
+
await commitStore(captured, {
|
|
2414
|
+
version: 1,
|
|
2415
|
+
github: {
|
|
2416
|
+
token: token.accessToken,
|
|
2417
|
+
...token.tokenType === void 0 ? {} : { tokenType: token.tokenType },
|
|
2418
|
+
...token.scope === void 0 ? {} : { scope: token.scope }
|
|
2419
|
+
},
|
|
2420
|
+
...token.account === void 0 ? {} : { account: token.account },
|
|
2421
|
+
clientId: options.clientId ?? "Iv1.b507a08c87ecfe98",
|
|
2422
|
+
obtainedAt: new Date(timerOf(options).now()).toISOString()
|
|
2423
|
+
}, initial.revision, operation);
|
|
2424
|
+
return Object.freeze({
|
|
2425
|
+
location: captured.label,
|
|
2426
|
+
login: token.account?.login,
|
|
2427
|
+
accountId: token.account?.id,
|
|
2428
|
+
scope: token.scope
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Poll the token leg until the user approves, the server refuses, or a bound
|
|
2433
|
+
* passes.
|
|
2434
|
+
*
|
|
2435
|
+
* Two things make this loop different from the Codex one, and both are easy to
|
|
2436
|
+
* get wrong:
|
|
2437
|
+
*
|
|
2438
|
+
* - **`Accept: application/json` is mandatory.** Without it GitHub's token
|
|
2439
|
+
* endpoint answers FORM-ENCODED, so a JSON parser meets
|
|
2440
|
+
* `error=authorization_pending&interval=10` and throws — which turns the "not
|
|
2441
|
+
* approved yet" branch into a hard-failure branch, and the flow can then never
|
|
2442
|
+
* succeed at all.
|
|
2443
|
+
* - **The error channel is HTTP 200 with `error` in the body.** Codex surfaces
|
|
2444
|
+
* "pending" as 403/404; GitHub surfaces it as a 200. So classification reads the
|
|
2445
|
+
* BODY FIRST and the status second. A status-first reader treats every pending
|
|
2446
|
+
* poll as a success and then fails looking for `access_token`.
|
|
2447
|
+
* @param code - the pending authorization.
|
|
2448
|
+
* @param options - issuer, client id, bounds and the injectable timer.
|
|
2449
|
+
* @param progress - poll notifications.
|
|
2450
|
+
* @returns the access token and whatever the endpoint disclosed beside it.
|
|
2451
|
+
*/
|
|
2452
|
+
async function pollForCopilotToken(code, options, progress) {
|
|
2453
|
+
const pinned = issuerOf("oauthIssuer", options.oauthIssuer, DEFAULT_COPILOT_OAUTH_ISSUER, options);
|
|
2454
|
+
const url = copilotUrl(pinned, DEVICE_TOKEN_PATH);
|
|
2455
|
+
const timer = timerOf(options);
|
|
2456
|
+
const startedAt = timer.now();
|
|
2457
|
+
const deadlineAt = startedAt + Math.min(COPILOT_DEVICE_CODE_MAX_WAIT_MS, code.expiresInSeconds * 1e3);
|
|
2458
|
+
let intervalSeconds = code.intervalSeconds;
|
|
2459
|
+
while (true) {
|
|
2460
|
+
throwIfAborted(options.signal);
|
|
2461
|
+
if (timer.now() >= deadlineAt) throw deviceTimeout(startedAt, timer.now());
|
|
2462
|
+
notify(() => progress.onPoll?.(timer.now() - startedAt, intervalSeconds));
|
|
2463
|
+
const body = await deviceJson({
|
|
2464
|
+
pinned,
|
|
2465
|
+
url,
|
|
2466
|
+
operation: "device token",
|
|
2467
|
+
init: {
|
|
2468
|
+
method: "POST",
|
|
2469
|
+
headers: {
|
|
2470
|
+
accept: "application/json",
|
|
2471
|
+
"content-type": "application/json"
|
|
2472
|
+
},
|
|
2473
|
+
body: JSON.stringify({
|
|
2474
|
+
client_id: options.clientId ?? "Iv1.b507a08c87ecfe98",
|
|
2475
|
+
device_code: code.deviceCode,
|
|
2476
|
+
grant_type: DEVICE_GRANT_TYPE
|
|
2477
|
+
})
|
|
2478
|
+
}
|
|
2479
|
+
}, "the device-token endpoint", options);
|
|
2480
|
+
const error = typeof body.json.error === "string" ? body.json.error : void 0;
|
|
2481
|
+
if (error === "authorization_pending" || error === "slow_down") {
|
|
2482
|
+
intervalSeconds = nextIntervalSeconds(intervalSeconds, body.json.interval, error);
|
|
2483
|
+
const remaining = deadlineAt - timer.now();
|
|
2484
|
+
if (remaining <= 0) throw deviceTimeout(startedAt, timer.now());
|
|
2485
|
+
await sleep(Math.min(intervalSeconds * 1e3, remaining), options.signal, timer);
|
|
2486
|
+
continue;
|
|
2487
|
+
}
|
|
2488
|
+
if (error === "access_denied") throw deviceFailure(`the device login was denied on GitHub; run \`${COPILOT_LOGIN_COMMAND$1}\` again if you did mean to approve it`, "denied");
|
|
2489
|
+
if (error === "expired_token") throw deviceFailure(`the device code expired before it was approved; run \`${COPILOT_LOGIN_COMMAND$1}\` again to request a new code`, "expired");
|
|
2490
|
+
if (error !== void 0) throw deviceFailure(`the device-token endpoint refused the request (${error}, HTTP ${body.status})`, "failed");
|
|
2491
|
+
if (!body.ok) throw deviceFailure(`the device-token endpoint failed (HTTP ${body.status})`, "failed", body.parseError);
|
|
2492
|
+
return Object.freeze({
|
|
2493
|
+
accessToken: requireDeviceString(body.json, "access_token"),
|
|
2494
|
+
tokenType: optionalString(body.json.token_type),
|
|
2495
|
+
scope: optionalString(body.json.scope),
|
|
2496
|
+
account: accountIdentityOf(body.json)
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* The effective wait after a `slow_down`, which must STRICTLY increase.
|
|
2502
|
+
*
|
|
2503
|
+
* `max(current, server-requested, current + 5)` — the third term is what keeps
|
|
2504
|
+
* the sequence increasing when the server sends no new `interval`, and taking the
|
|
2505
|
+
* max of all three keeps it from ever decreasing when the server sends a smaller
|
|
2506
|
+
* one. `authorization_pending` may carry a new interval too; there it is honoured
|
|
2507
|
+
* without the increment, so an ordinary pending poll does not back off forever.
|
|
2508
|
+
*/
|
|
2509
|
+
function nextIntervalSeconds(current, requested, error) {
|
|
2510
|
+
const server = positiveSecondsOf(requested, 0);
|
|
2511
|
+
return error === "slow_down" ? Math.max(current, server, current + 5) : Math.max(current, server);
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2514
|
+
* Wait `ms`, losing the race to `signal` the instant it aborts.
|
|
2515
|
+
*
|
|
2516
|
+
* The timer is injected rather than closed over, so a test can drive the poll
|
|
2517
|
+
* loop through fifteen virtual minutes in a millisecond. Aborting rejects instead
|
|
2518
|
+
* of resolving early, because a caller who pressed Ctrl-C wants the flow to END,
|
|
2519
|
+
* not to take one more turn round the loop.
|
|
2520
|
+
*/
|
|
2521
|
+
function sleep(ms, signal, timer) {
|
|
2522
|
+
if (signal?.aborted === true) return Promise.reject(deviceAborted());
|
|
2523
|
+
return new Promise((resolve, reject) => {
|
|
2524
|
+
const onAbort = () => {
|
|
2525
|
+
timer.clearTimeout(handle);
|
|
2526
|
+
reject(deviceAborted());
|
|
2527
|
+
};
|
|
2528
|
+
const handle = timer.setTimeout(() => {
|
|
2529
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2530
|
+
resolve();
|
|
2531
|
+
}, ms);
|
|
2532
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* Dispatch one OAuth leg and read its body within the configured bounds.
|
|
2537
|
+
*
|
|
2538
|
+
* A body that is not a JSON object yields an EMPTY object plus `parseError`
|
|
2539
|
+
* rather than throwing: the status still has to be classified, and on the token
|
|
2540
|
+
* leg an unreadable body is one of the shapes a misconfigured `Accept` header
|
|
2541
|
+
* produces. Callers therefore always get to the body-first branch, and reach a
|
|
2542
|
+
* hard failure only after it finds no `error`.
|
|
2543
|
+
*/
|
|
2544
|
+
async function deviceJson(request, what, options) {
|
|
2545
|
+
let response;
|
|
2546
|
+
try {
|
|
2547
|
+
response = await copilotFetch(request, options);
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
throwIfAborted(options.signal);
|
|
2550
|
+
throw error instanceof CopilotDeviceLoginError ? error : deviceFailure(`${what} could not be reached`, "failed", error);
|
|
2551
|
+
}
|
|
2552
|
+
let raw;
|
|
2553
|
+
try {
|
|
2554
|
+
raw = await readCopilotResponseText(response, options);
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
throwIfAborted(options.signal);
|
|
2557
|
+
throw deviceFailure(`${what} returned a response beyond the configured limits`, "failed", error);
|
|
2558
|
+
}
|
|
2559
|
+
try {
|
|
2560
|
+
const parsed = JSON.parse(raw);
|
|
2561
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new TypeError(`${what} returned JSON that is not an object`);
|
|
2562
|
+
return {
|
|
2563
|
+
ok: response.ok,
|
|
2564
|
+
status: response.status,
|
|
2565
|
+
json: parsed,
|
|
2566
|
+
parseError: void 0
|
|
2567
|
+
};
|
|
2568
|
+
} catch (error) {
|
|
2569
|
+
return {
|
|
2570
|
+
ok: response.ok,
|
|
2571
|
+
status: response.status,
|
|
2572
|
+
json: {},
|
|
2573
|
+
parseError: error
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Read the verification URL the user is told to open.
|
|
2579
|
+
*
|
|
2580
|
+
* Only the SCHEME is constrained, not the origin. This SDK never fetches this
|
|
2581
|
+
* URL — it prints it — and GitHub Enterprise deployments legitimately answer with
|
|
2582
|
+
* a host other than the issuer, so an origin pin here would reject working
|
|
2583
|
+
* installations to guard a request that is never made. The scheme check remains
|
|
2584
|
+
* because a `javascript:` or `data:` URL handed to a browser opener is a real
|
|
2585
|
+
* problem, and {@link COPILOT_DEVICE_LOGIN_WARNING} covers the rest.
|
|
2586
|
+
*/
|
|
2587
|
+
function verificationUrlOf(body, options) {
|
|
2588
|
+
const raw = requireDeviceString(body, "verification_uri");
|
|
2589
|
+
let url;
|
|
2590
|
+
try {
|
|
2591
|
+
url = new URL(raw);
|
|
2592
|
+
} catch (error) {
|
|
2593
|
+
throw deviceFailure("the device-code endpoint returned an unusable verification URL", "failed", error);
|
|
2594
|
+
}
|
|
2595
|
+
if (url.protocol !== "https:" && !(options.allowInsecureIssuer === true && url.protocol === "http:")) throw deviceFailure("the device-code verification URL must use https", "failed");
|
|
2596
|
+
return url.href;
|
|
2597
|
+
}
|
|
2598
|
+
/** Identity fields, present only when the endpoint disclosed them (Property 16). */
|
|
2599
|
+
function accountIdentityOf(body) {
|
|
2600
|
+
const login = optionalString(body.login);
|
|
2601
|
+
const name = optionalString(body.name);
|
|
2602
|
+
const id = typeof body.id === "number" && Number.isFinite(body.id) ? body.id : void 0;
|
|
2603
|
+
if (login === void 0 && name === void 0 && id === void 0) return void 0;
|
|
2604
|
+
return Object.freeze({
|
|
2605
|
+
...login === void 0 ? {} : { login },
|
|
2606
|
+
...name === void 0 ? {} : { name },
|
|
2607
|
+
...id === void 0 ? {} : { id }
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
function optionalString(value) {
|
|
2611
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
2612
|
+
}
|
|
2613
|
+
function requireDeviceString(body, key) {
|
|
2614
|
+
const value = body[key];
|
|
2615
|
+
if (typeof value !== "string" || value.length === 0) throw deviceFailure(`the device flow response omitted "${key}"`, "failed");
|
|
2616
|
+
return value;
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Read a seconds value that the endpoint may send as a number, as a numeric
|
|
2620
|
+
* string, or not at all.
|
|
2621
|
+
*
|
|
2622
|
+
* GitHub has been observed sending `interval` as a string, so both forms are
|
|
2623
|
+
* accepted; anything unparsable falls back rather than failing the login, because
|
|
2624
|
+
* a bad hint about pacing is not a reason to refuse a working authorization.
|
|
2625
|
+
*/
|
|
2626
|
+
function positiveSecondsOf(value, fallbackSeconds) {
|
|
2627
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value.trim(), 10) : NaN;
|
|
2628
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackSeconds;
|
|
2629
|
+
}
|
|
2630
|
+
function timerOf(options) {
|
|
2631
|
+
return options.timer ?? DEFAULT_COPILOT_TIMER;
|
|
2632
|
+
}
|
|
2633
|
+
/** Run a progress observer; observers do not own authentication. */
|
|
2634
|
+
function notify(report) {
|
|
2635
|
+
try {
|
|
2636
|
+
report();
|
|
2637
|
+
} catch {}
|
|
2638
|
+
}
|
|
2639
|
+
function throwIfAborted(signal) {
|
|
2640
|
+
if (signal?.aborted === true) throw deviceAborted();
|
|
2641
|
+
}
|
|
2642
|
+
function deviceAborted() {
|
|
2643
|
+
return deviceFailure("the device login was cancelled", "aborted");
|
|
2644
|
+
}
|
|
2645
|
+
function deviceTimeout(startedAt, now) {
|
|
2646
|
+
return deviceFailure(`the device login was not approved within ${Math.round((now - startedAt) / 1e3)}s (bound: ${COPILOT_DEVICE_CODE_MAX_WAIT_MS / 6e4} minutes); run \`${COPILOT_LOGIN_COMMAND$1}\` again`, "timeout");
|
|
2647
|
+
}
|
|
2648
|
+
function deviceFailure(message, reason, cause) {
|
|
2649
|
+
return new CopilotDeviceLoginError(credentialFailure(message, cause), reason);
|
|
2650
|
+
}
|
|
2651
|
+
async function readStore(captured, operation) {
|
|
2652
|
+
if (captured.kind === "versioned") {
|
|
2653
|
+
const record = await captured.store.read(operation);
|
|
2654
|
+
return record === void 0 ? {
|
|
2655
|
+
file: void 0,
|
|
2656
|
+
revision: null
|
|
2657
|
+
} : {
|
|
2658
|
+
file: record.value,
|
|
2659
|
+
revision: record.revision
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
return {
|
|
2663
|
+
file: await captured.store.read(),
|
|
2664
|
+
revision: null
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
async function commitStore(captured, file, expectedRevision, operation) {
|
|
2668
|
+
if (captured.kind === "versioned") {
|
|
2669
|
+
await captured.store.commit({
|
|
2670
|
+
value: file,
|
|
2671
|
+
expectedRevision
|
|
2672
|
+
}, operation);
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
await captured.store.write(file);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
//#endregion
|
|
2679
|
+
export { COPILOT_BASE_URL, COPILOT_CATALOG_PATH, COPILOT_DEFAULT_CATALOG_TIMEOUT_MS, COPILOT_DEFAULT_DIALECT, COPILOT_DEFAULT_MAX_CATALOG_BYTES, COPILOT_DEFAULT_MAX_CATALOG_CHUNKS, COPILOT_DEFAULT_MAX_CATALOG_MODELS, COPILOT_DEFAULT_POLL_INTERVAL_SECONDS, COPILOT_DEVICE_CODE_MAX_WAIT_MS, COPILOT_DEVICE_LOGIN_WARNING, COPILOT_DISPLAY_NAME, COPILOT_DUAL_PROTOCOL_ID, COPILOT_EDITOR_PLUGIN_VERSION, COPILOT_EDITOR_VERSION, COPILOT_ERROR_CODES, COPILOT_LOGIN_COMMAND, COPILOT_OAUTH_CLIENT_ID, COPILOT_OAUTH_SCOPE, COPILOT_PROVIDER_ID, COPILOT_RESPONSES_MODEL_PREFIXES, COPILOT_ROUTE_ID, COPILOT_SLOW_DOWN_INCREMENT_SECONDS, COPILOT_TOKEN_EXCHANGE_MARGIN_MS, COPILOT_TOKEN_EXCHANGE_PATH, CopilotDeviceLoginError, CopilotTokenExchangeError, DEFAULT_COPILOT_OAUTH_ISSUER, DEFAULT_COPILOT_TIMER, DEFAULT_GITHUB_API_BASE_URL, copilotAdapter, copilotCatalogCacheOptions, copilotDualProtocol, copilotPlugin, createCopilotEndpointRouter, createCopilotTokenCache, credentialFailure, discoverCopilotModels, exchangeCopilotToken, memoryCopilotAuthStore, memoryCopilotCredentialStore, partitionCopilotCatalog, requestCopilotDeviceCode, requireGitHubToken, resolveCopilotCatalogLimits, runCopilotDeviceLogin, shouldExchange, toChatCompletionsDialect, toResponsesDialect };
|
|
2680
|
+
//# sourceMappingURL=index.js.map
|