@gajae-code/ai 0.15.5 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/types/adapter-internals/aws-region.d.ts +7 -0
- 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 +92 -24
- package/dist/types/core.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/provider-models/openai-compat.d.ts +1 -0
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/openai-codex-responses.d.ts +6 -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 +22 -2
- 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/schema/normalize.d.ts +0 -5
- package/dist/types/utils/sqlite-errors.d.ts +4 -0
- package/package.json +3 -3
- package/src/adapter-internals/aws-region.d.ts +7 -0
- package/src/adapter-internals/aws-region.ts +14 -0
- 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 +672 -168
- package/src/cli.ts +1 -0
- package/src/core.ts +1 -0
- package/src/index.ts +1 -0
- package/src/model-thinking.ts +8 -0
- package/src/models.json +1224 -3
- package/src/provider-models/descriptors.ts +3 -1
- package/src/provider-models/openai-compat.ts +102 -1
- package/src/providers/amazon-bedrock.ts +5 -1
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +8 -2
- package/src/providers/aws-credentials.ts +6 -0
- 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-gemini-headers.d.ts +1 -1
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/google-shared.ts +3 -0
- package/src/providers/kiro-api-key.ts +33 -8
- package/src/providers/kiro-codewhisperer.ts +28 -9
- package/src/providers/ollama.ts +3 -0
- package/src/providers/openai-codex-responses.d.ts +6 -0
- package/src/providers/openai-codex-responses.ts +37 -8
- package/src/providers/openai-completions.ts +11 -1
- package/src/providers/openai-responses.ts +10 -1
- package/src/providers/pi-native-client.ts +25 -1
- 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 +175 -67
- package/src/types.d.ts +22 -2
- package/src/types.ts +27 -1
- 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/kiro.ts +91 -22
- 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/schema/dereference.ts +169 -49
- package/src/utils/schema/draft.ts +46 -23
- package/src/utils/schema/normalize.d.ts +0 -5
- package/src/utils/schema/normalize.ts +396 -119
- package/src/utils/schema/types.ts +3 -1
- package/src/utils/schema/zod-decontaminate.ts +83 -29
- package/src/utils/sqlite-errors.d.ts +4 -0
- package/src/utils/sqlite-errors.ts +13 -0
- package/src/utils/tool-choice-capability.ts +2 -3
|
@@ -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
|
+
});
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -180,6 +180,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
|
|
|
180
180
|
name: "OpenCode Go",
|
|
181
181
|
available: true,
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
id: "commandcode-goat",
|
|
185
|
+
name: "Command Code GOAT",
|
|
186
|
+
available: true,
|
|
187
|
+
},
|
|
183
188
|
{
|
|
184
189
|
id: "openrouter",
|
|
185
190
|
name: "OpenRouter",
|
|
@@ -400,6 +405,7 @@ export async function refreshOAuthToken(
|
|
|
400
405
|
case "huggingface":
|
|
401
406
|
case "opencode-zen":
|
|
402
407
|
case "opencode-go":
|
|
408
|
+
case "commandcode-goat":
|
|
403
409
|
case "cerebras":
|
|
404
410
|
case "deepinfra":
|
|
405
411
|
case "fireworks":
|
|
@@ -534,10 +540,19 @@ export function resolveOAuthStorageProvider(provider: OAuthProviderId): OAuthPro
|
|
|
534
540
|
* Get list of OAuth providers.
|
|
535
541
|
*/
|
|
536
542
|
export function getOAuthProviders(): OAuthProviderInfo[] {
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
543
|
+
const builtInIds = new Set(builtInOAuthProviders.map(provider => provider.id));
|
|
544
|
+
// A custom provider colliding with a built-in id (e.g. an external
|
|
545
|
+
// integration mistakenly registering "kiro") must never produce a
|
|
546
|
+
// duplicate list entry: AuthStorage.login()'s switch statement always
|
|
547
|
+
// dispatches a matched built-in `case` before falling through to the
|
|
548
|
+
// custom-provider registry, so the built-in always wins the route and
|
|
549
|
+
// the advertised list must reflect that, not double-list the id.
|
|
550
|
+
const customProviders = Array.from(customOAuthProviders.values())
|
|
551
|
+
.filter(provider => !builtInIds.has(provider.id))
|
|
552
|
+
.map(provider => ({
|
|
553
|
+
id: provider.id,
|
|
554
|
+
name: provider.name,
|
|
555
|
+
available: true,
|
|
556
|
+
}));
|
|
542
557
|
return [...builtInOAuthProviders, ...customProviders];
|
|
543
558
|
}
|