@gajae-code/ai 0.4.5 → 0.5.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 +31 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/providers/amazon-bedrock.d.ts +29 -5
- package/dist/types/providers/composer-discipline.d.ts +27 -0
- package/dist/types/providers/cursor.d.ts +1 -1
- package/dist/types/providers/google-gemini-cli.d.ts +1 -1
- package/dist/types/providers/google-shared.d.ts +11 -1
- package/dist/types/providers/ollama.d.ts +36 -1
- package/dist/types/providers/openai-completions-compat.d.ts +3 -1
- package/dist/types/providers/register-builtins.d.ts +3 -3
- package/dist/types/types.d.ts +25 -3
- package/dist/types/usage/grok-cli.d.ts +10 -0
- package/dist/types/utils/event-stream.d.ts +6 -1
- package/dist/types/utils/oauth/xai.d.ts +10 -3
- package/dist/types/utils/tool-choice-capability.d.ts +41 -0
- package/package.json +2 -2
- package/src/auth-storage.ts +3 -0
- package/src/index.ts +2 -0
- package/src/model-thinking.ts +9 -0
- package/src/models.json +116 -0
- package/src/models.ts +33 -7
- package/src/provider-models/descriptors.ts +1 -1
- package/src/provider-models/openai-compat.ts +9 -1
- package/src/providers/amazon-bedrock.ts +145 -60
- package/src/providers/anthropic.ts +85 -32
- package/src/providers/azure-openai-responses.ts +44 -3
- package/src/providers/composer-discipline.ts +38 -0
- package/src/providers/cursor.ts +10 -3
- package/src/providers/google-gemini-cli.ts +69 -10
- package/src/providers/google-shared.ts +61 -12
- package/src/providers/ollama.ts +60 -4
- package/src/providers/openai-codex-responses.ts +151 -2
- package/src/providers/openai-completions-compat.ts +9 -1
- package/src/providers/openai-completions.ts +46 -6
- package/src/providers/openai-request-transform.ts +1 -0
- package/src/providers/openai-responses.ts +54 -5
- package/src/providers/register-builtins.ts +5 -6
- package/src/rate-limit-utils.ts +11 -2
- package/src/types.ts +37 -3
- package/src/usage/grok-cli.ts +163 -0
- package/src/utils/event-stream.ts +35 -5
- package/src/utils/oauth/xai.ts +49 -13
- package/src/utils/tool-choice-capability.ts +220 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $env, $inheritedEnv, extractHttpStatusFromError } from "@gajae-code/utils";
|
|
1
|
+
import { $credentialEnv, $env, $inheritedEnv, extractHttpStatusFromError, logger } from "@gajae-code/utils";
|
|
2
2
|
import OpenAI from "openai";
|
|
3
3
|
import type {
|
|
4
4
|
ChatCompletionAssistantMessageParam,
|
|
@@ -61,6 +61,12 @@ import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema
|
|
|
61
61
|
import { wrapFetchForSseDebug } from "../utils/sse-debug";
|
|
62
62
|
import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
|
|
63
63
|
import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
|
|
64
|
+
import {
|
|
65
|
+
isForcedToolChoiceUnsupportedError,
|
|
66
|
+
markToolChoiceIncapability,
|
|
67
|
+
resolveToolChoice,
|
|
68
|
+
} from "../utils/tool-choice-capability";
|
|
69
|
+
import { COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
|
|
64
70
|
import {
|
|
65
71
|
buildCopilotDynamicHeaders,
|
|
66
72
|
hasCopilotVisionInput,
|
|
@@ -493,7 +499,25 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
493
499
|
});
|
|
494
500
|
} catch (error) {
|
|
495
501
|
const capturedErrorResponse = getCapturedErrorResponse();
|
|
496
|
-
|
|
502
|
+
const sentForcedToolChoice = isForcedToolChoice(
|
|
503
|
+
(rawRequestDump?.body as { tool_choice?: unknown } | undefined)?.tool_choice,
|
|
504
|
+
);
|
|
505
|
+
if (firstTokenTime === undefined && isForcedToolChoiceUnsupportedError(error, sentForcedToolChoice)) {
|
|
506
|
+
const reason = await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse);
|
|
507
|
+
markToolChoiceIncapability(model, "auto", reason);
|
|
508
|
+
const resolvedToolChoice = resolveToolChoice(model, options?.toolChoice);
|
|
509
|
+
stream.push({
|
|
510
|
+
type: "toolChoiceIncapability",
|
|
511
|
+
api: model.api,
|
|
512
|
+
provider: model.provider,
|
|
513
|
+
model: model.id,
|
|
514
|
+
requestedLevel: resolvedToolChoice.requestedLevel,
|
|
515
|
+
resolvedLevel: "auto",
|
|
516
|
+
reason,
|
|
517
|
+
registryKey: resolvedToolChoice.registryKey,
|
|
518
|
+
});
|
|
519
|
+
openaiStream = await createCompletionsStream();
|
|
520
|
+
} else if (
|
|
497
521
|
isOpenRouterAnthropicModel(model) &&
|
|
498
522
|
!disableStrictTools &&
|
|
499
523
|
isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse)
|
|
@@ -928,12 +952,12 @@ async function createClient(
|
|
|
928
952
|
clearCapturedErrorResponse: () => void;
|
|
929
953
|
}> {
|
|
930
954
|
if (!apiKey) {
|
|
931
|
-
|
|
955
|
+
apiKey = $credentialEnv("OPENAI_API_KEY");
|
|
956
|
+
if (!apiKey) {
|
|
932
957
|
throw new Error(
|
|
933
958
|
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
|
|
934
959
|
);
|
|
935
960
|
}
|
|
936
|
-
apiKey = $env.OPENAI_API_KEY;
|
|
937
961
|
}
|
|
938
962
|
const rawApiKey = apiKey;
|
|
939
963
|
|
|
@@ -1166,8 +1190,19 @@ function buildParams(
|
|
|
1166
1190
|
params.tools = [];
|
|
1167
1191
|
}
|
|
1168
1192
|
|
|
1169
|
-
if (options?.toolChoice
|
|
1170
|
-
|
|
1193
|
+
if (options?.toolChoice) {
|
|
1194
|
+
const toolChoice = resolveToolChoice(model, options.toolChoice, compat);
|
|
1195
|
+
if (toolChoice.degraded && toolChoice.supportSource === "runtime") {
|
|
1196
|
+
logger.debug("openai-completions: degraded tool_choice after runtime capability discovery", {
|
|
1197
|
+
model: model.id,
|
|
1198
|
+
requestedLevel: toolChoice.requestedLevel,
|
|
1199
|
+
resolvedLevel: toolChoice.resolvedLevel,
|
|
1200
|
+
reason: toolChoice.reason,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (toolChoice.resolvedChoice !== undefined) {
|
|
1204
|
+
params.tool_choice = mapToOpenAICompletionsToolChoice(toolChoice.resolvedChoice);
|
|
1205
|
+
}
|
|
1171
1206
|
}
|
|
1172
1207
|
|
|
1173
1208
|
if (params.tool_choice === "none" && (!Array.isArray(params.tools) || params.tools.length === 0)) {
|
|
@@ -1430,6 +1465,11 @@ export function convertMessages(
|
|
|
1430
1465
|
};
|
|
1431
1466
|
|
|
1432
1467
|
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
|
1468
|
+
// Composer-harness models need anchor/edit discipline pinned ahead of the
|
|
1469
|
+
// host prompt (see composer-discipline.ts for the observed failure modes).
|
|
1470
|
+
if (systemPrompts.length > 0 && isComposerHarnessModel(model.id)) {
|
|
1471
|
+
systemPrompts.unshift(COMPOSER_EDIT_DISCIPLINE_PROMPT);
|
|
1472
|
+
}
|
|
1433
1473
|
if (systemPrompts.length > 0) {
|
|
1434
1474
|
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
|
|
1435
1475
|
const role = useDeveloperRole ? "developer" : "system";
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
$credentialEnv,
|
|
3
|
+
$env,
|
|
4
|
+
$inheritedEnv,
|
|
5
|
+
extractHttpStatusFromError,
|
|
6
|
+
logger,
|
|
7
|
+
structuredCloneJSON,
|
|
8
|
+
} from "@gajae-code/utils";
|
|
2
9
|
import OpenAI from "openai";
|
|
3
10
|
import type {
|
|
4
11
|
Tool as OpenAITool,
|
|
@@ -46,6 +53,11 @@ import { resolveRetryBudget } from "../utils/retry-budget";
|
|
|
46
53
|
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
|
47
54
|
import { wrapFetchForSseDebug } from "../utils/sse-debug";
|
|
48
55
|
import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice";
|
|
56
|
+
import {
|
|
57
|
+
isForcedToolChoiceUnsupportedError,
|
|
58
|
+
markToolChoiceIncapability,
|
|
59
|
+
resolveToolChoice,
|
|
60
|
+
} from "../utils/tool-choice-capability";
|
|
49
61
|
import {
|
|
50
62
|
buildCopilotDynamicHeaders,
|
|
51
63
|
hasCopilotVisionInput,
|
|
@@ -278,7 +290,31 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
278
290
|
return data;
|
|
279
291
|
},
|
|
280
292
|
{ provider: model.provider, signal: requestSignal },
|
|
281
|
-
)
|
|
293
|
+
).catch(async error => {
|
|
294
|
+
if (!isForcedToolChoiceUnsupportedError(error, isForcedOpenAIResponsesToolChoice(params.tool_choice))) {
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const reason = await finalizeErrorMessage(error, rawRequestDump);
|
|
298
|
+
markToolChoiceIncapability(model, "auto", reason);
|
|
299
|
+
const resolvedToolChoice = resolveToolChoice(model, options?.toolChoice);
|
|
300
|
+
stream.push({
|
|
301
|
+
type: "toolChoiceIncapability",
|
|
302
|
+
api: model.api,
|
|
303
|
+
provider: model.provider,
|
|
304
|
+
model: model.id,
|
|
305
|
+
requestedLevel: resolvedToolChoice.requestedLevel,
|
|
306
|
+
resolvedLevel: "auto",
|
|
307
|
+
reason,
|
|
308
|
+
registryKey: resolvedToolChoice.registryKey,
|
|
309
|
+
});
|
|
310
|
+
delete params.tool_choice;
|
|
311
|
+
if (rawRequestDump) rawRequestDump.body = params;
|
|
312
|
+
const { data, response, request_id } = await client.responses
|
|
313
|
+
.create(params, { signal: requestSignal })
|
|
314
|
+
.withResponse();
|
|
315
|
+
await notifyProviderResponse(options, response, model, request_id);
|
|
316
|
+
return data;
|
|
317
|
+
});
|
|
282
318
|
const firstEventWatchdog = createWatchdog(
|
|
283
319
|
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs),
|
|
284
320
|
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
@@ -363,12 +399,12 @@ function createClient(
|
|
|
363
399
|
baseUrl: string | undefined;
|
|
364
400
|
} {
|
|
365
401
|
if (!apiKey) {
|
|
366
|
-
|
|
402
|
+
apiKey = $credentialEnv("OPENAI_API_KEY");
|
|
403
|
+
if (!apiKey) {
|
|
367
404
|
throw new Error(
|
|
368
405
|
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
|
|
369
406
|
);
|
|
370
407
|
}
|
|
371
|
-
apiKey = $env.OPENAI_API_KEY;
|
|
372
408
|
}
|
|
373
409
|
const rawApiKey = apiKey;
|
|
374
410
|
|
|
@@ -490,7 +526,16 @@ function buildParams(
|
|
|
490
526
|
if (context.tools) {
|
|
491
527
|
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
|
|
492
528
|
if (options?.toolChoice) {
|
|
493
|
-
|
|
529
|
+
const toolChoice = resolveToolChoice(model, options.toolChoice);
|
|
530
|
+
if (toolChoice.degraded && toolChoice.supportSource === "runtime") {
|
|
531
|
+
logger.debug("openai-responses: degraded tool_choice after runtime capability discovery", {
|
|
532
|
+
model: model.id,
|
|
533
|
+
requestedLevel: toolChoice.requestedLevel,
|
|
534
|
+
resolvedLevel: toolChoice.resolvedLevel,
|
|
535
|
+
reason: toolChoice.reason,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
params.tool_choice = mapOpenAIResponsesToolChoiceForTools(toolChoice.resolvedChoice, context.tools, model);
|
|
494
539
|
}
|
|
495
540
|
// The apply_patch spec §1 marks only `apply_patch` itself as
|
|
496
541
|
// `supports_parallel_tool_calls = false`. OpenAI's Responses API
|
|
@@ -651,6 +696,10 @@ export function mapOpenAIResponsesToolChoiceForTools(
|
|
|
651
696
|
return customTool ? { type: "custom", name: customTool.customWireName ?? customTool.name } : mapped;
|
|
652
697
|
}
|
|
653
698
|
|
|
699
|
+
function isForcedOpenAIResponsesToolChoice(choice: unknown): boolean {
|
|
700
|
+
return !!choice && choice !== "none" && choice !== "auto";
|
|
701
|
+
}
|
|
702
|
+
|
|
654
703
|
/** @internal Exported for tests. */
|
|
655
704
|
export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">): OpenAITool[] {
|
|
656
705
|
const allowFreeform = supportsFreeformApplyPatch(model);
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* openai) at startup. The loaded module promise is cached so subsequent calls
|
|
7
7
|
* reuse the same import.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* stream.ts imports its provider stream functions from this module (see the
|
|
10
|
+
* lazy wrappers below), so this file IS the main streaming path's provider
|
|
11
|
+
* loader: heavy SDKs stay out of the CLI startup parse graph.
|
|
12
12
|
*/
|
|
13
13
|
import type {
|
|
14
14
|
Api,
|
|
@@ -390,9 +390,8 @@ function loadBedrockProviderModule(): Promise<LazyProviderModule<"bedrock-conver
|
|
|
390
390
|
// ---------------------------------------------------------------------------
|
|
391
391
|
// Lazy stream function exports
|
|
392
392
|
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
// providers, the lazy loading will take effect on the main code path.
|
|
393
|
+
// Provider registry code imports these wrappers so the concrete provider modules
|
|
394
|
+
// are loaded on first use instead of during package initialization.
|
|
396
395
|
// ---------------------------------------------------------------------------
|
|
397
396
|
|
|
398
397
|
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule);
|
package/src/rate-limit-utils.ts
CHANGED
|
@@ -45,7 +45,16 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
|
|
45
45
|
return "RATE_LIMIT_EXCEEDED";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
if (
|
|
48
|
+
if (
|
|
49
|
+
lower.includes("exhausted") ||
|
|
50
|
+
lower.includes("quota") ||
|
|
51
|
+
lower.includes("usage limit") ||
|
|
52
|
+
lower.includes("model limit") ||
|
|
53
|
+
lower.includes("model_limit") ||
|
|
54
|
+
lower.includes("message limit") ||
|
|
55
|
+
lower.includes("message_limit") ||
|
|
56
|
+
lower.includes("limit for this model")
|
|
57
|
+
) {
|
|
49
58
|
return "QUOTA_EXHAUSTED";
|
|
50
59
|
}
|
|
51
60
|
|
|
@@ -77,7 +86,7 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
|
|
|
77
86
|
|
|
78
87
|
/** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
|
|
79
88
|
const USAGE_LIMIT_PATTERN =
|
|
80
|
-
/usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|resource
|
|
89
|
+
/usage.?limit|usage_limit_reached|usage_not_included|limit_reached|model.?limit|model_limit_reached|message.?limit|message_limit_reached|limit for this model|quota.?exceeded|resource has been exhausted[^\n]*(?:quota|limit)/i;
|
|
81
90
|
|
|
82
91
|
export function isUsageLimitError(errorMessage: string): boolean {
|
|
83
92
|
return USAGE_LIMIT_PATTERN.test(errorMessage);
|
package/src/types.ts
CHANGED
|
@@ -161,6 +161,18 @@ export type ToolChoice =
|
|
|
161
161
|
| { type: "function"; function: { name: string } }
|
|
162
162
|
| { type: "tool"; name: string };
|
|
163
163
|
|
|
164
|
+
export type ToolChoiceSupport = "none" | "auto" | "required" | "named";
|
|
165
|
+
export type ToolChoiceSupportSource = "static" | "derived" | "runtime";
|
|
166
|
+
|
|
167
|
+
export interface ToolChoiceCompat {
|
|
168
|
+
/** Maximum supported tool_choice level. */
|
|
169
|
+
toolChoiceSupport?: ToolChoiceSupport;
|
|
170
|
+
/** Legacy flag for accepting the tool_choice parameter. */
|
|
171
|
+
supportsToolChoice?: boolean;
|
|
172
|
+
/** Legacy flag for forced tool_choice support. */
|
|
173
|
+
supportsForcedToolChoice?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
// Base options all providers share
|
|
165
177
|
export type CacheRetention = "none" | "short" | "long";
|
|
166
178
|
|
|
@@ -705,13 +717,24 @@ export type AssistantMessageEvent =
|
|
|
705
717
|
contentIndex?: undefined;
|
|
706
718
|
reason: Extract<StopReason, "aborted" | "error">;
|
|
707
719
|
error: AssistantMessage;
|
|
720
|
+
}
|
|
721
|
+
| {
|
|
722
|
+
type: "toolChoiceIncapability";
|
|
723
|
+
contentIndex?: undefined;
|
|
724
|
+
api: string;
|
|
725
|
+
provider: string;
|
|
726
|
+
model: string;
|
|
727
|
+
requestedLevel: ToolChoiceSupport;
|
|
728
|
+
resolvedLevel: ToolChoiceSupport;
|
|
729
|
+
reason: string;
|
|
730
|
+
registryKey: string;
|
|
708
731
|
};
|
|
709
732
|
|
|
710
733
|
/**
|
|
711
734
|
* Compatibility settings for openai-completions API.
|
|
712
735
|
* Use this to override URL-based auto-detection for custom providers.
|
|
713
736
|
*/
|
|
714
|
-
export interface OpenAICompat {
|
|
737
|
+
export interface OpenAICompat extends ToolChoiceCompat {
|
|
715
738
|
/** Whether the provider supports the `store` field. Default: auto-detected from URL. */
|
|
716
739
|
supportsStore?: boolean;
|
|
717
740
|
/** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */
|
|
@@ -757,6 +780,8 @@ export interface OpenAICompat {
|
|
|
757
780
|
requiresAssistantContentForToolCalls?: boolean;
|
|
758
781
|
/** Whether the provider supports the `tool_choice` parameter. Default: true. */
|
|
759
782
|
supportsToolChoice?: boolean;
|
|
783
|
+
/** Whether `tool_choice` may force a tool (`required` / named tool). Default: true. */
|
|
784
|
+
supportsForcedToolChoice?: boolean;
|
|
760
785
|
/**
|
|
761
786
|
* Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for
|
|
762
787
|
* the request when `tool_choice` forces a tool call. Mirrors the Anthropic
|
|
@@ -789,7 +814,7 @@ export interface OpenAICompat {
|
|
|
789
814
|
* Use this to disable features that strict-by-default Anthropic accepts but
|
|
790
815
|
* that proxy gateways (Vertex AI, AWS Bedrock-style fronts, etc.) reject.
|
|
791
816
|
*/
|
|
792
|
-
export interface AnthropicCompat {
|
|
817
|
+
export interface AnthropicCompat extends ToolChoiceCompat {
|
|
793
818
|
/**
|
|
794
819
|
* Drop the top-level `strict: true` field on tool definitions. Vertex AI's
|
|
795
820
|
* Anthropic-compatible endpoint rejects unknown tool fields with
|
|
@@ -911,7 +936,16 @@ export interface Model<TApi extends Api = any> {
|
|
|
911
936
|
? OpenAICompat
|
|
912
937
|
: TApi extends "anthropic-messages"
|
|
913
938
|
? AnthropicCompat
|
|
914
|
-
:
|
|
939
|
+
: TApi extends
|
|
940
|
+
| "bedrock-converse-stream"
|
|
941
|
+
| "google-generative-ai"
|
|
942
|
+
| "google-gemini-cli"
|
|
943
|
+
| "google-vertex"
|
|
944
|
+
| "ollama-chat"
|
|
945
|
+
| "azure-openai-responses"
|
|
946
|
+
| "openai-codex-responses"
|
|
947
|
+
? ToolChoiceCompat
|
|
948
|
+
: never;
|
|
915
949
|
/**
|
|
916
950
|
* Which shape to use when exposing the OpenAI code backend `apply_patch` tool to this model.
|
|
917
951
|
* Generated catalog policy sets `"freeform"` for first-party GPT-5 Responses
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CredentialRankingStrategy,
|
|
3
|
+
UsageFetchContext,
|
|
4
|
+
UsageFetchParams,
|
|
5
|
+
UsageLimit,
|
|
6
|
+
UsageProvider,
|
|
7
|
+
UsageReport,
|
|
8
|
+
} from "../usage";
|
|
9
|
+
|
|
10
|
+
interface BillingUsage {
|
|
11
|
+
monthlyLimit: number;
|
|
12
|
+
used: number;
|
|
13
|
+
billingPeriodEnd: string;
|
|
14
|
+
}
|
|
15
|
+
const DEFAULT_GROK_BUILD_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
16
|
+
const ALLOWED_GROK_BUILD_HOSTS = new Set(["cli-chat-proxy.grok.com"]);
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return !!value && typeof value === "object";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
23
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseValNumber(value: unknown): number | undefined {
|
|
27
|
+
return isRecord(value) ? finiteNumber(value.val) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseGrokCliBillingUsage(payload: unknown): BillingUsage {
|
|
31
|
+
if (!isRecord(payload) || !isRecord(payload.config)) {
|
|
32
|
+
throw new Error("invalid Grok CLI billing payload");
|
|
33
|
+
}
|
|
34
|
+
const monthlyLimit = parseValNumber(payload.config.monthlyLimit);
|
|
35
|
+
const used = parseValNumber(payload.config.used);
|
|
36
|
+
const billingPeriodEnd = payload.config.billingPeriodEnd;
|
|
37
|
+
if (
|
|
38
|
+
monthlyLimit === undefined ||
|
|
39
|
+
used === undefined ||
|
|
40
|
+
typeof billingPeriodEnd !== "string" ||
|
|
41
|
+
!Number.isFinite(new Date(billingPeriodEnd).getTime())
|
|
42
|
+
) {
|
|
43
|
+
throw new Error("invalid Grok CLI billing payload");
|
|
44
|
+
}
|
|
45
|
+
return { monthlyLimit, used, billingPeriodEnd };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isAllowedGrokCredentialHost(baseUrl: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(baseUrl);
|
|
51
|
+
return url.protocol === "https:" && ALLOWED_GROK_BUILD_HOSTS.has(url.hostname.toLowerCase());
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeGrokBaseUrl(baseUrl?: string): string {
|
|
58
|
+
const normalized = (baseUrl?.trim() || DEFAULT_GROK_BUILD_BASE_URL).replace(/\/+$/, "");
|
|
59
|
+
return isAllowedGrokCredentialHost(normalized) ? normalized : DEFAULT_GROK_BUILD_BASE_URL;
|
|
60
|
+
}
|
|
61
|
+
function isUnsafeGrokBaseUrlOverride(baseUrl?: string): boolean {
|
|
62
|
+
const normalized = baseUrl?.trim().replace(/\/+$/, "");
|
|
63
|
+
return !!normalized && !isAllowedGrokCredentialHost(normalized);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveAccessToken(params: UsageFetchParams): string | undefined {
|
|
67
|
+
const token = params.credential.accessToken ?? params.credential.apiKey ?? process.env.GROK_CLI_OAUTH_TOKEN;
|
|
68
|
+
return token?.trim() || undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildMonthlyUsageLimit(usage: BillingUsage, nowMs: number): UsageLimit {
|
|
72
|
+
const usedFraction = usage.monthlyLimit > 0 ? usage.used / usage.monthlyLimit : 0;
|
|
73
|
+
const percent = usedFraction * 100;
|
|
74
|
+
const resetsAt = new Date(usage.billingPeriodEnd).getTime();
|
|
75
|
+
return {
|
|
76
|
+
id: "grok-build:7d",
|
|
77
|
+
label: "SuperGrok monthly credits",
|
|
78
|
+
scope: { provider: "grok-build", shared: true, windowId: "7d" },
|
|
79
|
+
window: {
|
|
80
|
+
id: "7d",
|
|
81
|
+
label: "Monthly credits",
|
|
82
|
+
resetsAt,
|
|
83
|
+
},
|
|
84
|
+
amount: {
|
|
85
|
+
unit: "percent",
|
|
86
|
+
used: percent,
|
|
87
|
+
limit: 100,
|
|
88
|
+
remaining: Math.max(0, 100 - percent),
|
|
89
|
+
usedFraction,
|
|
90
|
+
remainingFraction: Math.max(0, 1 - usedFraction),
|
|
91
|
+
},
|
|
92
|
+
status: percent >= 95 ? "exhausted" : percent >= 80 ? "warning" : "ok",
|
|
93
|
+
notes: [
|
|
94
|
+
`${usage.used}/${usage.monthlyLimit} credits used`,
|
|
95
|
+
`resets in ${Math.max(0, Math.round((resetsAt - nowMs) / 3_600_000))}h`,
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const grokCliUsageProvider: UsageProvider = {
|
|
101
|
+
id: "grok-build",
|
|
102
|
+
|
|
103
|
+
supports(params) {
|
|
104
|
+
return params.provider === "grok-build";
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
|
108
|
+
const accessToken = resolveAccessToken(params);
|
|
109
|
+
if (!accessToken) {
|
|
110
|
+
ctx.logger?.warn("Grok Build usage: no access token", { provider: params.provider });
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isUnsafeGrokBaseUrlOverride(params.baseUrl)) {
|
|
115
|
+
ctx.logger?.warn("Grok Build usage: ignoring unsafe base URL override for credential safety", {
|
|
116
|
+
provider: params.provider,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const billingBaseUrl = normalizeGrokBaseUrl(params.baseUrl);
|
|
120
|
+
const response = await ctx.fetch(`${billingBaseUrl}/billing`, {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${accessToken}`,
|
|
123
|
+
"x-xai-token-auth": "xai-grok-cli",
|
|
124
|
+
accept: "application/json",
|
|
125
|
+
},
|
|
126
|
+
signal: params.signal,
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
ctx.logger?.warn("Grok Build billing request failed", { status: response.status, provider: params.provider });
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const payload = (await response.json()) as unknown;
|
|
134
|
+
let billing: BillingUsage;
|
|
135
|
+
try {
|
|
136
|
+
billing = parseGrokCliBillingUsage(payload);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
ctx.logger?.warn("Grok Build billing parse failed", { error: String(error) });
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const nowMs = Date.now();
|
|
143
|
+
return {
|
|
144
|
+
provider: "grok-build",
|
|
145
|
+
fetchedAt: nowMs,
|
|
146
|
+
limits: [buildMonthlyUsageLimit(billing, nowMs)],
|
|
147
|
+
metadata: {
|
|
148
|
+
email: params.credential.email,
|
|
149
|
+
accountId: params.credential.accountId,
|
|
150
|
+
subscription: true,
|
|
151
|
+
},
|
|
152
|
+
raw: payload,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const grokCliRankingStrategy: CredentialRankingStrategy = {
|
|
158
|
+
findWindowLimits(report) {
|
|
159
|
+
const monthly = report.limits.find(limit => limit.id === "grok-build:7d");
|
|
160
|
+
return { secondary: monthly };
|
|
161
|
+
},
|
|
162
|
+
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 30 * 24 * 60 * 60 * 1000 },
|
|
163
|
+
};
|
|
@@ -2,7 +2,8 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types";
|
|
|
2
2
|
|
|
3
3
|
// Generic event stream class for async iteration
|
|
4
4
|
export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
5
|
-
queue: T[] = [];
|
|
5
|
+
#queue: T[] = [];
|
|
6
|
+
#queueHead = 0;
|
|
6
7
|
waiting: Array<{ resolve: (value: IteratorResult<T>) => void; reject: (err: unknown) => void }> = [];
|
|
7
8
|
done = false;
|
|
8
9
|
#failed = false;
|
|
@@ -25,6 +26,35 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
25
26
|
this.extractResult = extractResult;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
#enqueue(event: T): void {
|
|
30
|
+
this.#queue.push(event);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#dequeue(): T | undefined {
|
|
34
|
+
if (this.#queueHead >= this.#queue.length) return undefined;
|
|
35
|
+
const event = this.#queue[this.#queueHead]!;
|
|
36
|
+
this.#queue[this.#queueHead] = undefined as T;
|
|
37
|
+
this.#queueHead++;
|
|
38
|
+
if (this.#queueHead > 1024 && this.#queueHead * 2 >= this.#queue.length) {
|
|
39
|
+
this.#queue = this.#queue.slice(this.#queueHead);
|
|
40
|
+
this.#queueHead = 0;
|
|
41
|
+
}
|
|
42
|
+
return event;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get #queueLength(): number {
|
|
46
|
+
return this.#queue.length - this.#queueHead;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read-only snapshot of the not-yet-consumed events. Always a fresh copy:
|
|
51
|
+
* external code can never mutate internal queue state or observe head-index
|
|
52
|
+
* tombstones, so the deque cannot desynchronize.
|
|
53
|
+
*/
|
|
54
|
+
get queue(): T[] {
|
|
55
|
+
return this.#queue.slice(this.#queueHead);
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
push(event: T): void {
|
|
29
59
|
if (this.done) return;
|
|
30
60
|
|
|
@@ -38,7 +68,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
38
68
|
if (waiter) {
|
|
39
69
|
waiter.resolve({ value: event, done: false });
|
|
40
70
|
} else {
|
|
41
|
-
this
|
|
71
|
+
this.#enqueue(event);
|
|
42
72
|
}
|
|
43
73
|
}
|
|
44
74
|
|
|
@@ -47,7 +77,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
47
77
|
if (waiter) {
|
|
48
78
|
waiter.resolve({ value: event, done: false });
|
|
49
79
|
} else {
|
|
50
|
-
this
|
|
80
|
+
this.#enqueue(event);
|
|
51
81
|
}
|
|
52
82
|
}
|
|
53
83
|
|
|
@@ -84,8 +114,8 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
84
114
|
|
|
85
115
|
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
86
116
|
while (true) {
|
|
87
|
-
if (this
|
|
88
|
-
yield this
|
|
117
|
+
if (this.#queueLength > 0) {
|
|
118
|
+
yield this.#dequeue()!;
|
|
89
119
|
} else if (this.#failed) {
|
|
90
120
|
throw this.#error;
|
|
91
121
|
} else if (this.done) {
|
package/src/utils/oauth/xai.ts
CHANGED
|
@@ -30,6 +30,15 @@ interface XaiTokenPayload {
|
|
|
30
30
|
token_type?: unknown;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface XaiOAuthFlowOptions {
|
|
34
|
+
extraAuthorizeParams?: Readonly<Record<string, string>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface XaiOAuthRefreshOptions {
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
extraTokenParams?: Readonly<Record<string, string>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
interface XaiJwtPayload {
|
|
34
43
|
sub?: unknown;
|
|
35
44
|
email?: unknown;
|
|
@@ -41,6 +50,28 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
|
41
50
|
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
function addNonOverridingParams(
|
|
54
|
+
target: URLSearchParams | Record<string, string>,
|
|
55
|
+
params: Readonly<Record<string, string>>,
|
|
56
|
+
): void {
|
|
57
|
+
for (const [key, value] of Object.entries(params)) {
|
|
58
|
+
if (key.length === 0 || value.length === 0) continue;
|
|
59
|
+
if (target instanceof URLSearchParams) {
|
|
60
|
+
if (!target.has(key)) target.set(key, value);
|
|
61
|
+
} else if (!(key in target)) {
|
|
62
|
+
target[key] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isAbortSignal(value: AbortSignal | XaiOAuthRefreshOptions | undefined): value is AbortSignal {
|
|
68
|
+
return value instanceof AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveRefreshOptions(options: AbortSignal | XaiOAuthRefreshOptions | undefined): XaiOAuthRefreshOptions {
|
|
72
|
+
return isAbortSignal(options) ? { signal: options } : (options ?? {});
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
function validateXaiEndpoint(rawUrl: string): string {
|
|
45
76
|
const parsed = new URL(rawUrl);
|
|
46
77
|
const host = parsed.hostname.toLowerCase();
|
|
@@ -136,8 +167,9 @@ function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback =
|
|
|
136
167
|
export class XaiOAuthFlow extends OAuthCallbackFlow {
|
|
137
168
|
#verifier = "";
|
|
138
169
|
#discovery: XaiDiscovery | undefined;
|
|
170
|
+
#extraAuthorizeParams: Readonly<Record<string, string>>;
|
|
139
171
|
|
|
140
|
-
constructor(ctrl: OAuthController) {
|
|
172
|
+
constructor(ctrl: OAuthController, options: XaiOAuthFlowOptions = {}) {
|
|
141
173
|
super(ctrl, {
|
|
142
174
|
preferredPort: XAI_OAUTH_CALLBACK_PORT,
|
|
143
175
|
callbackPath: XAI_OAUTH_CALLBACK_PATH,
|
|
@@ -145,6 +177,7 @@ export class XaiOAuthFlow extends OAuthCallbackFlow {
|
|
|
145
177
|
callbackBindHostname: "127.0.0.1",
|
|
146
178
|
redirectUri: `http://127.0.0.1:${XAI_OAUTH_CALLBACK_PORT}${XAI_OAUTH_CALLBACK_PATH}`,
|
|
147
179
|
} satisfies OAuthCallbackFlowOptions);
|
|
180
|
+
this.#extraAuthorizeParams = options.extraAuthorizeParams ?? {};
|
|
148
181
|
}
|
|
149
182
|
|
|
150
183
|
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
@@ -161,6 +194,7 @@ export class XaiOAuthFlow extends OAuthCallbackFlow {
|
|
|
161
194
|
state,
|
|
162
195
|
nonce: crypto.randomUUID(),
|
|
163
196
|
});
|
|
197
|
+
addNonOverridingParams(params, this.#extraAuthorizeParams);
|
|
164
198
|
return {
|
|
165
199
|
url: `${this.#discovery.authorizationEndpoint}?${params.toString()}`,
|
|
166
200
|
instructions:
|
|
@@ -188,23 +222,25 @@ export class XaiOAuthFlow extends OAuthCallbackFlow {
|
|
|
188
222
|
}
|
|
189
223
|
}
|
|
190
224
|
|
|
191
|
-
export async function loginXai(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
192
|
-
return new XaiOAuthFlow(ctrl).login();
|
|
225
|
+
export async function loginXai(ctrl: OAuthController, options?: XaiOAuthFlowOptions): Promise<OAuthCredentials> {
|
|
226
|
+
return new XaiOAuthFlow(ctrl, options).login();
|
|
193
227
|
}
|
|
194
228
|
|
|
195
|
-
export async function refreshXaiToken(
|
|
229
|
+
export async function refreshXaiToken(
|
|
230
|
+
refreshToken: string,
|
|
231
|
+
options?: AbortSignal | XaiOAuthRefreshOptions,
|
|
232
|
+
): Promise<OAuthCredentials> {
|
|
196
233
|
if (!refreshToken) {
|
|
197
234
|
throw new Error("xAI credentials are expired and do not include a refresh token");
|
|
198
235
|
}
|
|
236
|
+
const { signal, extraTokenParams = {} } = resolveRefreshOptions(options);
|
|
199
237
|
const discovery = await discoverXaiOAuthEndpoints(signal);
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
signal,
|
|
208
|
-
);
|
|
238
|
+
const body = {
|
|
239
|
+
grant_type: "refresh_token",
|
|
240
|
+
client_id: XAI_OAUTH_CLIENT_ID,
|
|
241
|
+
refresh_token: refreshToken,
|
|
242
|
+
};
|
|
243
|
+
addNonOverridingParams(body, extraTokenParams);
|
|
244
|
+
const tokenPayload = await postXaiToken(discovery.tokenEndpoint, body, signal);
|
|
209
245
|
return credentialsFromTokenPayload(tokenPayload, refreshToken);
|
|
210
246
|
}
|