@gajae-code/ai 0.11.11 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +3 -0
- package/dist/types/model-thinking.d.ts +15 -0
- package/dist/types/provider-models/openai-compat.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +2 -1
- package/dist/types/providers/azure-openai-responses.d.ts +8 -1
- package/dist/types/providers/google-auth.d.ts +2 -0
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/google-vertex.d.ts +4 -0
- package/dist/types/providers/openai-codex-responses.d.ts +4 -0
- package/dist/types/providers/openai-completions.d.ts +2 -0
- package/dist/types/providers/openai-responses.d.ts +2 -0
- package/dist/types/types.d.ts +1 -1
- package/dist/types/usage/grok-cli.d.ts +3 -1
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/utils/anthropic-auth.d.ts +8 -0
- package/dist/types/utils/fallback-transport.d.ts +2 -0
- package/dist/types/utils/foundry.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +23 -0
- package/dist/types/utils/idle-iterator.d.ts +4 -0
- package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
- package/dist/types/utils/oauth/kimi.d.ts +2 -0
- package/dist/types/utils/oauth/perplexity.d.ts +2 -7
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/auth-storage.ts +6 -0
- package/src/cli.ts +1 -0
- package/src/model-thinking.ts +19 -0
- package/src/models.json +72 -0
- package/src/provider-models/descriptors.ts +7 -0
- package/src/provider-models/openai-compat.ts +67 -6
- package/src/providers/amazon-bedrock.ts +5 -20
- package/src/providers/anthropic.ts +103 -48
- package/src/providers/azure-openai-responses.ts +44 -19
- package/src/providers/google-auth.ts +13 -2
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/google-vertex.ts +41 -5
- package/src/providers/ollama.ts +32 -4
- package/src/providers/openai-codex-responses.ts +97 -38
- package/src/providers/openai-completions.ts +25 -14
- package/src/providers/openai-responses.ts +22 -20
- package/src/providers/register-builtins.ts +12 -2
- package/src/stream.ts +1 -0
- package/src/types.ts +1 -0
- package/src/usage/claude.ts +2 -1
- package/src/usage/grok-cli.ts +12 -1
- package/src/usage/kimi.ts +16 -2
- package/src/utils/anthropic-auth.ts +11 -3
- package/src/utils/fallback-transport.ts +17 -10
- package/src/utils/foundry.ts +12 -2
- package/src/utils/http-inspector.ts +124 -1
- package/src/utils/idle-iterator.ts +12 -3
- package/src/utils/oauth/bizrouter.ts +15 -0
- package/src/utils/oauth/index.ts +6 -0
- package/src/utils/oauth/kimi.ts +17 -2
- package/src/utils/oauth/perplexity.ts +21 -2
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/schema/adapt.ts +2 -2
- package/src/utils/tool-choice-capability.ts +2 -1
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
$credentialEnv,
|
|
3
|
-
$env,
|
|
4
|
-
$inheritedEnv,
|
|
5
|
-
extractHttpStatusFromError,
|
|
6
|
-
logger,
|
|
7
|
-
structuredCloneJSON,
|
|
8
|
-
} from "@gajae-code/utils";
|
|
1
|
+
import { $credentialEnv, extractHttpStatusFromError, logger, structuredCloneJSON } from "@gajae-code/utils";
|
|
9
2
|
import OpenAI from "openai";
|
|
10
3
|
import type {
|
|
11
4
|
Tool as OpenAITool,
|
|
@@ -44,7 +37,6 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
|
44
37
|
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
45
38
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
46
39
|
import {
|
|
47
|
-
createWatchdog,
|
|
48
40
|
getOpenAIStreamIdleTimeoutMs,
|
|
49
41
|
getStreamFirstEventTimeoutMs,
|
|
50
42
|
iterateWithIdleTimeout,
|
|
@@ -159,7 +151,10 @@ function resolveOpenAIProviderBaseUrl(
|
|
|
159
151
|
authCredentialType: "api_key" | "oauth" | undefined,
|
|
160
152
|
): string {
|
|
161
153
|
if (authCredentialType === "oauth") return OPENAI_DEFAULT_BASE_URL;
|
|
162
|
-
|
|
154
|
+
// Trusted sources only: this base URL becomes the request endpoint that carries
|
|
155
|
+
// the OpenAI credential, and `$env` merges the caller's `cwd/.env`, so reading it
|
|
156
|
+
// there would let repository content redirect authenticated traffic.
|
|
157
|
+
const envBaseUrl = $credentialEnv("OPENAI_BASE_URL");
|
|
163
158
|
const configuredBaseUrl = baseUrl?.trim();
|
|
164
159
|
if (envBaseUrl && (!configuredBaseUrl || isDefaultOpenAIBaseUrl(configuredBaseUrl))) {
|
|
165
160
|
return envBaseUrl;
|
|
@@ -167,6 +162,14 @@ function resolveOpenAIProviderBaseUrl(
|
|
|
167
162
|
return configuredBaseUrl || envBaseUrl || OPENAI_DEFAULT_BASE_URL;
|
|
168
163
|
}
|
|
169
164
|
|
|
165
|
+
/** Test seam: the provider base URL as resolved from trusted env. */
|
|
166
|
+
export function resolveOpenAIProviderBaseUrlForTest(
|
|
167
|
+
baseUrl: string | undefined,
|
|
168
|
+
authCredentialType: "api_key" | "oauth" | undefined,
|
|
169
|
+
): string {
|
|
170
|
+
return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
|
|
171
|
+
}
|
|
172
|
+
|
|
170
173
|
const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([
|
|
171
174
|
"response.created",
|
|
172
175
|
"response.output_item.added",
|
|
@@ -261,7 +264,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
261
264
|
);
|
|
262
265
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
263
266
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
|
264
|
-
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
|
265
267
|
const { requestAbortController, requestSignal } = abortTracker;
|
|
266
268
|
|
|
267
269
|
try {
|
|
@@ -333,10 +335,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
333
335
|
});
|
|
334
336
|
const firstEventFallbackMs =
|
|
335
337
|
model.provider === "alibaba-token-plan" ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS : undefined;
|
|
336
|
-
const
|
|
337
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs)
|
|
338
|
-
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
339
|
-
);
|
|
338
|
+
const firstEventTimeoutMs =
|
|
339
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
|
|
340
340
|
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
|
341
341
|
stream.push({ type: "start", partial: output });
|
|
342
342
|
|
|
@@ -344,9 +344,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
344
344
|
await processResponsesStream(
|
|
345
345
|
iterateWithIdleTimeout(openaiStream, {
|
|
346
346
|
idleTimeoutMs,
|
|
347
|
-
|
|
347
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
348
|
+
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
|
|
348
349
|
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
|
|
349
350
|
onIdle: () => requestAbortController.abort(),
|
|
351
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
350
352
|
abortSignal: options?.signal,
|
|
351
353
|
isProgressItem: isOpenAIResponsesProgressEvent,
|
|
352
354
|
}),
|
|
@@ -385,11 +387,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
385
387
|
stream.end();
|
|
386
388
|
} catch (error) {
|
|
387
389
|
for (const block of output.content) delete (block as { index?: number }).index;
|
|
388
|
-
const
|
|
390
|
+
const localAbortReason = abortTracker.getLocalAbortReason();
|
|
389
391
|
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
|
|
390
|
-
output.errorStatus = extractHttpStatusFromError(error);
|
|
391
|
-
output.transportFailure = transportFailureFacts(error);
|
|
392
|
-
output.errorMessage =
|
|
392
|
+
output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error);
|
|
393
|
+
output.transportFailure = transportFailureFacts(localAbortReason ?? error);
|
|
394
|
+
output.errorMessage = localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
|
|
393
395
|
output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
|
|
394
396
|
// Explicitly mark the poisoned-history rejection so the shared
|
|
395
397
|
// `invalid_prompt` contract is present even when the SDK error surfaces
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* lazy wrappers below), so this file IS the main streaming path's provider
|
|
11
11
|
* loader: heavy SDKs stay out of the CLI startup parse graph.
|
|
12
12
|
*/
|
|
13
|
+
|
|
13
14
|
import type {
|
|
14
15
|
Api,
|
|
15
16
|
AssistantMessage,
|
|
@@ -21,7 +22,13 @@ import type {
|
|
|
21
22
|
} from "../types";
|
|
22
23
|
import { type AbortSourceTracker, createAbortSourceTracker } from "../utils/abort";
|
|
23
24
|
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
|
|
24
|
-
import {
|
|
25
|
+
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
26
|
+
import {
|
|
27
|
+
FirstEventTimeoutError,
|
|
28
|
+
getStreamFirstEventTimeoutMs,
|
|
29
|
+
getStreamIdleTimeoutMs,
|
|
30
|
+
iterateWithIdleTimeout,
|
|
31
|
+
} from "../utils/idle-iterator";
|
|
25
32
|
import type { BedrockOptions } from "./amazon-bedrock";
|
|
26
33
|
import type { AnthropicOptions } from "./anthropic";
|
|
27
34
|
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
|
|
@@ -229,7 +236,8 @@ function forwardStream<TApi extends Api>(
|
|
|
229
236
|
errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
|
|
230
237
|
firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
|
|
231
238
|
onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
|
|
232
|
-
onFirstItemTimeout: () =>
|
|
239
|
+
onFirstItemTimeout: () =>
|
|
240
|
+
abortTracker.abortLocally(new FirstEventTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
|
|
233
241
|
abortSignal: options.signal,
|
|
234
242
|
// The synthetic `start` event is yielded immediately by every provider before
|
|
235
243
|
// the upstream model has emitted any tokens. Treating it as the first "real"
|
|
@@ -261,6 +269,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
|
|
|
261
269
|
error: unknown,
|
|
262
270
|
stopReason: Extract<AssistantMessage["stopReason"], "aborted" | "error"> = "error",
|
|
263
271
|
): AssistantMessage {
|
|
272
|
+
const transportFailure = transportFailureFacts(error);
|
|
264
273
|
return {
|
|
265
274
|
role: "assistant",
|
|
266
275
|
content: [],
|
|
@@ -278,6 +287,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
|
|
|
278
287
|
stopReason,
|
|
279
288
|
errorMessage:
|
|
280
289
|
stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
|
|
290
|
+
...(transportFailure ? { transportFailure } : {}),
|
|
281
291
|
timestamp: Date.now(),
|
|
282
292
|
};
|
|
283
293
|
}
|
package/src/stream.ts
CHANGED
|
@@ -163,6 +163,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
|
|
163
163
|
together: "TOGETHER_API_KEY",
|
|
164
164
|
zenmux: "ZENMUX_API_KEY",
|
|
165
165
|
opengateway: "OPENGATEWAY_API_KEY",
|
|
166
|
+
bizrouter: "BIZROUTER_API_KEY",
|
|
166
167
|
venice: "VENICE_API_KEY",
|
|
167
168
|
vllm: "VLLM_API_KEY",
|
|
168
169
|
xiaomi: "XIAOMI_API_KEY",
|
package/src/types.ts
CHANGED
package/src/usage/claude.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scheduler } from "node:timers/promises";
|
|
2
|
+
import { claudeCodeVersion } from "../providers/anthropic";
|
|
2
3
|
import type {
|
|
3
4
|
CredentialRankingStrategy,
|
|
4
5
|
UsageAmount,
|
|
@@ -31,7 +32,7 @@ const CLAUDE_HEADERS = {
|
|
|
31
32
|
"anthropic-beta":
|
|
32
33
|
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
|
33
34
|
"content-type": "application/json",
|
|
34
|
-
"user-agent":
|
|
35
|
+
"user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
|
|
35
36
|
connection: "keep-alive",
|
|
36
37
|
} as const;
|
|
37
38
|
|
package/src/usage/grok-cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { $credentialEnv } from "@gajae-code/utils";
|
|
1
2
|
import type {
|
|
2
3
|
CredentialRankingStrategy,
|
|
3
4
|
UsageFetchContext,
|
|
@@ -64,10 +65,20 @@ function isUnsafeGrokBaseUrlOverride(baseUrl?: string): boolean {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
function resolveAccessToken(params: UsageFetchParams): string | undefined {
|
|
67
|
-
|
|
68
|
+
// Trusted sources only for the env fallback: this token authenticates the
|
|
69
|
+
// billing/usage call, so whatever can set it decides which account is queried
|
|
70
|
+
// with it. `$env` merges the caller's `cwd/.env` into `process.env`, so
|
|
71
|
+
// reading it there would let repository content supply the credential.
|
|
72
|
+
// Stored credentials keep precedence.
|
|
73
|
+
const token = params.credential.accessToken ?? params.credential.apiKey ?? $credentialEnv("GROK_CLI_OAUTH_TOKEN");
|
|
68
74
|
return token?.trim() || undefined;
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
/** Test seam: the usage access token as resolved from a credential plus trusted env. */
|
|
78
|
+
export function resolveGrokAccessTokenForTest(params: UsageFetchParams): string | undefined {
|
|
79
|
+
return resolveAccessToken(params);
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
function buildMonthlyUsageLimit(usage: BillingUsage, nowMs: number): UsageLimit {
|
|
72
83
|
const usedFraction = usage.monthlyLimit > 0 ? usage.used / usage.monthlyLimit : 0;
|
|
73
84
|
const percent = usedFraction * 100;
|
package/src/usage/kimi.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $
|
|
1
|
+
import { $credentialEnv } from "@gajae-code/utils";
|
|
2
2
|
import type {
|
|
3
3
|
UsageAmount,
|
|
4
4
|
UsageFetchContext,
|
|
@@ -31,12 +31,26 @@ type KimiUsageRow = {
|
|
|
31
31
|
window?: UsageWindow;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Usage endpoint base, with the environment override resolved from trusted
|
|
36
|
+
* sources only.
|
|
37
|
+
*
|
|
38
|
+
* The result becomes the usage URL that the request sends
|
|
39
|
+
* `Authorization: Bearer <accessToken>` to, so whatever can set it receives the
|
|
40
|
+
* user's Kimi access token. `$env` merges the caller's `cwd/.env`, so reading it
|
|
41
|
+
* there would let repository content collect that token.
|
|
42
|
+
*/
|
|
34
43
|
function normalizeBaseUrl(baseUrl?: string): string {
|
|
35
|
-
const envBase = $
|
|
44
|
+
const envBase = $credentialEnv("KIMI_CODE_BASE_URL");
|
|
36
45
|
const candidate = baseUrl?.trim() || envBase || DEFAULT_BASE_URL;
|
|
37
46
|
return candidate.replace(/\/+$/, "");
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
/** Test seam: the usage base URL as resolved from a caller value plus trusted env. */
|
|
50
|
+
export function normalizeKimiUsageBaseUrlForTest(baseUrl?: string): string {
|
|
51
|
+
return normalizeBaseUrl(baseUrl);
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
function buildUsageUrl(baseUrl: string): string {
|
|
41
55
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
42
56
|
return `${normalized}${USAGE_PATH}`;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `authStorage.getApiKey("anthropic", sessionId)` first, then pass the result
|
|
9
9
|
* through {@link buildAnthropicAuthConfig} for header/URL shaping.
|
|
10
10
|
*/
|
|
11
|
-
import { $
|
|
11
|
+
import { $credentialEnv } from "@gajae-code/utils";
|
|
12
12
|
import {
|
|
13
13
|
buildAnthropicHeaders as buildProviderAnthropicHeaders,
|
|
14
14
|
normalizeAnthropicBaseUrl,
|
|
@@ -29,12 +29,20 @@ function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
|
|
|
29
29
|
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the Anthropic base URL from the environment.
|
|
34
|
+
*
|
|
35
|
+
* Trusted sources only: the result becomes the request URL that carries the
|
|
36
|
+
* Anthropic API key / OAuth token, so whatever can set it can redirect
|
|
37
|
+
* authenticated traffic. `$env` merges the caller's `cwd/.env`, so reading it
|
|
38
|
+
* there would let repository content choose where credentials are sent.
|
|
39
|
+
*/
|
|
32
40
|
export function resolveAnthropicBaseUrlFromEnv(): string | undefined {
|
|
33
41
|
if (isFoundryEnabled()) {
|
|
34
|
-
const foundryBaseUrl = normalizeBaseUrl($
|
|
42
|
+
const foundryBaseUrl = normalizeBaseUrl($credentialEnv("FOUNDRY_BASE_URL"));
|
|
35
43
|
if (foundryBaseUrl) return foundryBaseUrl;
|
|
36
44
|
}
|
|
37
|
-
const anthropicBaseUrl = normalizeBaseUrl($
|
|
45
|
+
const anthropicBaseUrl = normalizeBaseUrl($credentialEnv("ANTHROPIC_BASE_URL"));
|
|
38
46
|
return anthropicBaseUrl || undefined;
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -5,6 +5,9 @@ export interface FallbackTrigger {
|
|
|
5
5
|
retryAfterMs?: number;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Stable code for streams that time out before producing semantic progress. */
|
|
9
|
+
export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
|
|
10
|
+
|
|
8
11
|
export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
9
12
|
|
|
10
13
|
/**
|
|
@@ -185,7 +188,8 @@ export function transportFailureFacts(
|
|
|
185
188
|
!isQuotaCode(normalizedCode) &&
|
|
186
189
|
!isAuthCode(normalizedCode) &&
|
|
187
190
|
!isRateLimitCode(normalizedCode) &&
|
|
188
|
-
!isContextOverflowCode(normalizedCode)
|
|
191
|
+
!isContextOverflowCode(normalizedCode) &&
|
|
192
|
+
normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
|
|
189
193
|
) {
|
|
190
194
|
return undefined;
|
|
191
195
|
}
|
|
@@ -256,14 +260,17 @@ export function classifyFallbackTrigger(
|
|
|
256
260
|
parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
|
|
257
261
|
parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
|
|
258
262
|
const code = (facts.openaiErrorCode ?? facts.anthropicErrorType ?? facts.providerCode)?.toLowerCase();
|
|
259
|
-
const triggerClass: FallbackTriggerClass =
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
263
|
+
const triggerClass: FallbackTriggerClass =
|
|
264
|
+
code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
|
|
265
|
+
? "server"
|
|
266
|
+
: isQuotaCode(code)
|
|
267
|
+
? "quota"
|
|
268
|
+
: facts.status === 401 || facts.status === 403 || isAuthCode(code)
|
|
269
|
+
? "auth"
|
|
270
|
+
: facts.status === 429 || isRateLimitCode(code)
|
|
271
|
+
? "rate_limit"
|
|
272
|
+
: facts.status !== undefined && facts.status >= 500 && facts.status <= 599
|
|
273
|
+
? "server"
|
|
274
|
+
: "other";
|
|
268
275
|
return retryAfterMs === undefined ? { class: triggerClass } : { class: triggerClass, retryAfterMs };
|
|
269
276
|
}
|
package/src/utils/foundry.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { $
|
|
1
|
+
import { $credentialEnv } from "@gajae-code/utils";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Whether Anthropic requests run in Foundry gateway mode.
|
|
5
|
+
*
|
|
6
|
+
* Resolved from trusted environment sources only. Enabling Foundry switches the
|
|
7
|
+
* request base URL and injects TLS client material, so whatever can set this
|
|
8
|
+
* redirects authenticated traffic. `$env` merges the caller's `cwd/.env`, so
|
|
9
|
+
* reading it there would let repository content flip the mode; resolve it the
|
|
10
|
+
* same way the credentials themselves are (launching shell plus GJC/user-owned
|
|
11
|
+
* `.env` files, never the project `.env`).
|
|
12
|
+
*/
|
|
3
13
|
export function isFoundryEnabled(): boolean {
|
|
4
|
-
const value = $
|
|
14
|
+
const value = $credentialEnv("CLAUDE_CODE_USE_FOUNDRY");
|
|
5
15
|
if (!value) return false;
|
|
6
16
|
const normalized = value.trim().toLowerCase();
|
|
7
17
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
import { APP_NAME, extractHttpStatusFromError, getLogsDir } from "@gajae-code/utils";
|
|
3
4
|
import { isCopilotTransientModelError } from "./retry.js";
|
|
@@ -26,6 +27,34 @@ type ErrorWithStatus = {
|
|
|
26
27
|
|
|
27
28
|
const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie", "proxy-authorization"];
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Connection-level failure codes, meaning the request never reached the
|
|
32
|
+
* provider and no HTTP status exists. Bun reports the first group for `fetch`;
|
|
33
|
+
* the `E*`/`UND_ERR_*` group comes from Node-style DNS and socket errors.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately excludes aborts and TLS/certificate codes: an abort is a
|
|
36
|
+
* user/watchdog outcome with its own display path, and its message must keep
|
|
37
|
+
* matching the abort normalizers in `modes/utils/abort-message`.
|
|
38
|
+
*/
|
|
39
|
+
const TRANSPORT_FAILURE_CODES: ReadonlySet<string> = new Set([
|
|
40
|
+
"ConnectionClosed",
|
|
41
|
+
"ConnectionRefused",
|
|
42
|
+
"ConnectionReset",
|
|
43
|
+
"ConnectionTimeout",
|
|
44
|
+
"FailedToOpenSocket",
|
|
45
|
+
"HTTP2Unsupported",
|
|
46
|
+
"EAI_AGAIN",
|
|
47
|
+
"ECONNREFUSED",
|
|
48
|
+
"ECONNRESET",
|
|
49
|
+
"EHOSTUNREACH",
|
|
50
|
+
"ENETUNREACH",
|
|
51
|
+
"ENOTFOUND",
|
|
52
|
+
"EPIPE",
|
|
53
|
+
"ETIMEDOUT",
|
|
54
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
55
|
+
"UND_ERR_SOCKET",
|
|
56
|
+
]);
|
|
57
|
+
|
|
29
58
|
/**
|
|
30
59
|
* Privacy note appended next to a saved raw HTTP request dump. The dump is
|
|
31
60
|
* sanitized (secrets/thinking redacted) but can still contain prompt content
|
|
@@ -66,6 +95,49 @@ export function formatModelUnavailableGuidance(dump: RawHttpRequestDump | undefi
|
|
|
66
95
|
].join("\n");
|
|
67
96
|
}
|
|
68
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Cap on retained HTTP 400 request dumps.
|
|
100
|
+
*
|
|
101
|
+
* Each dump carries the full sanitized request body, so they are large: a
|
|
102
|
+
* developer machine accumulated 27,249 files totalling 7.0 GB, averaging 264 KB
|
|
103
|
+
* each, because nothing ever removed them. The rotating application log already
|
|
104
|
+
* bounds itself (`maxSize: 10m`, `maxFiles: 5`); these diagnostics get the same
|
|
105
|
+
* treatment so the newest failures stay available without unbounded growth.
|
|
106
|
+
*/
|
|
107
|
+
const MAX_RETAINED_DUMPS = 50;
|
|
108
|
+
|
|
109
|
+
/** Directory holding the retained HTTP 400 dumps. */
|
|
110
|
+
export function httpRequestDumpDir(): string {
|
|
111
|
+
return path.join(getLogsDir(), "http-400-requests");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Drop the oldest dumps beyond the cap. Best-effort: diagnostics must never turn
|
|
116
|
+
* a request failure into a second failure, so every step swallows its error.
|
|
117
|
+
*
|
|
118
|
+
* File names are `${Date.now()}-${hash}.json`, so a lexical sort is chronological
|
|
119
|
+
* for the millisecond timestamps this writer produces.
|
|
120
|
+
*/
|
|
121
|
+
export async function pruneHttpRequestDumps(dir: string = httpRequestDumpDir()): Promise<number> {
|
|
122
|
+
const entries = await fs.readdir(dir).catch(() => undefined);
|
|
123
|
+
if (!entries) return 0;
|
|
124
|
+
|
|
125
|
+
const dumps = entries.filter(name => name.endsWith(".json")).sort();
|
|
126
|
+
if (dumps.length <= MAX_RETAINED_DUMPS) return 0;
|
|
127
|
+
|
|
128
|
+
let removed = 0;
|
|
129
|
+
for (const name of dumps.slice(0, dumps.length - MAX_RETAINED_DUMPS)) {
|
|
130
|
+
if (
|
|
131
|
+
await fs.rm(path.join(dir, name), { force: true }).then(
|
|
132
|
+
() => true,
|
|
133
|
+
() => false,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
removed++;
|
|
137
|
+
}
|
|
138
|
+
return removed;
|
|
139
|
+
}
|
|
140
|
+
|
|
69
141
|
export async function appendRawHttpRequestDumpFor400(
|
|
70
142
|
message: string,
|
|
71
143
|
error: unknown,
|
|
@@ -77,10 +149,12 @@ export async function appendRawHttpRequestDumpFor400(
|
|
|
77
149
|
|
|
78
150
|
const sanitizedDump = sanitizeDump(dump);
|
|
79
151
|
const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(sanitizedDump)).toString(36)}.json`;
|
|
80
|
-
const
|
|
152
|
+
const dumpDir = httpRequestDumpDir();
|
|
153
|
+
const filePath = path.join(dumpDir, fileName);
|
|
81
154
|
|
|
82
155
|
try {
|
|
83
156
|
await Bun.write(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`);
|
|
157
|
+
await pruneHttpRequestDumps(dumpDir);
|
|
84
158
|
return `${message}\nraw-http-request=${filePath}\n${RAW_HTTP_REQUEST_PRIVACY_NOTE}`;
|
|
85
159
|
} catch (writeError) {
|
|
86
160
|
const writeMessage = writeError instanceof Error ? writeError.message : String(writeError);
|
|
@@ -88,6 +162,54 @@ export async function appendRawHttpRequestDumpFor400(
|
|
|
88
162
|
}
|
|
89
163
|
}
|
|
90
164
|
|
|
165
|
+
/** Origin and path of `value`, dropping query, fragment, and credentials so a
|
|
166
|
+
* key carried in the request URL (Google `?key=`, signed URLs) never lands in
|
|
167
|
+
* a user-visible error string. */
|
|
168
|
+
function redactRequestUrl(value: unknown): string | undefined {
|
|
169
|
+
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(value);
|
|
172
|
+
return `${url.origin}${url.pathname}`;
|
|
173
|
+
} catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findTransportFailure(error: unknown, depth: number): { code: string; url?: string } | undefined {
|
|
179
|
+
if (!error || typeof error !== "object" || depth > 2) return undefined;
|
|
180
|
+
const info = error as { code?: unknown; path?: unknown; url?: unknown; cause?: unknown };
|
|
181
|
+
if (typeof info.code === "string" && TRANSPORT_FAILURE_CODES.has(info.code)) {
|
|
182
|
+
return { code: info.code, url: redactRequestUrl(info.path) ?? redactRequestUrl(info.url) };
|
|
183
|
+
}
|
|
184
|
+
return findTransportFailure(info.cause, depth + 1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Name the failed connection when the request never produced an HTTP status.
|
|
189
|
+
*
|
|
190
|
+
* Bun raises DNS and socket failures as a bare `Error` whose message is a
|
|
191
|
+
* standalone hint ("Was there a typo in the url or port?", "Unable to connect.
|
|
192
|
+
* Is the computer able to access the url?") while the actionable facts live on
|
|
193
|
+
* `code` and `path`. Those properties are dropped when only `message` reaches
|
|
194
|
+
* the assistant message, so a provider outage, a local DNS failure, and a
|
|
195
|
+
* mistyped custom base URL all render as the same context-free sentence.
|
|
196
|
+
* Appending the code and the target URL tells the user which host failed and
|
|
197
|
+
* whether the fault is theirs.
|
|
198
|
+
*/
|
|
199
|
+
export function appendTransportFailureContext(
|
|
200
|
+
message: string,
|
|
201
|
+
error: unknown,
|
|
202
|
+
rawRequestDump: RawHttpRequestDump | undefined,
|
|
203
|
+
): string {
|
|
204
|
+
if (extractHttpStatusFromError(error) !== undefined) return message;
|
|
205
|
+
const failure = findTransportFailure(error, 0);
|
|
206
|
+
if (!failure) return message;
|
|
207
|
+
|
|
208
|
+
const url = failure.url ?? redactRequestUrl(rawRequestDump?.url);
|
|
209
|
+
const context = url ? `transport=${failure.code} url=${url}` : `transport=${failure.code}`;
|
|
210
|
+
return message.includes(context) ? message : `${message} (${context})`;
|
|
211
|
+
}
|
|
212
|
+
|
|
91
213
|
export async function finalizeErrorMessage(
|
|
92
214
|
error: unknown,
|
|
93
215
|
rawRequestDump: RawHttpRequestDump | undefined,
|
|
@@ -105,6 +227,7 @@ export async function finalizeErrorMessage(
|
|
|
105
227
|
if (isModelUnavailableError(message, error)) {
|
|
106
228
|
message = `${message}\n\n${formatModelUnavailableGuidance(rawRequestDump)}`;
|
|
107
229
|
}
|
|
230
|
+
message = appendTransportFailureContext(message, error, rawRequestDump);
|
|
108
231
|
return appendRawHttpRequestDumpFor400(message, error, rawRequestDump);
|
|
109
232
|
}
|
|
110
233
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { $env } from "@gajae-code/utils";
|
|
2
|
+
import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE } from "./fallback-transport";
|
|
2
3
|
|
|
3
4
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
|
|
4
5
|
const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
|
|
@@ -66,6 +67,14 @@ export function getStreamFirstEventTimeoutMs(
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export type Watchdog = NodeJS.Timeout | undefined;
|
|
70
|
+
export class FirstEventTimeoutError extends Error {
|
|
71
|
+
readonly providerCode = STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
|
|
72
|
+
|
|
73
|
+
constructor(message: string) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "FirstEventTimeoutError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
69
78
|
|
|
70
79
|
const dummyWatchdog = setTimeout(() => {}, 1);
|
|
71
80
|
clearTimeout(dummyWatchdog);
|
|
@@ -228,9 +237,9 @@ export async function* iterateWithIdleTimeout<T>(
|
|
|
228
237
|
options.onFirstItemTimeout?.();
|
|
229
238
|
}
|
|
230
239
|
closeIterator();
|
|
231
|
-
throw
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
throw awaitingFirstItem
|
|
241
|
+
? new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage)
|
|
242
|
+
: new Error(options.errorMessage);
|
|
234
243
|
}
|
|
235
244
|
if (outcome.kind === "error") {
|
|
236
245
|
throw outcome.error;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** BizRouter login flow (API key paste, validated via /v1/models). */
|
|
2
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
3
|
+
|
|
4
|
+
export const loginBizRouter = createApiKeyLogin({
|
|
5
|
+
providerLabel: "BizRouter",
|
|
6
|
+
authUrl: "https://bizrouter.ai/settings/keys",
|
|
7
|
+
instructions: "Create or copy your BizRouter API key",
|
|
8
|
+
promptMessage: "Paste your BizRouter API key",
|
|
9
|
+
placeholder: "sk-br-v1-...",
|
|
10
|
+
validation: {
|
|
11
|
+
kind: "models-endpoint",
|
|
12
|
+
provider: "BizRouter",
|
|
13
|
+
modelsUrl: "https://api.bizrouter.ai/v1/models",
|
|
14
|
+
},
|
|
15
|
+
});
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -240,6 +240,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
|
|
|
240
240
|
name: "ZenMux",
|
|
241
241
|
available: true,
|
|
242
242
|
},
|
|
243
|
+
{
|
|
244
|
+
id: "bizrouter",
|
|
245
|
+
name: "BizRouter",
|
|
246
|
+
available: true,
|
|
247
|
+
},
|
|
243
248
|
{
|
|
244
249
|
id: "opengateway",
|
|
245
250
|
name: "OpenGateway by Sionic AI",
|
|
@@ -391,6 +396,7 @@ export async function refreshOAuthToken(
|
|
|
391
396
|
case "vercel-ai-gateway":
|
|
392
397
|
case "qwen-portal":
|
|
393
398
|
case "zenmux":
|
|
399
|
+
case "bizrouter":
|
|
394
400
|
case "opengateway":
|
|
395
401
|
case "vllm":
|
|
396
402
|
// API keys / static bearer tokens don't expire, return as-is
|
package/src/utils/oauth/kimi.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { scheduler } from "node:timers/promises";
|
|
10
|
-
import { $
|
|
10
|
+
import { $pickCredentialEnv, getAgentDir, isEnoent } from "@gajae-code/utils";
|
|
11
11
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
12
12
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
13
13
|
|
|
@@ -38,8 +38,23 @@ interface TokenResponse {
|
|
|
38
38
|
interval?: number;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* OAuth host for the device flow, from trusted environment sources only.
|
|
43
|
+
*
|
|
44
|
+
* This host receives the device-authorization request, the authorization-code
|
|
45
|
+
* exchange, and the refresh call that carries the existing refresh token, so
|
|
46
|
+
* whatever can set it can collect the user's Kimi credentials. `$env` merges the
|
|
47
|
+
* caller's `cwd/.env`, so reading it there would let repository content redirect
|
|
48
|
+
* the login flow. Resolve it the same way the credentials themselves are:
|
|
49
|
+
* launching shell plus GJC/user-owned `.env` files, never the project `.env`.
|
|
50
|
+
*/
|
|
41
51
|
function resolveOAuthHost(): string {
|
|
42
|
-
return $
|
|
52
|
+
return $pickCredentialEnv("KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST") || DEFAULT_OAUTH_HOST;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Test seam: the OAuth host as resolved from trusted env. */
|
|
56
|
+
export function resolveKimiOAuthHostForTest(): string {
|
|
57
|
+
return resolveOAuthHost();
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
function formatDeviceModel(system: string, release: string, arch: string): string {
|
|
@@ -186,13 +186,32 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
|
|
|
186
186
|
*
|
|
187
187
|
* No browser/manual token paste fallback is used.
|
|
188
188
|
*/
|
|
189
|
+
/**
|
|
190
|
+
* Whether the operator disabled borrowing a token from the native macOS app.
|
|
191
|
+
*
|
|
192
|
+
* `GJC_AUTH_NO_BORROW` is the documented name; `PI_AUTH_NO_BORROW` is the legacy
|
|
193
|
+
* one that was the only name actually read.
|
|
194
|
+
*/
|
|
195
|
+
function authBorrowDisabled(): boolean {
|
|
196
|
+
return Boolean($env.GJC_AUTH_NO_BORROW || $env.PI_AUTH_NO_BORROW);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Test seam: the resolved native-app borrowing opt-out. */
|
|
200
|
+
export function authBorrowDisabledForTest(): boolean {
|
|
201
|
+
return authBorrowDisabled();
|
|
202
|
+
}
|
|
203
|
+
|
|
189
204
|
export async function loginPerplexity(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
190
205
|
if (!ctrl.onPrompt) {
|
|
191
206
|
throw new Error("Perplexity login requires onPrompt callback");
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
// Path 1: Native macOS app JWT
|
|
195
|
-
|
|
209
|
+
// Path 1: Native macOS app JWT, skipped when the operator opts out.
|
|
210
|
+
//
|
|
211
|
+
// Presence-based on purpose: this is a privacy opt-out, so any set value must
|
|
212
|
+
// disable borrowing. A boolean contract would let `GJC_AUTH_NO_BORROW=0`
|
|
213
|
+
// silently re-enable reading a token out of another application.
|
|
214
|
+
if (!authBorrowDisabled()) {
|
|
196
215
|
ctrl.onProgress?.("Checking for Perplexity desktop app...");
|
|
197
216
|
const nativeJwt = await extractFromNativeApp();
|
|
198
217
|
if (nativeJwt) {
|