@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/dist/types/auth-broker/client.d.ts +6 -2
  3. package/dist/types/auth-broker/remote-store.d.ts +14 -2
  4. package/dist/types/auth-broker/types.d.ts +6 -0
  5. package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
  6. package/dist/types/auth-gateway/server.d.ts +39 -5
  7. package/dist/types/auth-gateway/types.d.ts +16 -2
  8. package/dist/types/auth-storage.d.ts +116 -34
  9. package/dist/types/provider-models/openai-compat.d.ts +1 -0
  10. package/dist/types/provider-models/special.d.ts +2 -1
  11. package/dist/types/providers/kiro-api-key.d.ts +50 -0
  12. package/dist/types/providers/kiro-codewhisperer.d.ts +3 -0
  13. package/dist/types/providers/register-builtins.d.ts +12 -12
  14. package/dist/types/stream.d.ts +2 -1
  15. package/dist/types/types.d.ts +35 -24
  16. package/dist/types/utils/fallback-transport.d.ts +7 -0
  17. package/dist/types/utils/json-parse.d.ts +5 -3
  18. package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
  19. package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
  20. package/dist/types/utils/oauth/commandcode.d.ts +1 -0
  21. package/dist/types/utils/oauth/types.d.ts +1 -1
  22. package/dist/types/utils/retry.d.ts +2 -0
  23. package/dist/types/utils/tool-call-healing.d.ts +4 -4
  24. package/package.json +3 -3
  25. package/src/auth-broker/client.ts +41 -13
  26. package/src/auth-broker/redact.ts +25 -1
  27. package/src/auth-broker/remote-store.ts +374 -115
  28. package/src/auth-broker/server.ts +131 -91
  29. package/src/auth-broker/types.ts +6 -0
  30. package/src/auth-broker/wire-schemas.ts +6 -0
  31. package/src/auth-gateway/server.ts +447 -79
  32. package/src/auth-gateway/types.ts +28 -2
  33. package/src/auth-storage.ts +742 -157
  34. package/src/cli.ts +1 -0
  35. package/src/model-thinking.ts +16 -0
  36. package/src/models.json +1054 -0
  37. package/src/models.ts +9 -1
  38. package/src/provider-models/descriptors.ts +3 -1
  39. package/src/provider-models/openai-compat.ts +41 -1
  40. package/src/provider-models/special.ts +15 -3
  41. package/src/providers/anthropic.ts +7 -1
  42. package/src/providers/azure-openai-responses.ts +4 -1
  43. package/src/providers/cursor.ts +256 -101
  44. package/src/providers/gitlab-duo.ts +18 -1
  45. package/src/providers/google-gemini-cli.ts +3 -0
  46. package/src/providers/google-shared.ts +3 -0
  47. package/src/providers/kiro-api-key.d.ts +50 -0
  48. package/src/providers/kiro-api-key.ts +786 -0
  49. package/src/providers/kiro-codewhisperer.d.ts +3 -0
  50. package/src/providers/kiro-codewhisperer.ts +34 -9
  51. package/src/providers/ollama.ts +3 -0
  52. package/src/providers/openai-codex-responses.ts +24 -6
  53. package/src/providers/openai-completions.ts +11 -1
  54. package/src/providers/openai-responses-shared.ts +23 -2
  55. package/src/providers/openai-responses.ts +10 -1
  56. package/src/providers/pi-native-client.ts +1 -0
  57. package/src/providers/pi-native-server.ts +24 -0
  58. package/src/providers/register-builtins.d.ts +12 -12
  59. package/src/providers/register-builtins.ts +16 -3
  60. package/src/stream.d.ts +2 -1
  61. package/src/stream.ts +180 -70
  62. package/src/types.d.ts +35 -24
  63. package/src/types.ts +40 -23
  64. package/src/utils/fallback-transport.d.ts +7 -0
  65. package/src/utils/fallback-transport.ts +21 -4
  66. package/src/utils/json-parse.d.ts +5 -3
  67. package/src/utils/json-parse.ts +6 -6
  68. package/src/utils/oauth/api-key-login.ts +13 -2
  69. package/src/utils/oauth/api-key-validation.ts +242 -41
  70. package/src/utils/oauth/commandcode.ts +17 -0
  71. package/src/utils/oauth/index.ts +20 -5
  72. package/src/utils/oauth/types.d.ts +1 -1
  73. package/src/utils/oauth/types.ts +1 -0
  74. package/src/utils/retry.d.ts +2 -0
  75. package/src/utils/retry.ts +15 -2
  76. package/src/utils/tool-call-healing.d.ts +4 -4
  77. 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?: (provider: string, apiKey: string, error: unknown) => Promise<string | undefined>;
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 the raw argument JSON spelled a printable character as a `\uXXXX`
624
- * escape instead of a literal character. This includes ASCII landings because
625
- * a one-nibble mutation can move an intended non-ASCII scalar below U+0080.
626
- * Such a payload parses cleanly but
627
- * is unverifiable: one mistyped hex digit decodes to a different, equally
628
- * valid character, so the text can be silently wrong with no in-band evidence.
629
- * The agent loop resamples the turn a bounded number of times and then
630
- * rejects the call instead of executing it. The single bounded exception
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 the original raw escape positions and
642
- * process-keyed scalar/path identities. Required for the display-safe terminal exemption: decoded values
643
- * alone cannot prove that an ASCII landing such as `\u0077` was not a
644
- * one-nibble mutation of a non-ASCII escape. Presence of this evidence implies
645
- * the guarded state even if a legacy producer omitted
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 codes = [facts.openaiErrorCode, facts.anthropicErrorType, facts.providerCode].map(value =>
364
- value?.toLowerCase(),
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 || code === EMPTY_RESPONSE_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
- * Malformed JSON, duplicate/deep evidence, and unpaired UTF-16 surrogates keep
72
- * the existing fail-closed path.
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. */
@@ -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 || seen.has(current)) continue;
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
- * Malformed JSON, duplicate/deep evidence, and unpaired UTF-16 surrogates keep
515
- * the existing fail-closed path.
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
- const response = await fetch(`${options.baseUrl}/chat/completions`, {
36
- method: "POST",
37
- headers: {
38
- "Content-Type": "application/json",
39
- Authorization: `Bearer ${options.apiKey}`,
40
- },
41
- body: JSON.stringify({
42
- model: options.model,
43
- messages: [{ role: "user", content: "ping" }],
44
- max_tokens: 1,
45
- temperature: 0,
46
- }),
47
- signal,
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
- details = boundedDetails(await response.text());
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
- if (Array.isArray(parsed)) return true;
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 Array.isArray(record.data) || Array.isArray(record.models);
251
+ return isModelArray(record.data) || isModelArray(record.models);
77
252
  }
78
253
 
79
254
  /**
80
- * Validate an API key against a provider models endpoint.
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 key validation.
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, and
87
- * accepting the key on status alone would store a credential that was never
88
- * actually checked. The body must parse as JSON and carry a recognizable model
89
- * list before the key is considered validated.
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
- const response = await fetch(options.modelsUrl, {
96
- method: "GET",
97
- headers: {
98
- Authorization: `Bearer ${options.apiKey}`,
99
- },
100
- signal,
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
- body = await response.text();
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
- throw new Error(
109
- `${options.provider} API key validation failed: the models endpoint response body could not be read (${
110
- error instanceof Error ? error.message : String(error)
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
- details = boundedDetails(await response.text());
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
+ });