@gajae-code/ai 0.15.4 → 0.15.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/dist/types/auth-broker/client.d.ts +6 -2
- package/dist/types/auth-broker/remote-store.d.ts +14 -2
- package/dist/types/auth-broker/types.d.ts +6 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
- package/dist/types/auth-gateway/server.d.ts +39 -5
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/auth-storage.d.ts +116 -34
- package/dist/types/provider-models/openai-compat.d.ts +1 -0
- package/dist/types/provider-models/special.d.ts +2 -1
- package/dist/types/providers/kiro-api-key.d.ts +50 -0
- package/dist/types/providers/kiro-codewhisperer.d.ts +3 -0
- package/dist/types/providers/register-builtins.d.ts +12 -12
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/types.d.ts +35 -24
- package/dist/types/utils/fallback-transport.d.ts +7 -0
- package/dist/types/utils/json-parse.d.ts +5 -3
- package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
- package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
- package/dist/types/utils/oauth/commandcode.d.ts +1 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/dist/types/utils/retry.d.ts +2 -0
- package/dist/types/utils/tool-call-healing.d.ts +4 -4
- package/package.json +3 -3
- package/src/auth-broker/client.ts +41 -13
- package/src/auth-broker/redact.ts +25 -1
- package/src/auth-broker/remote-store.ts +374 -115
- package/src/auth-broker/server.ts +131 -91
- package/src/auth-broker/types.ts +6 -0
- package/src/auth-broker/wire-schemas.ts +6 -0
- package/src/auth-gateway/server.ts +447 -79
- package/src/auth-gateway/types.ts +28 -2
- package/src/auth-storage.ts +742 -157
- package/src/cli.ts +1 -0
- package/src/model-thinking.ts +16 -0
- package/src/models.json +1054 -0
- package/src/models.ts +9 -1
- package/src/provider-models/descriptors.ts +3 -1
- package/src/provider-models/openai-compat.ts +41 -1
- package/src/provider-models/special.ts +15 -3
- package/src/providers/anthropic.ts +7 -1
- package/src/providers/azure-openai-responses.ts +4 -1
- package/src/providers/cursor.ts +256 -101
- package/src/providers/gitlab-duo.ts +18 -1
- package/src/providers/google-gemini-cli.ts +3 -0
- package/src/providers/google-shared.ts +3 -0
- package/src/providers/kiro-api-key.d.ts +50 -0
- package/src/providers/kiro-api-key.ts +786 -0
- package/src/providers/kiro-codewhisperer.d.ts +3 -0
- package/src/providers/kiro-codewhisperer.ts +34 -9
- package/src/providers/ollama.ts +3 -0
- package/src/providers/openai-codex-responses.ts +24 -6
- package/src/providers/openai-completions.ts +11 -1
- package/src/providers/openai-responses-shared.ts +23 -2
- package/src/providers/openai-responses.ts +10 -1
- package/src/providers/pi-native-client.ts +1 -0
- package/src/providers/pi-native-server.ts +24 -0
- package/src/providers/register-builtins.d.ts +12 -12
- package/src/providers/register-builtins.ts +16 -3
- package/src/stream.d.ts +2 -1
- package/src/stream.ts +180 -70
- package/src/types.d.ts +35 -24
- package/src/types.ts +40 -23
- package/src/utils/fallback-transport.d.ts +7 -0
- package/src/utils/fallback-transport.ts +21 -4
- package/src/utils/json-parse.d.ts +5 -3
- package/src/utils/json-parse.ts +6 -6
- package/src/utils/oauth/api-key-login.ts +13 -2
- package/src/utils/oauth/api-key-validation.ts +242 -41
- package/src/utils/oauth/commandcode.ts +17 -0
- package/src/utils/oauth/index.ts +20 -5
- package/src/utils/oauth/types.d.ts +1 -1
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/retry.d.ts +2 -0
- package/src/utils/retry.ts +15 -2
- package/src/utils/tool-call-healing.d.ts +4 -4
- package/src/utils/tool-call-healing.ts +4 -4
package/src/types.ts
CHANGED
|
@@ -167,6 +167,7 @@ export const KNOWN_PROVIDERS = [
|
|
|
167
167
|
"mistral",
|
|
168
168
|
"minimax",
|
|
169
169
|
"opencode-go",
|
|
170
|
+
"commandcode-goat",
|
|
170
171
|
"opencode-zen",
|
|
171
172
|
"opengateway",
|
|
172
173
|
"bizrouter",
|
|
@@ -353,6 +354,19 @@ export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) =>
|
|
|
353
354
|
preconnect?: typeof globalThis.fetch.preconnect;
|
|
354
355
|
};
|
|
355
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Credential returned by an auth retry resolver.
|
|
359
|
+
*
|
|
360
|
+
* The optional admission callback lets an authority-bearing caller retain a
|
|
361
|
+
* credential lease until the replacement provider request is actually
|
|
362
|
+
* admitted. Ordinary callers can continue returning a string from
|
|
363
|
+
* {@link StreamOptions.onAuthError}.
|
|
364
|
+
*/
|
|
365
|
+
export interface AuthRetryCredential {
|
|
366
|
+
apiKey: string;
|
|
367
|
+
onStreamCreated?: () => void;
|
|
368
|
+
}
|
|
369
|
+
|
|
356
370
|
export interface StreamOptions {
|
|
357
371
|
temperature?: number;
|
|
358
372
|
topP?: number;
|
|
@@ -384,7 +398,11 @@ export interface StreamOptions {
|
|
|
384
398
|
* event has been emitted. Returning a different key retries the provider
|
|
385
399
|
* request once.
|
|
386
400
|
*/
|
|
387
|
-
onAuthError?: (
|
|
401
|
+
onAuthError?: (
|
|
402
|
+
provider: string,
|
|
403
|
+
apiKey: string,
|
|
404
|
+
error: unknown,
|
|
405
|
+
) => Promise<string | AuthRetryCredential | undefined>;
|
|
388
406
|
cacheRetention?: CacheRetention;
|
|
389
407
|
/**
|
|
390
408
|
* Additional headers to include in provider requests.
|
|
@@ -449,6 +467,14 @@ export interface StreamOptions {
|
|
|
449
467
|
model?: Model<Api>,
|
|
450
468
|
scope?: AttemptScopeRef,
|
|
451
469
|
) => void | Promise<void>;
|
|
470
|
+
/**
|
|
471
|
+
* Internal dispatch-admission hook. Providers invoke this immediately before
|
|
472
|
+
* submitting an outbound request; stream forwarding retains a first-response
|
|
473
|
+
* fallback for custom providers that do not expose a transport seam.
|
|
474
|
+
*/
|
|
475
|
+
onStreamCreated?: () => void;
|
|
476
|
+
/** Internal authority policy: disable provider-owned retries and corrective replays. */
|
|
477
|
+
disableProviderRetries?: boolean;
|
|
452
478
|
/**
|
|
453
479
|
* Optional callback for raw Server-Sent Events as they arrive from HTTP streaming providers.
|
|
454
480
|
*
|
|
@@ -620,31 +646,22 @@ export interface ToolCall {
|
|
|
620
646
|
*/
|
|
621
647
|
incompleteArgumentsReason?: "truncated" | "malformed" | "conflicting" | "ambiguous";
|
|
622
648
|
/**
|
|
623
|
-
* Set when
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
* The agent loop
|
|
630
|
-
*
|
|
631
|
-
* is a tool that enumerated its display-only fields
|
|
632
|
-
* (`displaySafeEscapedArgFields`): when every escaped scalar corroborates
|
|
633
|
-
* a decoded non-ASCII character inside those fields, the call executes
|
|
634
|
-
* with a warning instead — rendered question text, never executable
|
|
635
|
-
* content, ids, or durable metadata.
|
|
636
|
-
* Escapes that are required (control characters) or unavoidable (lone
|
|
637
|
-
* surrogates) never set this.
|
|
649
|
+
* Set by current producers when raw argument JSON carries unsafe Unicode
|
|
650
|
+
* data, such as malformed escape evidence or a decoded unpaired surrogate.
|
|
651
|
+
* Valid JSON `\uXXXX` escapes are canonical spellings of the decoded string
|
|
652
|
+
* and current producers do not set this flag for them.
|
|
653
|
+
*
|
|
654
|
+
* Legacy producers may still set the flag for any escaped non-ASCII spelling.
|
|
655
|
+
* The agent loop keeps its bounded legacy resample/display-safe behavior for
|
|
656
|
+
* those calls while consuming the transient evidence below.
|
|
638
657
|
*/
|
|
639
658
|
escapedNonAsciiArguments?: boolean;
|
|
640
659
|
/**
|
|
641
|
-
* Bounded, payload-free evidence for
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* `escapedNonAsciiArguments`. The agent consumes and removes this transient
|
|
647
|
-
* field before the tool-call message can become durable.
|
|
660
|
+
* Bounded, payload-free evidence for raw Unicode argument data. Current
|
|
661
|
+
* producers attach it only for unsafe data; legacy producers may attach
|
|
662
|
+
* non-malformed positional evidence used by the display-safe compatibility
|
|
663
|
+
* path. The agent consumes and removes this transient field before the
|
|
664
|
+
* tool-call message can become durable.
|
|
648
665
|
*/
|
|
649
666
|
escapedUnicodeArgumentEvidence?: UnicodeEscapeEvidence;
|
|
650
667
|
}
|
|
@@ -23,6 +23,13 @@ export interface FallbackTrigger {
|
|
|
23
23
|
export declare const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
|
|
24
24
|
/** Stable code for a nominally successful response with no content or token usage. */
|
|
25
25
|
export declare const EMPTY_RESPONSE_PROVIDER_CODE = "empty_response";
|
|
26
|
+
/**
|
|
27
|
+
* OpenAI's typed capacity-overload code. It arrives without an HTTP status —
|
|
28
|
+
* inside an HTTP 200 terminal Responses envelope or a Codex error event — so the
|
|
29
|
+
* code itself is the only structured evidence of the failure and must survive
|
|
30
|
+
* the existence gate below. It is always compared case-sensitively.
|
|
31
|
+
*/
|
|
32
|
+
export declare const SERVER_OVERLOADED_PROVIDER_CODE = "server_is_overloaded";
|
|
26
33
|
export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
27
34
|
/**
|
|
28
35
|
* Structured facts from an upstream HTTP or transport failure. Retry decisions
|
|
@@ -26,6 +26,13 @@ export interface FallbackTrigger {
|
|
|
26
26
|
export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
|
|
27
27
|
/** Stable code for a nominally successful response with no content or token usage. */
|
|
28
28
|
export const EMPTY_RESPONSE_PROVIDER_CODE = "empty_response";
|
|
29
|
+
/**
|
|
30
|
+
* OpenAI's typed capacity-overload code. It arrives without an HTTP status —
|
|
31
|
+
* inside an HTTP 200 terminal Responses envelope or a Codex error event — so the
|
|
32
|
+
* code itself is the only structured evidence of the failure and must survive
|
|
33
|
+
* the existence gate below. It is always compared case-sensitively.
|
|
34
|
+
*/
|
|
35
|
+
export const SERVER_OVERLOADED_PROVIDER_CODE = "server_is_overloaded";
|
|
29
36
|
|
|
30
37
|
export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
31
38
|
|
|
@@ -241,6 +248,11 @@ export function transportFailureFacts(
|
|
|
241
248
|
!isContextOverflowCode(normalizedCode) &&
|
|
242
249
|
normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE &&
|
|
243
250
|
normalizedCode !== EMPTY_RESPONSE_PROVIDER_CODE &&
|
|
251
|
+
// Matched case-sensitively against the raw code: this is the one gate a
|
|
252
|
+
// provider-owned code can open on its own, so a cased or padded variant
|
|
253
|
+
// must not materialize facts that would disqualify an unrelated
|
|
254
|
+
// bare-default retry.
|
|
255
|
+
providerCode !== SERVER_OVERLOADED_PROVIDER_CODE &&
|
|
244
256
|
requestBytes === undefined &&
|
|
245
257
|
firstEventElapsedMs === undefined &&
|
|
246
258
|
firstEventTimeoutMs === undefined &&
|
|
@@ -360,12 +372,17 @@ export function classifyFallbackTrigger(
|
|
|
360
372
|
const retryAfterMs =
|
|
361
373
|
parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
|
|
362
374
|
parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
);
|
|
375
|
+
const rawCodes = [facts.openaiErrorCode, facts.anthropicErrorType, facts.providerCode];
|
|
376
|
+
const codes = rawCodes.map(value => value?.toLowerCase());
|
|
366
377
|
const code = codes[0] ?? codes[1] ?? codes[2];
|
|
378
|
+
// The capacity-overload code is provider-owned and is the only code that can
|
|
379
|
+
// classify without a status, so it is matched case-sensitively — the same
|
|
380
|
+
// exactness the parser and the session admission use.
|
|
381
|
+
const rawCode = rawCodes[0] ?? rawCodes[1] ?? rawCodes[2];
|
|
367
382
|
const triggerClass: FallbackTriggerClass =
|
|
368
|
-
code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE ||
|
|
383
|
+
code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE ||
|
|
384
|
+
code === EMPTY_RESPONSE_PROVIDER_CODE ||
|
|
385
|
+
(facts.status === undefined && rawCode === SERVER_OVERLOADED_PROVIDER_CODE)
|
|
369
386
|
? "server"
|
|
370
387
|
: isQuotaCode(code)
|
|
371
388
|
? "quota"
|
|
@@ -67,9 +67,11 @@ export declare function collectUnicodeEscapeEvidence(json: string): UnicodeEscap
|
|
|
67
67
|
/**
|
|
68
68
|
* Return evidence only when decoded tool arguments are unsafe to execute.
|
|
69
69
|
*
|
|
70
|
-
* Valid JSON escapes and literal UTF-8 have the same canonical decoded value
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Valid JSON escapes and literal UTF-8 have the same canonical decoded value,
|
|
71
|
+
* including a valid scalar whose hex digits differ from what a caller intended:
|
|
72
|
+
* runtime syntax validation cannot infer author intent after decoding.
|
|
73
|
+
* Malformed escape-bearing JSON, duplicate/deep suspicious escape evidence, and
|
|
74
|
+
* unpaired UTF-16 surrogates keep the fail-closed path.
|
|
73
75
|
*/
|
|
74
76
|
export declare function collectUnsafeUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined;
|
|
75
77
|
/** Attach unsafe raw evidence while preserving the existing call-level guard flag. */
|
package/src/utils/json-parse.ts
CHANGED
|
@@ -486,15 +486,13 @@ export function collectUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidenc
|
|
|
486
486
|
|
|
487
487
|
function hasUnpairedUnicodeSurrogate(value: unknown): boolean {
|
|
488
488
|
const pending: unknown[] = [value];
|
|
489
|
-
const seen = new WeakSet<object>();
|
|
490
489
|
while (pending.length > 0) {
|
|
491
490
|
const current = pending.pop();
|
|
492
491
|
if (typeof current === "string") {
|
|
493
492
|
if (!current.isWellFormed()) return true;
|
|
494
493
|
continue;
|
|
495
494
|
}
|
|
496
|
-
if (typeof current !== "object" || current === null
|
|
497
|
-
seen.add(current);
|
|
495
|
+
if (typeof current !== "object" || current === null) continue;
|
|
498
496
|
if (Array.isArray(current)) {
|
|
499
497
|
for (const child of current) pending.push(child);
|
|
500
498
|
continue;
|
|
@@ -510,9 +508,11 @@ function hasUnpairedUnicodeSurrogate(value: unknown): boolean {
|
|
|
510
508
|
/**
|
|
511
509
|
* Return evidence only when decoded tool arguments are unsafe to execute.
|
|
512
510
|
*
|
|
513
|
-
* Valid JSON escapes and literal UTF-8 have the same canonical decoded value
|
|
514
|
-
*
|
|
515
|
-
*
|
|
511
|
+
* Valid JSON escapes and literal UTF-8 have the same canonical decoded value,
|
|
512
|
+
* including a valid scalar whose hex digits differ from what a caller intended:
|
|
513
|
+
* runtime syntax validation cannot infer author intent after decoding.
|
|
514
|
+
* Malformed escape-bearing JSON, duplicate/deep suspicious escape evidence, and
|
|
515
|
+
* unpaired UTF-16 surrogates keep the fail-closed path.
|
|
516
516
|
*/
|
|
517
517
|
export function collectUnsafeUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined {
|
|
518
518
|
const hasUnicodeEscape = json.includes("\\u");
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared factory for API-key-paste "login" flows.
|
|
3
3
|
*
|
|
4
|
-
* Several providers (Cerebras, Synthetic, Moonshot, Together, NanoGPT, ZenMux
|
|
4
|
+
* Several providers (Cerebras, Synthetic, Moonshot, Together, NanoGPT, ZenMux,
|
|
5
|
+
* Command Code GOAT)
|
|
5
6
|
* don't actually implement OAuth — they just ask the user to paste an API key,
|
|
6
7
|
* optionally validate it, and return the trimmed key.
|
|
7
8
|
*/
|
|
@@ -14,6 +15,7 @@ type ChatCompletionsValidation = {
|
|
|
14
15
|
provider: string;
|
|
15
16
|
baseUrl: string;
|
|
16
17
|
model: string;
|
|
18
|
+
requireInferenceResponse?: boolean;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
type ModelsEndpointValidation = {
|
|
@@ -35,10 +37,12 @@ export type ApiKeyLoginConfig = {
|
|
|
35
37
|
placeholder: string;
|
|
36
38
|
/** Validation strategy, or `null` to skip validation. */
|
|
37
39
|
validation: ChatCompletionsValidation | ModelsEndpointValidation | null;
|
|
40
|
+
validationProgressMessage?: string;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise<string> {
|
|
41
44
|
return async function login(options: OAuthController): Promise<string> {
|
|
45
|
+
if (options.signal?.aborted) throw new Error("Login cancelled");
|
|
42
46
|
if (!options.onPrompt) {
|
|
43
47
|
throw new Error(`${config.providerLabel} login requires onPrompt callback`);
|
|
44
48
|
}
|
|
@@ -61,16 +65,21 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
|
|
|
61
65
|
if (!trimmed) {
|
|
62
66
|
throw new Error("API key is required");
|
|
63
67
|
}
|
|
68
|
+
if (/[\x00-\x1f\x7f-\x9f]/u.test(trimmed)) {
|
|
69
|
+
throw new Error("API key contains unsupported control characters");
|
|
70
|
+
}
|
|
64
71
|
|
|
65
72
|
if (config.validation) {
|
|
66
|
-
options.onProgress?.("Validating API key...");
|
|
73
|
+
options.onProgress?.(config.validationProgressMessage ?? "Validating API key...");
|
|
67
74
|
if (config.validation.kind === "chat-completions") {
|
|
68
75
|
await validateOpenAICompatibleApiKey({
|
|
69
76
|
provider: config.validation.provider,
|
|
70
77
|
apiKey: trimmed,
|
|
71
78
|
baseUrl: config.validation.baseUrl,
|
|
72
79
|
model: config.validation.model,
|
|
80
|
+
requireInferenceResponse: config.validation.requireInferenceResponse,
|
|
73
81
|
signal: options.signal,
|
|
82
|
+
fetch: options.fetch,
|
|
74
83
|
});
|
|
75
84
|
} else {
|
|
76
85
|
await validateApiKeyAgainstModelsEndpoint({
|
|
@@ -78,8 +87,10 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
|
|
|
78
87
|
apiKey: trimmed,
|
|
79
88
|
modelsUrl: config.validation.modelsUrl,
|
|
80
89
|
signal: options.signal,
|
|
90
|
+
fetch: options.fetch,
|
|
81
91
|
});
|
|
82
92
|
}
|
|
93
|
+
if (options.signal?.aborted) throw new Error("Login cancelled");
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
return trimmed;
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { sanitizeDisplayLine } from "@gajae-code/utils";
|
|
2
|
+
|
|
1
3
|
type OpenAICompatibleValidationOptions = {
|
|
2
4
|
provider: string;
|
|
3
5
|
apiKey: string;
|
|
4
6
|
baseUrl: string;
|
|
5
7
|
model: string;
|
|
6
8
|
signal?: AbortSignal;
|
|
9
|
+
fetch?: typeof globalThis.fetch;
|
|
10
|
+
requireInferenceResponse?: boolean;
|
|
11
|
+
timeoutMs?: number;
|
|
7
12
|
};
|
|
8
13
|
|
|
9
14
|
type ModelListValidationOptions = {
|
|
@@ -11,50 +16,209 @@ type ModelListValidationOptions = {
|
|
|
11
16
|
apiKey: string;
|
|
12
17
|
modelsUrl: string;
|
|
13
18
|
signal?: AbortSignal;
|
|
19
|
+
fetch?: typeof globalThis.fetch;
|
|
20
|
+
timeoutMs?: number;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
const VALIDATION_TIMEOUT_MS = 15_000;
|
|
17
24
|
|
|
18
25
|
/** Most characters of an upstream body echoed into a validation error. */
|
|
19
26
|
const VALIDATION_DETAILS_LIMIT = 200;
|
|
27
|
+
const VALIDATION_BODY_LIMIT = 64 * 1024;
|
|
28
|
+
|
|
29
|
+
function redactSecrets(text: string, apiKey: string): string {
|
|
30
|
+
let safe = text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "");
|
|
31
|
+
if (apiKey) {
|
|
32
|
+
const escaped = [...apiKey].map(char => char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"));
|
|
33
|
+
const interspersed = new RegExp(escaped.join("[\\s\\x00-\\x1f\\x7f-\\x9f]*"), "gu");
|
|
34
|
+
safe = safe.replace(interspersed, "[REDACTED]");
|
|
35
|
+
}
|
|
36
|
+
safe = sanitizeDisplayLine(safe);
|
|
37
|
+
safe = safe.replace(/[\x00-\x1f\x7f-\x9f]/gu, " ");
|
|
38
|
+
if (apiKey) safe = safe.replaceAll(apiKey, "[REDACTED]");
|
|
39
|
+
// Upstream errors sometimes echo credentials under a field name instead of
|
|
40
|
+
// returning the exact bearer value. Redact those values before retaining any
|
|
41
|
+
// bounded diagnostic snippet.
|
|
42
|
+
safe = safe
|
|
43
|
+
.replace(/(Bearer\s+)([^\s,}"']+)/giu, "$1[REDACTED]")
|
|
44
|
+
.replace(
|
|
45
|
+
/(["']?(?:authorization|proxy-authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|token|secret|password)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,}\]]+)/giu,
|
|
46
|
+
"$1[REDACTED]",
|
|
47
|
+
);
|
|
48
|
+
return safe;
|
|
49
|
+
}
|
|
20
50
|
|
|
21
|
-
function boundedDetails(text: string): string {
|
|
22
|
-
const trimmed = text.trim();
|
|
51
|
+
function boundedDetails(text: string, apiKey: string): string {
|
|
52
|
+
const trimmed = redactSecrets(text, apiKey).trim();
|
|
23
53
|
return trimmed.length > VALIDATION_DETAILS_LIMIT ? `${trimmed.slice(0, VALIDATION_DETAILS_LIMIT)}…` : trimmed;
|
|
24
54
|
}
|
|
25
55
|
|
|
56
|
+
type BoundedBody = { text: string; truncated: boolean };
|
|
57
|
+
|
|
58
|
+
async function readBoundedBody(response: Response, signal: AbortSignal): Promise<BoundedBody> {
|
|
59
|
+
if (signal.aborted) throw new Error("Login cancelled");
|
|
60
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
61
|
+
if (Number.isFinite(contentLength) && contentLength > VALIDATION_BODY_LIMIT) {
|
|
62
|
+
await response.body?.cancel().catch(() => {});
|
|
63
|
+
return { text: "", truncated: true };
|
|
64
|
+
}
|
|
65
|
+
if (!response.body) return { text: "", truncated: false };
|
|
66
|
+
const reader = response.body.getReader();
|
|
67
|
+
const chunks: Uint8Array[] = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
let truncated = false;
|
|
70
|
+
try {
|
|
71
|
+
while (!truncated && total <= VALIDATION_BODY_LIMIT) {
|
|
72
|
+
if (signal.aborted) throw new Error("Login cancelled");
|
|
73
|
+
const { promise, resolve, reject } = Promise.withResolvers<{ done: boolean; value?: Uint8Array }>();
|
|
74
|
+
const onAbort = () => reject(new Error("Login cancelled"));
|
|
75
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
76
|
+
reader
|
|
77
|
+
.read()
|
|
78
|
+
.then(value => resolve(value), reject)
|
|
79
|
+
.finally(() => signal.removeEventListener("abort", onAbort));
|
|
80
|
+
const { done, value } = await promise;
|
|
81
|
+
if (done) break;
|
|
82
|
+
if (value) {
|
|
83
|
+
const remaining = VALIDATION_BODY_LIMIT - total;
|
|
84
|
+
if (value.byteLength > remaining) truncated = true;
|
|
85
|
+
chunks.push(value.byteLength > remaining ? value.subarray(0, remaining) : value);
|
|
86
|
+
total += Math.min(value.byteLength, remaining);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
await reader.cancel().catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
const bytes = new Uint8Array(Math.min(total, VALIDATION_BODY_LIMIT));
|
|
93
|
+
let offset = 0;
|
|
94
|
+
for (const chunk of chunks) {
|
|
95
|
+
const take = Math.min(chunk.byteLength, bytes.length - offset);
|
|
96
|
+
if (take <= 0) break;
|
|
97
|
+
bytes.set(chunk.subarray(0, take), offset);
|
|
98
|
+
offset += take;
|
|
99
|
+
}
|
|
100
|
+
return { text: new TextDecoder().decode(bytes), truncated };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function abortFailure(
|
|
104
|
+
provider: string,
|
|
105
|
+
apiKey: string,
|
|
106
|
+
callerSignal: AbortSignal | undefined,
|
|
107
|
+
timeoutSignal: AbortSignal,
|
|
108
|
+
): Error | undefined {
|
|
109
|
+
if (callerSignal?.aborted) return new Error("Login cancelled");
|
|
110
|
+
if (timeoutSignal.aborted) return validationFailure(provider, apiKey, "validation request timed out");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorDetails(error: unknown, apiKey: string): string {
|
|
114
|
+
return boundedDetails(error instanceof Error ? error.message : String(error), apiKey);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validationFailure(provider: string, apiKey: string, suffix: string): Error {
|
|
118
|
+
const details = boundedDetails(suffix, apiKey);
|
|
119
|
+
return new Error(
|
|
120
|
+
details ? `${provider} API key validation failed: ${details}` : `${provider} API key validation failed`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
26
124
|
/**
|
|
27
125
|
* Validate an API key against an OpenAI-compatible chat completions endpoint.
|
|
28
126
|
*
|
|
29
127
|
* Performs a minimal request to verify credentials and endpoint access.
|
|
30
128
|
*/
|
|
31
129
|
export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleValidationOptions): Promise<void> {
|
|
32
|
-
const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
|
|
130
|
+
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? VALIDATION_TIMEOUT_MS);
|
|
33
131
|
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
132
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
133
|
+
if (signal.aborted) throw new Error("Login cancelled");
|
|
134
|
+
if (/[\x00-\x1f\x7f-\x9f]/u.test(options.apiKey))
|
|
135
|
+
throw new Error(`${options.provider} API key contains unsupported control characters`);
|
|
34
136
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
137
|
+
let response: Response;
|
|
138
|
+
try {
|
|
139
|
+
response = await fetchImpl(`${options.baseUrl}/chat/completions`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
model: options.model,
|
|
147
|
+
messages: [{ role: "user", content: "ping" }],
|
|
148
|
+
max_tokens: 1,
|
|
149
|
+
temperature: 0,
|
|
150
|
+
}),
|
|
151
|
+
signal,
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const abortError = abortFailure(options.provider, options.apiKey, options.signal, timeoutSignal);
|
|
155
|
+
if (abortError) throw abortError;
|
|
156
|
+
throw validationFailure(
|
|
157
|
+
options.provider,
|
|
158
|
+
options.apiKey,
|
|
159
|
+
`request failed (${errorDetails(error, options.apiKey)})`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
49
162
|
|
|
50
163
|
if (response.ok) {
|
|
164
|
+
if (options.requireInferenceResponse) {
|
|
165
|
+
let body: string;
|
|
166
|
+
try {
|
|
167
|
+
const bounded = await readBoundedBody(response, signal);
|
|
168
|
+
if (bounded.truncated) throw new Error("inference probe response exceeded validation limit");
|
|
169
|
+
body = bounded.text;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const abortError = abortFailure(options.provider, options.apiKey, options.signal, timeoutSignal);
|
|
172
|
+
if (abortError) throw abortError;
|
|
173
|
+
if (error instanceof Error && error.message === "Login cancelled") throw error;
|
|
174
|
+
if (error instanceof Error && error.message === "inference probe response exceeded validation limit")
|
|
175
|
+
throw validationFailure(options.provider, options.apiKey, error.message);
|
|
176
|
+
throw validationFailure(
|
|
177
|
+
options.provider,
|
|
178
|
+
options.apiKey,
|
|
179
|
+
`the inference probe response could not be read (${errorDetails(error, options.apiKey)})`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
let parsed: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(body);
|
|
185
|
+
} catch {
|
|
186
|
+
throw validationFailure(
|
|
187
|
+
options.provider,
|
|
188
|
+
options.apiKey,
|
|
189
|
+
`the inference probe returned a non-JSON response (${boundedDetails(body, options.apiKey)})`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const choices =
|
|
193
|
+
typeof parsed === "object" && parsed !== null && "choices" in parsed
|
|
194
|
+
? (parsed as { choices?: unknown }).choices
|
|
195
|
+
: undefined;
|
|
196
|
+
const firstChoice = Array.isArray(choices) ? choices[0] : undefined;
|
|
197
|
+
const message =
|
|
198
|
+
typeof firstChoice === "object" &&
|
|
199
|
+
firstChoice !== null &&
|
|
200
|
+
"message" in firstChoice &&
|
|
201
|
+
typeof (firstChoice as { message?: unknown }).message === "object" &&
|
|
202
|
+
!Array.isArray((firstChoice as { message?: unknown }).message)
|
|
203
|
+
? (firstChoice as { message: Record<string, unknown> }).message
|
|
204
|
+
: undefined;
|
|
205
|
+
const content = message?.content;
|
|
206
|
+
if (!(typeof content === "string" && content.trim().length > 0)) {
|
|
207
|
+
throw validationFailure(options.provider, options.apiKey, "the inference probe returned no choices");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
51
210
|
return;
|
|
52
211
|
}
|
|
53
212
|
|
|
54
213
|
let details = "";
|
|
55
214
|
try {
|
|
56
|
-
|
|
215
|
+
const bounded = await readBoundedBody(response, signal);
|
|
216
|
+
details = bounded.truncated
|
|
217
|
+
? "response body exceeded validation limit"
|
|
218
|
+
: boundedDetails(bounded.text, options.apiKey);
|
|
57
219
|
} catch {
|
|
220
|
+
const abortError = abortFailure(options.provider, options.apiKey, options.signal, timeoutSignal);
|
|
221
|
+
if (abortError) throw abortError;
|
|
58
222
|
// ignore body parse errors, status is enough
|
|
59
223
|
}
|
|
60
224
|
|
|
@@ -70,45 +234,77 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa
|
|
|
70
234
|
* list — is not evidence that the credential reached a models endpoint.
|
|
71
235
|
*/
|
|
72
236
|
function isModelList(parsed: unknown): boolean {
|
|
73
|
-
|
|
237
|
+
const isModelArray = (value: unknown): boolean => {
|
|
238
|
+
if (!Array.isArray(value)) return false;
|
|
239
|
+
if (value.length === 0) return true;
|
|
240
|
+
return value.some(
|
|
241
|
+
item =>
|
|
242
|
+
typeof item === "object" &&
|
|
243
|
+
item !== null &&
|
|
244
|
+
typeof (item as { id?: unknown }).id === "string" &&
|
|
245
|
+
(item as { id: string }).id.trim().length > 0,
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
if (Array.isArray(parsed)) return isModelArray(parsed);
|
|
74
249
|
if (typeof parsed !== "object" || parsed === null) return false;
|
|
75
250
|
const record = parsed as { data?: unknown; models?: unknown };
|
|
76
|
-
return
|
|
251
|
+
return isModelArray(record.data) || isModelArray(record.models);
|
|
77
252
|
}
|
|
78
253
|
|
|
79
254
|
/**
|
|
80
|
-
* Validate
|
|
255
|
+
* Validate a provider models endpoint's reachability and response shape.
|
|
81
256
|
*
|
|
82
257
|
* Useful for providers where access to specific models may vary by plan and
|
|
83
|
-
* should not block
|
|
258
|
+
* should not block login; an available model list is not proof that an
|
|
259
|
+
* authenticated inference request will succeed for the supplied key.
|
|
84
260
|
*
|
|
85
261
|
* A 200 status alone is NOT accepted: a captive portal, misrouting proxy, or
|
|
86
|
-
* broken gateway can answer 200 with an HTML page or an empty JSON object
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
262
|
+
* broken gateway can answer 200 with an HTML page or an empty JSON object.
|
|
263
|
+
* The body must parse as JSON and carry a recognizable model list before the
|
|
264
|
+
* endpoint is considered reachable. This catalog check is not proof that
|
|
265
|
+
* authenticated inference is entitled to use the supplied key.
|
|
90
266
|
*/
|
|
91
267
|
export async function validateApiKeyAgainstModelsEndpoint(options: ModelListValidationOptions): Promise<void> {
|
|
92
|
-
const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
|
|
268
|
+
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? VALIDATION_TIMEOUT_MS);
|
|
93
269
|
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
270
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
271
|
+
if (signal.aborted) throw new Error("Login cancelled");
|
|
272
|
+
if (/[\x00-\x1f\x7f-\x9f]/u.test(options.apiKey))
|
|
273
|
+
throw new Error(`${options.provider} API key contains unsupported control characters`);
|
|
94
274
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
275
|
+
let response: Response;
|
|
276
|
+
try {
|
|
277
|
+
response = await fetchImpl(options.modelsUrl, {
|
|
278
|
+
method: "GET",
|
|
279
|
+
headers: {
|
|
280
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
281
|
+
},
|
|
282
|
+
signal,
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const abortError = abortFailure(options.provider, options.apiKey, options.signal, timeoutSignal);
|
|
286
|
+
if (abortError) throw abortError;
|
|
287
|
+
throw validationFailure(
|
|
288
|
+
options.provider,
|
|
289
|
+
options.apiKey,
|
|
290
|
+
`request failed (${errorDetails(error, options.apiKey)})`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
102
293
|
|
|
103
294
|
if (response.ok) {
|
|
104
295
|
let body: string;
|
|
105
296
|
try {
|
|
106
|
-
|
|
297
|
+
const bounded = await readBoundedBody(response, signal);
|
|
298
|
+
if (bounded.truncated) throw new Error("response body exceeded validation limit");
|
|
299
|
+
body = bounded.text;
|
|
107
300
|
} catch (error) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
301
|
+
const abortError = abortFailure(options.provider, options.apiKey, options.signal, timeoutSignal);
|
|
302
|
+
if (abortError) throw abortError;
|
|
303
|
+
if (error instanceof Error && error.message === "Login cancelled") throw error;
|
|
304
|
+
throw validationFailure(
|
|
305
|
+
options.provider,
|
|
306
|
+
options.apiKey,
|
|
307
|
+
`the models endpoint response body could not be read (${errorDetails(error, options.apiKey)})`,
|
|
112
308
|
);
|
|
113
309
|
}
|
|
114
310
|
let parsed: unknown;
|
|
@@ -117,7 +313,7 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali
|
|
|
117
313
|
} catch {
|
|
118
314
|
throw new Error(
|
|
119
315
|
`${options.provider} API key validation failed: the models endpoint returned ${response.status} with a non-JSON body` +
|
|
120
|
-
`${body.trim() ? ` (${boundedDetails(body)})` : ""}. Refusing to accept the key on status alone.`,
|
|
316
|
+
`${body.trim() ? ` (${boundedDetails(body, options.apiKey)})` : ""}. Refusing to accept the key on status alone.`,
|
|
121
317
|
);
|
|
122
318
|
}
|
|
123
319
|
if (!isModelList(parsed)) {
|
|
@@ -131,8 +327,13 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali
|
|
|
131
327
|
|
|
132
328
|
let details = "";
|
|
133
329
|
try {
|
|
134
|
-
|
|
330
|
+
const bounded = await readBoundedBody(response, signal);
|
|
331
|
+
details = bounded.truncated
|
|
332
|
+
? "response body exceeded validation limit"
|
|
333
|
+
: boundedDetails(bounded.text, options.apiKey);
|
|
135
334
|
} catch {
|
|
335
|
+
const abortError = abortFailure(options.provider, options.apiKey, options.signal, timeoutSignal);
|
|
336
|
+
if (abortError) throw abortError;
|
|
136
337
|
// ignore body parse errors, status is enough
|
|
137
338
|
}
|
|
138
339
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
2
|
+
|
|
3
|
+
export const loginCommandCode = createApiKeyLogin({
|
|
4
|
+
providerLabel: "Command Code GOAT",
|
|
5
|
+
authUrl: "https://commandcode.ai/studio/#api-keys",
|
|
6
|
+
instructions: "Create or copy your Command Code API key",
|
|
7
|
+
promptMessage: "Paste your Command Code API key",
|
|
8
|
+
placeholder: "cmd-...",
|
|
9
|
+
validationProgressMessage: "Verifying Command Code inference entitlement...",
|
|
10
|
+
validation: {
|
|
11
|
+
kind: "chat-completions",
|
|
12
|
+
provider: "Command Code GOAT",
|
|
13
|
+
baseUrl: "https://api.commandcode.ai/provider/v1",
|
|
14
|
+
model: "zai-org/GLM-5.3",
|
|
15
|
+
requireInferenceResponse: true,
|
|
16
|
+
},
|
|
17
|
+
});
|