@gajae-code/ai 0.14.2 → 0.15.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/types/auth-storage.d.ts +2 -2
  3. package/dist/types/model-cache.d.ts +2 -0
  4. package/dist/types/provider-models/special.d.ts +3 -1
  5. package/dist/types/providers/anthropic.d.ts +1 -0
  6. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  7. package/dist/types/providers/cursor/gen/agent_pb.d.ts +3854 -107
  8. package/dist/types/providers/cursor-pi-args.d.ts +119 -0
  9. package/dist/types/providers/cursor.d.ts +8 -1
  10. package/dist/types/providers/openai-codex-responses.d.ts +2 -0
  11. package/dist/types/providers/openai-responses-shared.d.ts +1 -1
  12. package/dist/types/types.d.ts +41 -1
  13. package/dist/types/utils/block-symbols.d.ts +6 -0
  14. package/dist/types/utils/discovery/openai-compatible.d.ts +2 -0
  15. package/dist/types/utils/idle-iterator.d.ts +5 -2
  16. package/dist/types/utils/oauth/kimi.d.ts +3 -9
  17. package/dist/types/utils/oauth/openrouter.d.ts +1 -0
  18. package/dist/types/utils/oauth/types.d.ts +1 -1
  19. package/package.json +4 -4
  20. package/src/auth-broker/remote-store.ts +13 -2
  21. package/src/auth-storage.ts +10 -11
  22. package/src/model-cache.ts +78 -0
  23. package/src/model-manager.ts +194 -25
  24. package/src/provider-models/special.ts +67 -4
  25. package/src/providers/anthropic.ts +115 -40
  26. package/src/providers/aws-credential-config.ts +2 -3
  27. package/src/providers/aws-credentials.ts +2 -3
  28. package/src/providers/azure-openai-responses.ts +18 -2
  29. package/src/providers/cursor/exec-modern.ts +497 -0
  30. package/src/providers/cursor/gen/agent_pb.ts +4687 -181
  31. package/src/providers/cursor/proto/agent.proto +1007 -0
  32. package/src/providers/cursor-pi-args.ts +187 -0
  33. package/src/providers/cursor.ts +382 -47
  34. package/src/providers/google-auth.ts +2 -3
  35. package/src/providers/openai-codex-responses.ts +358 -73
  36. package/src/providers/openai-completions.ts +2 -2
  37. package/src/providers/openai-responses-shared.ts +55 -6
  38. package/src/providers/openai-responses.ts +27 -4
  39. package/src/stream.ts +8 -3
  40. package/src/types.ts +55 -0
  41. package/src/utils/block-symbols.ts +11 -0
  42. package/src/utils/discovery/openai-compatible.ts +21 -6
  43. package/src/utils/idle-iterator.ts +22 -4
  44. package/src/utils/oauth/index.ts +6 -0
  45. package/src/utils/oauth/kimi.ts +14 -8
  46. package/src/utils/oauth/kiro.ts +2 -2
  47. package/src/utils/oauth/openrouter.ts +16 -0
  48. package/src/utils/oauth/types.ts +1 -0
@@ -525,7 +525,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
525
525
 
526
526
  try {
527
527
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
528
- const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.provider);
528
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.provider, model.id);
529
529
  const {
530
530
  client,
531
531
  copilotPremiumRequests,
@@ -1239,7 +1239,7 @@ async function createClient(
1239
1239
  // The OpenAI SDK's default is 10 minutes per attempt × `maxRetries`, which
1240
1240
  // turns a stalled-before-headers fetch into a multi-minute hang invisible
1241
1241
  // to the agent loop (the iterator watchdog only arms AFTER `create()` returns).
1242
- const sdkTimeoutMs = resolveOpenAISdkRequestTimeoutMs(model.provider, streamFirstEventTimeoutOverride);
1242
+ const sdkTimeoutMs = resolveOpenAISdkRequestTimeoutMs(model.provider, streamFirstEventTimeoutOverride, model.id);
1243
1243
  return {
1244
1244
  client: new OpenAI({
1245
1245
  apiKey,
@@ -327,13 +327,57 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
327
327
 
328
328
  export function appendResponsesToolResultMessages<TApi extends Api>(
329
329
  messages: ResponseInput,
330
- toolResult: ToolResultMessage,
330
+ toolResults: readonly ToolResultMessage[],
331
331
  model: Model<TApi>,
332
332
  strictResponsesPairing: boolean,
333
333
  knownCallIds: ReadonlySet<string>,
334
334
  customCallIds?: ReadonlySet<string>,
335
335
  ): void {
336
336
  const supportsImages = model.input.includes("image");
337
+ const imageParts: ResponseInputContent[] = [];
338
+
339
+ for (const toolResult of toolResults) {
340
+ appendResponsesToolResultOutput(
341
+ messages,
342
+ imageParts,
343
+ toolResult,
344
+ supportsImages,
345
+ strictResponsesPairing,
346
+ knownCallIds,
347
+ customCallIds,
348
+ );
349
+ }
350
+
351
+ if (imageParts.length === 0) {
352
+ return;
353
+ }
354
+
355
+ messages.push({ role: "user", content: imageParts });
356
+ }
357
+
358
+ /**
359
+ * Append the Responses items for one tool result of a batch (#4807).
360
+ *
361
+ * Emits the paired `function_call_output` / `custom_tool_call_output` in
362
+ * `messages` — keeping every output of the batch contiguous — and collects
363
+ * supported image blocks into `imageParts` instead of emitting a standalone
364
+ * user message per result. A per-result image user message interleaves with
365
+ * sibling outputs of the same assistant tool-call turn; once an OpenAI
366
+ * Responses → Anthropic Messages proxy groups consecutive outputs into the
367
+ * single user message carrying `tool_result` blocks, the interleaved image
368
+ * user message splits that group and leaves a `tool_use` without its
369
+ * immediately-following `tool_result`, which Anthropic rejects with a 400 on
370
+ * every replay of the poisoned tail.
371
+ */
372
+ function appendResponsesToolResultOutput(
373
+ messages: ResponseInput,
374
+ imageParts: ResponseInputContent[],
375
+ toolResult: ToolResultMessage,
376
+ supportsImages: boolean,
377
+ strictResponsesPairing: boolean,
378
+ knownCallIds: ReadonlySet<string>,
379
+ customCallIds?: ReadonlySet<string>,
380
+ ): void {
337
381
  const textResult = toolResult.content
338
382
  .filter((block): block is TextContent => block.type === "text")
339
383
  .map(block => block.text)
@@ -370,19 +414,24 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
370
414
  return;
371
415
  }
372
416
 
373
- const contentParts: ResponseInputContent[] = [
374
- { type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText,
375
- ];
417
+ if (imageParts.length === 0) {
418
+ imageParts.push({ type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText);
419
+ }
420
+ // Label each result's image group with its call id so parallel results keep
421
+ // image-to-call attribution inside the single collected user message (#4807).
422
+ imageParts.push({
423
+ type: "input_text",
424
+ text: `call_id=${normalized.callId}`,
425
+ } satisfies ResponseInputText);
376
426
  for (const block of toolResult.content) {
377
427
  if (block.type === "image") {
378
- contentParts.push({
428
+ imageParts.push({
379
429
  type: "input_image",
380
430
  detail: "auto",
381
431
  image_url: `data:${block.mimeType};base64,${block.data}`,
382
432
  } satisfies ResponseInputImage);
383
433
  }
384
434
  }
385
- messages.push({ role: "user", content: contentParts });
386
435
  }
387
436
 
388
437
  export interface ProcessResponsesStreamOptions {
@@ -22,6 +22,7 @@ import {
22
22
  type StreamOptions,
23
23
  type Tool,
24
24
  type ToolChoice,
25
+ type ToolResultMessage,
25
26
  } from "../types";
26
27
  import {
27
28
  createOpenAIResponsesHistoryPayload,
@@ -389,7 +390,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
389
390
  const premiumRequestsTotal = copilotPremiumRequests;
390
391
  const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
391
392
  const { params } = buildParams(model, context, options, providerSessionState, cacheRetention, baseUrl);
392
- const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.provider);
393
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.provider, model.id);
393
394
  options?.onPayload?.(params, undefined, options?.attemptScope);
394
395
  rawRequestDump = {
395
396
  provider: model.provider,
@@ -623,7 +624,7 @@ function createClient(
623
624
  );
624
625
  // Bound HTTP request timeout to the first-event window so a stalled-before-headers
625
626
  // fetch cannot wait the SDK's 10-minute default before the transport watchdog arms.
626
- const sdkTimeoutMs = resolveOpenAISdkRequestTimeoutMs(model.provider, streamFirstEventTimeoutOverride);
627
+ const sdkTimeoutMs = resolveOpenAISdkRequestTimeoutMs(model.provider, streamFirstEventTimeoutOverride, model.id);
627
628
  return {
628
629
  client: new OpenAI({
629
630
  apiKey,
@@ -800,7 +801,30 @@ function convertConversationMessages(
800
801
  const transformedMessages = transformMessages(context.messages, model, normalizeResponsesToolCallIdForTransform);
801
802
 
802
803
  let msgIndex = 0;
804
+ // Consecutive tool results are batched into one append call so every output
805
+ // of the turn stays contiguous before the collected image user message;
806
+ // per-result image user messages interleave with sibling outputs and break
807
+ // tool_use→tool_result adjacency through Anthropic-translating proxies (#4807).
808
+ let pendingToolResults: ToolResultMessage[] = [];
809
+ const flushPendingToolResults = (): void => {
810
+ if (pendingToolResults.length === 0) return;
811
+ appendResponsesToolResultMessages(
812
+ messages,
813
+ pendingToolResults,
814
+ model,
815
+ strictResponsesPairing,
816
+ knownCallIds,
817
+ customCallIds,
818
+ );
819
+ pendingToolResults = [];
820
+ };
803
821
  for (const msg of transformedMessages) {
822
+ if (msg.role === "toolResult") {
823
+ pendingToolResults.push(msg);
824
+ msgIndex++;
825
+ continue;
826
+ }
827
+ flushPendingToolResults();
804
828
  if (msg.role === "user" || msg.role === "developer") {
805
829
  const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
806
830
  const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
@@ -851,11 +875,10 @@ function convertConversationMessages(
851
875
  );
852
876
  if (outputItems.length === 0) continue;
853
877
  messages.push(...outputItems);
854
- } else if (msg.role === "toolResult") {
855
- appendResponsesToolResultMessages(messages, msg, model, strictResponsesPairing, knownCallIds, customCallIds);
856
878
  }
857
879
  msgIndex++;
858
880
  }
881
+ flushPendingToolResults();
859
882
 
860
883
  return repairOrphanResponsesToolOutputs(messages);
861
884
  }
package/src/stream.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
- import { $credentialEnv, $env, $pickCredentialEnv, extractHttpStatusFromError } from "@gajae-code/utils";
3
+ import {
4
+ $credentialEnv,
5
+ $env,
6
+ $pickCredentialEnv,
7
+ extractHttpStatusFromError,
8
+ getTrustedHomeDir,
9
+ } from "@gajae-code/utils";
5
10
  import { assertManagedAttempt, classifyFallbackTrigger, type TransportFailureFacts } from "./utils/fallback-transport";
6
11
 
7
12
  const managedAttemptValidated = Symbol("managedAttemptValidated");
@@ -77,7 +82,7 @@ function hasVertexAdcCredentials(): boolean {
77
82
  cachedVertexAdcCredentialsExists = fs.existsSync(gacPath);
78
83
  } else {
79
84
  cachedVertexAdcCredentialsExists = fs.existsSync(
80
- path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"),
85
+ path.join(getTrustedHomeDir(), ".config", "gcloud", "application_default_credentials.json"),
81
86
  );
82
87
  }
83
88
  }
package/src/types.ts CHANGED
@@ -13,6 +13,20 @@ import type {
13
13
  LsArgs,
14
14
  LsResult,
15
15
  McpResult,
16
+ PiBashExecArgs,
17
+ PiBashExecResult,
18
+ PiEditExecArgs,
19
+ PiEditExecResult,
20
+ PiFindExecArgs,
21
+ PiFindExecResult,
22
+ PiGrepExecArgs,
23
+ PiGrepExecResult,
24
+ PiLsExecArgs,
25
+ PiLsExecResult,
26
+ PiReadExecArgs,
27
+ PiReadExecResult,
28
+ PiWriteExecArgs,
29
+ PiWriteExecResult,
16
30
  ReadArgs,
17
31
  ReadResult,
18
32
  ShellArgs,
@@ -669,6 +683,26 @@ export interface Usage {
669
683
 
670
684
  export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
671
685
  export type AssistantErrorKind = "provider_safety_stop" | "local_snapshot_failure" | "local_buffer_overflow";
686
+ /**
687
+ * Structured, shape-only staging-buffer overflow diagnostic carried on the
688
+ * terminal `AssistantMessage`. Attached only by the agent runtime from its own
689
+ * identity-checked overflow error; every field is a closed vocabulary literal
690
+ * or a locally synthesized number.
691
+ */
692
+ export interface AssistantBufferOverflowDiagnostic {
693
+ /** Rejecting stage from the closed managed-local-failure vocabulary. */
694
+ stage: string;
695
+ /** Which provisional cap tripped. */
696
+ exceeded: "events" | "bytes" | "both";
697
+ /** Events retained in the batch at rejection (post-compaction). */
698
+ stagedEventCount: number;
699
+ /** Bytes retained in the batch at rejection (post-compaction). */
700
+ stagedBytes: number;
701
+ /** Serialized size of the event that was rejected. */
702
+ incomingEventBytes: number;
703
+ maxStagedEvents: number;
704
+ maxStagedBytes: number;
705
+ }
672
706
 
673
707
  export interface OpenAIResponsesHistoryPayload {
674
708
  type: "openaiResponsesHistory";
@@ -712,6 +746,15 @@ export interface AssistantMessage {
712
746
  stopReason: StopReason;
713
747
  errorMessage?: string;
714
748
  errorKind?: AssistantErrorKind;
749
+ /**
750
+ * Structured, shape-only diagnostic for a terminal local staging-buffer
751
+ * overflow (`errorKind: "local_buffer_overflow"`). Attached only by the
752
+ * agent runtime from its own identity-checked overflow error, so a
753
+ * foreign, self-labeled error cannot populate it. Every field is a closed
754
+ * vocabulary literal or a locally synthesized number — parent surfaces
755
+ * render this instead of trusting the free-form `errorMessage`.
756
+ */
757
+ bufferOverflow?: AssistantBufferOverflowDiagnostic;
715
758
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
716
759
  errorStatus?: number;
717
760
  /** Typed upstream failure facts retained for retry classification without parsing errorMessage. */
@@ -767,6 +810,11 @@ export interface CursorShellStreamCallbacks {
767
810
  onStderr(data: string): void;
768
811
  }
769
812
 
813
+ export interface CursorPiCall<TArgs> {
814
+ args: TArgs;
815
+ toolCallId: string;
816
+ }
817
+
770
818
  export interface CursorExecHandlers {
771
819
  read?: (args: ReadArgs) => Promise<CursorExecHandlerResult<ReadResult>>;
772
820
  ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
@@ -780,6 +828,13 @@ export interface CursorExecHandlers {
780
828
  ) => Promise<CursorExecHandlerResult<ShellResult>>;
781
829
  diagnostics?: (args: DiagnosticsArgs) => Promise<CursorExecHandlerResult<DiagnosticsResult>>;
782
830
  mcp?: (call: CursorMcpCall) => Promise<CursorExecHandlerResult<McpResult>>;
831
+ piRead?: (call: CursorPiCall<PiReadExecArgs>) => Promise<CursorExecHandlerResult<PiReadExecResult>>;
832
+ piBash?: (call: CursorPiCall<PiBashExecArgs>) => Promise<CursorExecHandlerResult<PiBashExecResult>>;
833
+ piEdit?: (call: CursorPiCall<PiEditExecArgs>) => Promise<CursorExecHandlerResult<PiEditExecResult>>;
834
+ piWrite?: (call: CursorPiCall<PiWriteExecArgs>) => Promise<CursorExecHandlerResult<PiWriteExecResult>>;
835
+ piGrep?: (call: CursorPiCall<PiGrepExecArgs>) => Promise<CursorExecHandlerResult<PiGrepExecResult>>;
836
+ piFind?: (call: CursorPiCall<PiFindExecArgs>) => Promise<CursorExecHandlerResult<PiFindExecResult>>;
837
+ piLs?: (call: CursorPiCall<PiLsExecArgs>) => Promise<CursorExecHandlerResult<PiLsExecResult>>;
783
838
  onToolResult?: CursorToolResultHandler;
784
839
  }
785
840
 
@@ -0,0 +1,11 @@
1
+ export const kCursorExecResolved = Symbol("provider.block.cursorExecResolved");
2
+
3
+ export type CursorExecResolvedCarrier = object & { [kCursorExecResolved]?: true };
4
+
5
+ export function isCursorExecResolved(block: CursorExecResolvedCarrier | null | undefined): boolean {
6
+ return block?.[kCursorExecResolved] === true;
7
+ }
8
+
9
+ export function copyCursorExecResolved(target: CursorExecResolvedCarrier, source: CursorExecResolvedCarrier): void {
10
+ if (source[kCursorExecResolved] === true) target[kCursorExecResolved] = true;
11
+ }
@@ -5,6 +5,17 @@ import { toNumber } from "../../utils";
5
5
 
6
6
  const MODELS_PATH = "/models";
7
7
  const MAX_MODELS_RESPONSE_BYTES = 1_000_000;
8
+ const MAX_CATALOG_MODEL_ID_LENGTH = 200;
9
+
10
+ /** Catalog identities are rendered and used for routing; unsafe values are dropped, never rewritten. */
11
+ export function isSafeCatalogModelId(value: unknown): value is string {
12
+ return (
13
+ typeof value === "string" &&
14
+ value.trim().length > 0 &&
15
+ value.length <= MAX_CATALOG_MODEL_ID_LENGTH &&
16
+ !/[\u0000-\u001f\u007f-\u009f]/u.test(value)
17
+ );
18
+ }
8
19
 
9
20
  /**
10
21
  * Minimal OpenAI-style model entry shape consumed by discovery.
@@ -186,6 +197,9 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
186
197
 
187
198
  const deduped = new Map<string, Model<TApi>>();
188
199
  for (const entry of entries) {
200
+ if (!isSafeCatalogModelId(entry.id)) {
201
+ continue;
202
+ }
189
203
  const rawContextWindow = firstPositiveModelNumber(
190
204
  UNK_CONTEXT_WINDOW,
191
205
  entry.max_model_len,
@@ -211,7 +225,7 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
211
225
  };
212
226
 
213
227
  const mapped = options.mapModel?.(entry, defaults, context) ?? defaults;
214
- if (!mapped || typeof mapped.id !== "string" || mapped.id.length === 0) {
228
+ if (!mapped || !isSafeCatalogModelId(mapped.id)) {
215
229
  continue;
216
230
  }
217
231
  if (options.filterModel && !options.filterModel(entry, mapped)) {
@@ -314,16 +328,17 @@ function extractModelEntriesFromNode(node: unknown): ParsedOpenAICompatibleModel
314
328
  }
315
329
 
316
330
  /**
317
- * First finite positive number among candidates, else the fallback.
331
+ * First positive safe integer among candidates, else the fallback.
318
332
  *
319
- * Rejects non-numbers, non-finite values (JSON `1e400` parses to
320
- * `Infinity`), zero, and negatives so a malformed catalog field can never
321
- * poison compaction thresholds or output budgets with `Infinity`.
333
+ * Rejects non-numbers, non-finite values (JSON `1e400` parses to `Infinity`),
334
+ * fractions, values outside the safe integer range, zero, and negatives so a
335
+ * malformed catalog field can never poison compaction thresholds or output
336
+ * budgets.
322
337
  */
323
338
  function firstPositiveModelNumber(fallback: number, ...candidates: readonly unknown[]): number {
324
339
  for (const candidate of candidates) {
325
340
  const value = toNumber(candidate);
326
- if (value !== undefined && value > 0 && Number.isFinite(value)) {
341
+ if (value !== undefined && Number.isSafeInteger(value) && value > 0) {
327
342
  return value;
328
343
  }
329
344
  }
@@ -15,6 +15,19 @@ export function getProviderStreamIdleTimeoutFallbackMs(provider: string): number
15
15
  return undefined;
16
16
  }
17
17
 
18
+ // Grok models behind OpenAI-compatible hosts keep the model id (or an
19
+ // `x-ai/`-prefixed OpenRouter id) even when `provider` is openrouter, kilo,
20
+ // litellm, zenmux, venice, and similar. The long-reasoning silence that
21
+ // motivates the 300s floor is a property of the model, not the account
22
+ // used to reach it, so the floor keys on the model when the provider alone
23
+ // does not already grant it (#4797).
24
+ const GROK_MODEL_ID_PATTERN = /(?:^|[/._-])grok(?:[/._-]|$)/i;
25
+
26
+ export function isGrokModelId(modelId: string | undefined): boolean {
27
+ if (!modelId) return false;
28
+ return GROK_MODEL_ID_PATTERN.test(modelId);
29
+ }
30
+
18
31
  export function getProviderFirstEventTimeoutFallbackMs(provider: string): number | undefined {
19
32
  if (provider === "alibaba-token-plan") return ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS;
20
33
  return provider === "kimi-code" ? KIMI_CODE_FIRST_EVENT_TIMEOUT_MS : undefined;
@@ -50,11 +63,15 @@ export function getStreamIdleTimeoutMs(fallbackMs: number = DEFAULT_STREAM_IDLE_
50
63
  *
51
64
  * Honors `GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS` first (`PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is the legacy alias). Set `=0` to disable.
52
65
  * When `provider` is given, long-reasoning hosts (xAI Grok and Grok Build) use that floor instead of the 120s default.
66
+ * Grok models reached through other OpenAI-compatible hosts (`openrouter/x-ai/grok-*`, kilo, litellm, …) get the
67
+ * same floor keyed on the model id, because long-reasoning silence is a property of the model (#4797).
53
68
  */
54
- export function getOpenAIStreamIdleTimeoutMs(provider?: string): number | undefined {
69
+ export function getOpenAIStreamIdleTimeoutMs(provider?: string, modelId?: string): number | undefined {
55
70
  return normalizeIdleTimeoutMs(
56
- $env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
57
- getProviderStreamIdleTimeoutFallbackMs(provider ?? "") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS,
71
+ $env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS,
72
+ getProviderStreamIdleTimeoutFallbackMs(provider ?? "") ??
73
+ (isGrokModelId(modelId) ? ANTHROPIC_STREAM_IDLE_TIMEOUT_MS : undefined) ??
74
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
58
75
  );
59
76
  }
60
77
 
@@ -97,10 +114,11 @@ export function getStreamFirstEventTimeoutMs(
97
114
  export function resolveOpenAISdkRequestTimeoutMs(
98
115
  provider: string,
99
116
  streamFirstEventTimeoutOverride?: number,
117
+ modelId?: string,
100
118
  ): number | undefined {
101
119
  const providerFirstEventFallbackMs = getProviderFirstEventTimeoutFallbackMs(provider);
102
120
  const envSdkTimeoutMs = getStreamFirstEventTimeoutMs(
103
- getOpenAIStreamIdleTimeoutMs(provider),
121
+ getOpenAIStreamIdleTimeoutMs(provider, modelId),
104
122
  providerFirstEventFallbackMs,
105
123
  );
106
124
  if (streamFirstEventTimeoutOverride === 0) return undefined;
@@ -180,6 +180,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
180
180
  name: "OpenCode Go",
181
181
  available: true,
182
182
  },
183
+ {
184
+ id: "openrouter",
185
+ name: "OpenRouter",
186
+ available: true,
187
+ },
183
188
  {
184
189
  id: "zai",
185
190
  name: "Z.AI (GLM Coding Plan)",
@@ -408,6 +413,7 @@ export async function refreshOAuthToken(
408
413
  case "zai":
409
414
  case "qianfan":
410
415
  case "venice":
416
+ case "openrouter":
411
417
  case "minimax-code":
412
418
  case "minimax-code-cn":
413
419
  case "moonshot":
@@ -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 { $pickCredentialEnv, getAgentDir, isEnoent } from "@gajae-code/utils";
10
+ import { $pickCredentialEnv, getAgentDir, isEnoent, sanitizeHeaderComponent } from "@gajae-code/utils";
11
11
  import packageJson from "../../../package.json" with { type: "json" };
12
12
  import type { OAuthController, OAuthCredentials } from "./types";
13
13
 
@@ -90,18 +90,24 @@ let getDeviceId = (): string => {
90
90
  return deviceId;
91
91
  };
92
92
 
93
- export let getKimiCommonHeaders = () => {
94
- const headers = Object.freeze({
93
+ /** @internal Exported for tests. Builds unsanitized-input-safe Kimi common headers. */
94
+ export function buildKimiCommonHeaders(): Readonly<Record<string, string>> {
95
+ return Object.freeze({
95
96
  "User-Agent": `KimiCLI/${packageJson.version}`,
96
97
  "X-Msh-Platform": "kimi_cli",
97
98
  "X-Msh-Version": packageJson.version,
98
- "X-Msh-Device-Name": os.hostname(),
99
- "X-Msh-Device-Model": getDeviceModel(),
100
- "X-Msh-Os-Version": os.version(),
99
+ "X-Msh-Device-Name": sanitizeHeaderComponent(os.hostname()),
100
+ "X-Msh-Device-Model": sanitizeHeaderComponent(getDeviceModel()),
101
+ "X-Msh-Os-Version": sanitizeHeaderComponent(os.version()),
101
102
  "X-Msh-Device-Id": getDeviceId(),
102
103
  });
103
- getKimiCommonHeaders = () => headers;
104
- return headers;
104
+ }
105
+
106
+ let memoizedKimiCommonHeaders: Readonly<Record<string, string>> | undefined;
107
+
108
+ export const getKimiCommonHeaders = () => {
109
+ memoizedKimiCommonHeaders ??= buildKimiCommonHeaders();
110
+ return memoizedKimiCommonHeaders;
105
111
  };
106
112
 
107
113
  async function requestDeviceAuthorization(): Promise<{
@@ -382,6 +382,7 @@ export async function loginKiro(options: KiroLoginOptions): Promise<OAuthCredent
382
382
 
383
383
  import * as fs from "node:fs";
384
384
  import * as path from "node:path";
385
+ import { getTrustedHomeDir } from "@gajae-code/utils";
385
386
 
386
387
  interface SsoCachedAccessToken {
387
388
  accessToken: string;
@@ -398,8 +399,7 @@ interface SsoCachedAccessToken {
398
399
  * credential store.
399
400
  */
400
401
  export function importSsoCacheToken(): OAuthCredentials | undefined {
401
- const homeDir = process.env.HOME ?? process.env.USERPROFILE;
402
- if (!homeDir) return undefined;
402
+ const homeDir = getTrustedHomeDir();
403
403
  const cacheDir = path.join(homeDir, ".aws", "sso", "cache");
404
404
 
405
405
  let files: string[];
@@ -0,0 +1,16 @@
1
+ /** OpenRouter login flow (API key paste against https://openrouter.ai/api/v1). */
2
+ import { createApiKeyLogin } from "./api-key-login";
3
+
4
+ export const loginOpenRouter = createApiKeyLogin({
5
+ providerLabel: "OpenRouter",
6
+ authUrl: "https://openrouter.ai/keys",
7
+ instructions: "Copy your API key from the OpenRouter dashboard",
8
+ promptMessage: "Paste your OpenRouter API key",
9
+ placeholder: "sk-or-v1-...",
10
+ validation: {
11
+ kind: "chat-completions",
12
+ provider: "OpenRouter",
13
+ baseUrl: "https://openrouter.ai/api/v1",
14
+ model: "openrouter/auto",
15
+ },
16
+ });
@@ -44,6 +44,7 @@ export type OAuthProvider =
44
44
  | "opencode-go"
45
45
  | "opencode-zen"
46
46
  | "opengateway"
47
+ | "openrouter"
47
48
  | "parallel"
48
49
  | "perplexity"
49
50
  | "qianfan"