@oh-my-pi/pi-ai 17.0.3 → 17.0.5

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.
@@ -1,3 +1,4 @@
1
+ import { scheduler } from "node:timers/promises";
1
2
  import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
2
3
  import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
3
4
  import * as AIError from "../error";
@@ -38,6 +39,7 @@ import {
38
39
  adaptSchemaForStrict,
39
40
  findStrictToolSchemaViolation,
40
41
  NO_STRICT,
42
+ normalizeSchemaForMoonshot,
41
43
  sanitizeSchemaForOpenAIResponses,
42
44
  toolWireSchema,
43
45
  } from "../utils/schema";
@@ -154,6 +156,33 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
154
156
  "OpenAI responses stream timed out while waiting for the first event";
155
157
  /** Consecutive stale-previous-response failures before chaining is disabled for the session. */
156
158
  const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
159
+ const OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES = 1;
160
+ const OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS = 500;
161
+
162
+ function isOpenAIResponsesReplayUnsafeEvent(event: ResponseStreamEvent): boolean {
163
+ switch (event.type) {
164
+ case "response.output_text.delta":
165
+ case "response.refusal.delta":
166
+ case "response.reasoning_summary_text.delta":
167
+ case "response.reasoning_text.delta":
168
+ case "response.function_call_arguments.delta":
169
+ case "response.custom_tool_call_input.delta":
170
+ return typeof event.delta === "string" && event.delta.length > 0;
171
+ case "response.reasoning_summary_part.done":
172
+ return true;
173
+ case "response.output_item.done":
174
+ return true;
175
+ default:
176
+ return false;
177
+ }
178
+ }
179
+
180
+ function isRetryableOpenAIResponsesStreamFailure(error: unknown): boolean {
181
+ return (
182
+ AIError.isTransientStreamParseError(error) ||
183
+ (error instanceof AIError.ProviderResponseError && error.kind === "incomplete-stream")
184
+ );
185
+ }
157
186
 
158
187
  interface OpenAIResponsesProviderSessionState
159
188
  extends ProviderSessionState,
@@ -452,7 +481,7 @@ const streamOpenAIResponsesOnce = (
452
481
  return payload;
453
482
  };
454
483
  chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
455
- rawRequestDump = {
484
+ const activeRawRequestDump: RawHttpRequestDump = {
456
485
  provider: model.provider,
457
486
  api: output.api,
458
487
  model: model.id,
@@ -460,6 +489,7 @@ const streamOpenAIResponsesOnce = (
460
489
  url: requestUrl,
461
490
  body: chained.params,
462
491
  };
492
+ rawRequestDump = activeRawRequestDump;
463
493
  const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
464
494
  activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
465
495
  "responses",
@@ -507,187 +537,257 @@ const streamOpenAIResponsesOnce = (
507
537
  { provider: model.provider, signal: requestSignal },
508
538
  );
509
539
  };
510
- let openaiStream: AsyncIterable<ResponseStreamEvent>;
511
540
  let strictRetryAvailable = true;
512
541
  let activeStrictToolsApplied = builtParams.strictToolsApplied;
513
542
  let forceDisableStrictTools = false;
514
- while (true) {
515
- try {
516
- openaiStream = await openResponsesStream(chained.params);
517
- if (pendingReasoningEffortFallback) {
518
- rememberOpenAIReasoningEffortFallback(
519
- providerSessionState,
520
- pendingReasoningEffortFallback.key,
521
- pendingReasoningEffortFallback.fallback,
522
- );
523
- pendingReasoningEffortFallback = undefined;
524
- }
525
- break;
526
- } catch (error) {
527
- const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
528
- const reasoningEffortFallback =
529
- activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
530
- ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
531
- explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
532
- })
533
- : undefined;
534
- if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
535
- const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
536
- if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
537
- attemptedReasoningEffortFallbacks.add(retryMarker);
538
- requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
539
- applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
540
- applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
541
- rawRequestDump.body = chained.params;
542
- pendingReasoningEffortFallback = {
543
- key: activeReasoningEffortFallbackKey,
544
- fallback: reasoningEffortFallback,
545
- };
546
- continue;
547
- }
548
- const compiledGrammarTooLarge =
549
- isOpenRouterAnthropicModel(model) &&
550
- isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
551
- const canRetryWithoutStrictTools =
552
- strictRetryAvailable &&
553
- !requestSignal.aborted &&
554
- (compiledGrammarTooLarge ||
555
- shouldRetryWithoutStrictTools(
556
- error,
557
- capturedErrorResponse,
558
- activeStrictToolsApplied,
559
- context.tools,
560
- ));
561
- if (canRetryWithoutStrictTools) {
562
- strictRetryAvailable = false;
563
- forceDisableStrictTools = true;
564
- disableStrictToolsForScope(providerSessionState, strictToolsScope);
565
- const fallbackBuilt = buildParams(
543
+ const openResponsesStreamWithFallbacks = async (): Promise<AsyncIterable<ResponseStreamEvent>> => {
544
+ let openaiStream: AsyncIterable<ResponseStreamEvent>;
545
+ while (true) {
546
+ try {
547
+ openaiStream = await openResponsesStream(chained.params);
548
+ if (pendingReasoningEffortFallback) {
549
+ rememberOpenAIReasoningEffortFallback(
550
+ providerSessionState,
551
+ pendingReasoningEffortFallback.key,
552
+ pendingReasoningEffortFallback.fallback,
553
+ );
554
+ pendingReasoningEffortFallback = undefined;
555
+ }
556
+ break;
557
+ } catch (error) {
558
+ const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
559
+ const reasoningEffortFallback =
560
+ activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
561
+ ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
562
+ explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
563
+ })
564
+ : undefined;
565
+ if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
566
+ const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
567
+ if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
568
+ attemptedReasoningEffortFallbacks.add(retryMarker);
569
+ requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
570
+ applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
571
+ applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
572
+ activeRawRequestDump.body = chained.params;
573
+ pendingReasoningEffortFallback = {
574
+ key: activeReasoningEffortFallbackKey,
575
+ fallback: reasoningEffortFallback,
576
+ };
577
+ continue;
578
+ }
579
+ const compiledGrammarTooLarge =
580
+ isOpenRouterAnthropicModel(model) &&
581
+ isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
582
+ const canRetryWithoutStrictTools =
583
+ strictRetryAvailable &&
584
+ !requestSignal.aborted &&
585
+ (compiledGrammarTooLarge ||
586
+ shouldRetryWithoutStrictTools(
587
+ error,
588
+ capturedErrorResponse,
589
+ activeStrictToolsApplied,
590
+ context.tools,
591
+ ));
592
+ if (canRetryWithoutStrictTools) {
593
+ strictRetryAvailable = false;
594
+ forceDisableStrictTools = true;
595
+ disableStrictToolsForScope(providerSessionState, strictToolsScope);
596
+ const fallbackBuilt = buildParams(
597
+ model,
598
+ context,
599
+ options,
600
+ providerSessionState,
601
+ strictToolsScope,
602
+ true,
603
+ );
604
+ const fallbackParams = fallbackBuilt.params;
605
+ if (chainState && !chainState.disabled) fallbackParams.store = true;
606
+ let fallbackChained: OpenAIResponsesChainedParams =
607
+ chainState && !chainState.disabled
608
+ ? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
609
+ : { params: fallbackParams };
610
+ sentPreviousResponseId = fallbackChained.previousResponseId;
611
+ fallbackChained = {
612
+ ...fallbackChained,
613
+ params: await applyPayloadReplacement(fallbackChained.params),
614
+ };
615
+ chained = fallbackChained;
616
+ activeRawRequestDump.body = chained.params;
617
+ activeParams = fallbackParams;
618
+ activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
619
+ continue;
620
+ }
621
+ if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
622
+ throw error;
623
+ }
624
+ const zdrRejection =
625
+ error instanceof Error &&
626
+ /previous[ _]?response/i.test(error.message) &&
627
+ /zero[ _-]?data[ _-]?retention/i.test(error.message);
628
+ const isPromptBlocked =
629
+ error instanceof Error &&
630
+ ((error as { code?: string }).code === "invalid_prompt" ||
631
+ /invalid_prompt|Request blocked/i.test(error.message));
632
+ if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
633
+ throw error;
634
+ }
635
+ // Server rejected the chain baseline: reset, count the failure (or
636
+ // disable categorically on ZDR), and retry once with the full
637
+ // transcript. Structurally cannot loop — the retry carries no
638
+ // previous_response_id.
639
+ if (zdrRejection) {
640
+ markOpenAIResponsesChainZeroDataRetention(chainState, error);
641
+ // ZDR orgs cannot store responses; the retry uses `store: false`.
642
+ } else {
643
+ registerOpenAIResponsesChainStaleFailure(chainState, error);
644
+ }
645
+ sentPreviousResponseId = undefined;
646
+ const currentBuilt = buildParams(
566
647
  model,
567
648
  context,
568
649
  options,
569
650
  providerSessionState,
570
651
  strictToolsScope,
571
- true,
652
+ forceDisableStrictTools,
572
653
  );
573
- const fallbackParams = fallbackBuilt.params;
574
- if (chainState && !chainState.disabled) fallbackParams.store = true;
575
- let fallbackChained: OpenAIResponsesChainedParams =
576
- chainState && !chainState.disabled
577
- ? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
578
- : { params: fallbackParams };
579
- sentPreviousResponseId = fallbackChained.previousResponseId;
580
- fallbackChained = {
581
- ...fallbackChained,
582
- params: await applyPayloadReplacement(fallbackChained.params),
583
- };
584
- chained = fallbackChained;
585
- rawRequestDump.body = chained.params;
586
- activeParams = fallbackParams;
587
- activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
588
- continue;
589
- }
590
- if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
591
- throw error;
654
+ const currentParams = currentBuilt.params;
655
+ // Only ZDR forces `store: false` (the org never persists responses). A
656
+ // non-ZDR stale baseline is transient, so keep storing: the full-context
657
+ // retry must be chainable next turn, and the consecutive stale-failure
658
+ // breaker only trips when each retry stores and the next turn re-chains.
659
+ currentParams.store = !zdrRejection;
660
+ const retryParams = await applyPayloadReplacement(currentParams);
661
+ chained = { params: retryParams };
662
+ activeRawRequestDump.body = retryParams;
663
+ activeParams = currentParams;
664
+ activeStrictToolsApplied = currentBuilt.strictToolsApplied;
592
665
  }
593
- const zdrRejection =
594
- error instanceof Error &&
595
- /previous[ _]?response/i.test(error.message) &&
596
- /zero[ _-]?data[ _-]?retention/i.test(error.message);
597
- const isPromptBlocked =
598
- error instanceof Error &&
599
- ((error as { code?: string }).code === "invalid_prompt" ||
600
- /invalid_prompt|Request blocked/i.test(error.message));
601
- if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
602
- throw error;
603
- }
604
- // Server rejected the chain baseline: reset, count the failure (or
605
- // disable categorically on ZDR), and retry once with the full
606
- // transcript. Structurally cannot loop — the retry carries no
607
- // previous_response_id.
608
- if (zdrRejection) {
609
- markOpenAIResponsesChainZeroDataRetention(chainState, error);
610
- // ZDR orgs cannot store responses; the retry uses `store: false`.
611
- } else {
612
- registerOpenAIResponsesChainStaleFailure(chainState, error);
613
- }
614
- sentPreviousResponseId = undefined;
615
- const currentBuilt = buildParams(
616
- model,
617
- context,
618
- options,
619
- providerSessionState,
620
- strictToolsScope,
621
- forceDisableStrictTools,
622
- );
623
- const currentParams = currentBuilt.params;
624
- // Only ZDR forces `store: false` (the org never persists responses). A
625
- // non-ZDR stale baseline is transient, so keep storing: the full-context
626
- // retry must be chainable next turn, and the consecutive stale-failure
627
- // breaker only trips when each retry stores and the next turn re-chains.
628
- currentParams.store = !zdrRejection;
629
- const retryParams = await applyPayloadReplacement(currentParams);
630
- chained = { params: retryParams };
631
- rawRequestDump.body = retryParams;
632
- activeParams = currentParams;
633
- activeStrictToolsApplied = currentBuilt.strictToolsApplied;
634
666
  }
635
- }
667
+ return openaiStream;
668
+ };
669
+ let openaiStream = await openResponsesStreamWithFallbacks();
636
670
  if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
637
671
  stream.push({ type: "start", partial: output });
638
672
 
639
673
  const nativeOutputItems: Array<Record<string, unknown>> = [];
640
- let sawTerminalResponseEvent = false;
641
- const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
642
- idleTimeoutMs,
643
- firstItemTimeoutMs: firstEventTimeoutMs,
644
- firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
645
- errorMessage: "OpenAI responses stream stalled while waiting for the next event",
646
- onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
647
- onIdle: () => requestAbortController.abort(),
648
- abortSignal: options?.signal,
649
- isProgressItem: isOpenAIResponsesProgressEvent,
650
- });
651
- await processResponsesStream(timedOpenaiStream, output, stream, model, {
652
- onFirstToken: () => {
653
- if (!firstTokenTime) firstTokenTime = performance.now();
654
- },
655
- onOutputItemDone: item => {
656
- // `processResponsesStream` hands over a private clone already; no
657
- // second deep copy needed (reasoning items carry multi-KB blobs).
658
- nativeOutputItems.push(item as unknown as Record<string, unknown>);
659
- },
660
- onCompleted: () => {
661
- sawTerminalResponseEvent = true;
662
- },
663
- requestServiceTier: options?.serviceTier,
664
- });
674
+ let transientStreamRetryAttempt = 0;
675
+ while (true) {
676
+ let sawReplayUnsafeOutput = false;
677
+ let sawTerminalResponseEvent = false;
678
+ const attemptStream = new AssistantMessageEventStream();
679
+ let forwardAttemptLive = false;
680
+ const forwardAttemptEvents = () => {
681
+ for (const event of attemptStream.queue) stream.push(event);
682
+ attemptStream.queue.length = 0;
683
+ };
684
+ nativeOutputItems.length = 0;
685
+ const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
686
+ idleTimeoutMs,
687
+ firstItemTimeoutMs: firstEventTimeoutMs,
688
+ firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
689
+ errorMessage: "OpenAI responses stream stalled while waiting for the next event",
690
+ onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
691
+ onIdle: () => requestAbortController.abort(),
692
+ abortSignal: options?.signal,
693
+ isProgressItem: isOpenAIResponsesProgressEvent,
694
+ });
695
+ const observedOpenaiStream = (async function* (): AsyncGenerator<ResponseStreamEvent> {
696
+ for await (const event of timedOpenaiStream) {
697
+ if (isOpenAIResponsesReplayUnsafeEvent(event)) {
698
+ sawReplayUnsafeOutput = true;
699
+ if (!forwardAttemptLive) {
700
+ forwardAttemptEvents();
701
+ forwardAttemptLive = true;
702
+ }
703
+ }
704
+ yield event;
705
+ if (forwardAttemptLive) forwardAttemptEvents();
706
+ }
707
+ })();
665
708
 
666
- const localAbortReason = abortTracker.getLocalAbortReason();
667
- if (localAbortReason) {
668
- throw localAbortReason;
669
- }
670
- if (abortTracker.wasCallerAbort()) {
671
- throw new AIError.AbortError();
672
- }
709
+ try {
710
+ await processResponsesStream(observedOpenaiStream, output, attemptStream, model, {
711
+ onFirstToken: () => {
712
+ if (!firstTokenTime) firstTokenTime = performance.now();
713
+ },
714
+ onOutputItemDone: item => {
715
+ // `processResponsesStream` hands over a private clone already; no
716
+ // second deep copy needed (reasoning items carry multi-KB blobs).
717
+ nativeOutputItems.push(item as unknown as Record<string, unknown>);
718
+ },
719
+ onCompleted: () => {
720
+ sawTerminalResponseEvent = true;
721
+ },
722
+ requestServiceTier: options?.serviceTier,
723
+ });
673
724
 
674
- // Detect premature stream closure: the HTTP stream ended without the
675
- // provider sending a recognized terminal response event.
676
- // Custom/proxy providers may drop the connection mid-stream; without
677
- // this guard the incomplete output is silently surfaced as a successful
678
- // "stop".
679
- if (!sawTerminalResponseEvent) {
680
- throw new AIError.ProviderResponseError(
681
- "OpenAI responses stream closed before a terminal response event was received",
682
- { provider: model.provider, kind: "incomplete-stream" },
683
- );
684
- }
725
+ const localAbortReason = abortTracker.getLocalAbortReason();
726
+ if (localAbortReason) throw localAbortReason;
727
+ if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
685
728
 
686
- if (output.stopReason === "aborted" || output.stopReason === "error") {
687
- throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
688
- provider: model.provider,
689
- kind: "runtime",
690
- });
729
+ // Detect premature stream closure: the HTTP stream ended without the
730
+ // provider sending a recognized terminal response event.
731
+ if (!sawTerminalResponseEvent) {
732
+ throw new AIError.ProviderResponseError(
733
+ "OpenAI responses stream closed before a terminal response event was received",
734
+ { provider: model.provider, kind: "incomplete-stream" },
735
+ );
736
+ }
737
+
738
+ if (output.stopReason === "aborted" || output.stopReason === "error") {
739
+ throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
740
+ provider: model.provider,
741
+ kind: "runtime",
742
+ });
743
+ }
744
+ forwardAttemptEvents();
745
+ break;
746
+ } catch (error) {
747
+ const streamFailure = abortTracker.getLocalAbortReason() ?? error;
748
+ const canRetry =
749
+ !sawReplayUnsafeOutput &&
750
+ !requestSignal.aborted &&
751
+ !abortTracker.wasCallerAbort() &&
752
+ transientStreamRetryAttempt < OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES &&
753
+ isRetryableOpenAIResponsesStreamFailure(streamFailure);
754
+ if (!canRetry) {
755
+ forwardAttemptEvents();
756
+ throw streamFailure;
757
+ }
758
+
759
+ transientStreamRetryAttempt++;
760
+ logger.debug("OpenAI responses stream ended before replay-unsafe output; retrying", {
761
+ provider: model.provider,
762
+ model: model.id,
763
+ attempt: transientStreamRetryAttempt,
764
+ error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
765
+ });
766
+ const retryOutput = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
767
+ output.content.length = 0;
768
+ output.responseId = undefined;
769
+ output.upstreamProvider = undefined;
770
+ output.errorMessage = undefined;
771
+ output.errorStatus = undefined;
772
+ output.errorId = undefined;
773
+ output.stopDetails = undefined;
774
+ output.providerPayload = undefined;
775
+ output.usage = retryOutput.usage;
776
+ if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
777
+ output.stopReason = "stop";
778
+ output.duration = undefined;
779
+ output.ttft = undefined;
780
+ firstTokenTime = undefined;
781
+ nativeOutputItems.length = 0;
782
+
783
+ if (options?.providerRetryWait) {
784
+ await options.providerRetryWait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, options.signal);
785
+ } else {
786
+ await scheduler.wait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, { signal: options?.signal });
787
+ }
788
+ if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
789
+ openaiStream = await openResponsesStreamWithFallbacks();
790
+ }
691
791
  }
692
792
 
693
793
  output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
@@ -999,7 +1099,15 @@ export function convertTools(
999
1099
  }
1000
1100
  const strict = !NO_STRICT && strictMode && tool.strict !== false;
1001
1101
  const baseParameters = toolWireSchema(tool);
1002
- const responseParameters = sanitizeSchemaForOpenAIResponses(baseParameters);
1102
+ // MFJS must run AFTER the Responses sanitizer: the sanitizer normalizes
1103
+ // `{}` → `true` (issue #1179), and Moonshot's validator rejects boolean
1104
+ // subschemas ("property schema … must be an object"), so the Moonshot
1105
+ // pass re-coerces them last.
1106
+ const sanitized = sanitizeSchemaForOpenAIResponses(baseParameters);
1107
+ const responseParameters =
1108
+ model.compat.toolSchemaFlavor === "moonshot-mfjs"
1109
+ ? (normalizeSchemaForMoonshot(sanitized) as Record<string, unknown>)
1110
+ : sanitized;
1003
1111
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
1004
1112
  // Quarantine a tool whose emitted schema carries a provider-rejecting
1005
1113
  // enum/const-vs-type contradiction: dropping just that tool keeps the rest
@@ -31,7 +31,7 @@ type AnthropicMessagesValidation = {
31
31
  type ModelsEndpointValidation = {
32
32
  kind: "models-endpoint";
33
33
  provider: string;
34
- modelsUrl: string;
34
+ modelsUrl: string | (() => string);
35
35
  headers?: Record<string, string> | (() => Record<string, string> | undefined);
36
36
  };
37
37
 
@@ -99,7 +99,10 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
99
99
  await validateApiKeyAgainstModelsEndpoint({
100
100
  provider: config.validation.provider,
101
101
  apiKey: trimmed,
102
- modelsUrl: config.validation.modelsUrl,
102
+ modelsUrl:
103
+ typeof config.validation.modelsUrl === "function"
104
+ ? config.validation.modelsUrl()
105
+ : config.validation.modelsUrl,
103
106
  headers: config.validation.headers,
104
107
  signal: options.signal,
105
108
  fetch: options.fetch,
@@ -1,7 +1,13 @@
1
+ import { $env } from "@oh-my-pi/pi-utils";
1
2
  import { createApiKeyLogin } from "./api-key-login";
2
3
  import type { OAuthLoginCallbacks } from "./oauth/types";
3
4
  import type { ProviderDefinition } from "./types";
4
5
 
6
+ function resolveMoonshotModelsUrl(): string {
7
+ const baseUrl = $env.MOONSHOT_BASE_URL?.trim() || "https://api.moonshot.ai/v1";
8
+ return `${baseUrl.replace(/\/+$/, "")}/models`;
9
+ }
10
+
5
11
  export const loginMoonshot = createApiKeyLogin({
6
12
  providerLabel: "Moonshot",
7
13
  authUrl: "https://platform.moonshot.ai/console/api-keys",
@@ -11,7 +17,7 @@ export const loginMoonshot = createApiKeyLogin({
11
17
  validation: {
12
18
  kind: "models-endpoint",
13
19
  provider: "moonshot",
14
- modelsUrl: "https://api.moonshot.ai/v1/models",
20
+ modelsUrl: resolveMoonshotModelsUrl,
15
21
  },
16
22
  });
17
23
 
@@ -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 { $env, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
10
+ import { $env, getAgentDir } from "@oh-my-pi/pi-utils";
11
11
  import packageJson from "../../../package.json" with { type: "json" };
12
12
  import * as AIError from "../../error";
13
13
  import type { OAuthController, OAuthCredentials } from "./types";
@@ -57,21 +57,29 @@ function getDeviceModel(): string {
57
57
  return formatDeviceModel(label, release, arch);
58
58
  }
59
59
 
60
+ // Device id identifies this install to Kimi. Persistence is best-effort: a
61
+ // missing/unwritable agent dir must never break header construction (and with
62
+ // it every usage probe / request that spreads getKimiCommonHeaders()) — fall
63
+ // back to a per-process ephemeral id instead.
60
64
  let getDeviceId = (): string => {
61
65
  const deviceIdPath = path.join(getAgentDir(), DEVICE_ID_FILENAME);
62
66
  try {
63
- const existing = fs.readFileSync(deviceIdPath, "utf-8");
64
- const trimmed = existing.trim();
65
- if (trimmed) {
66
- getDeviceId = () => trimmed;
67
- return trimmed;
67
+ const existing = fs.readFileSync(deviceIdPath, "utf-8").trim();
68
+ if (existing) {
69
+ getDeviceId = () => existing;
70
+ return existing;
68
71
  }
69
- } catch (error) {
70
- if (!isEnoent(error)) throw error;
72
+ } catch {
73
+ // Unreadable device-id file: regenerate below.
71
74
  }
72
75
 
73
76
  const deviceId = crypto.randomUUID().replace(/-/g, "");
74
- fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
77
+ try {
78
+ fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
79
+ fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
80
+ } catch {
81
+ // Persist failure → ephemeral id for this process.
82
+ }
75
83
  getDeviceId = () => deviceId;
76
84
  return deviceId;
77
85
  };
package/src/usage/kimi.ts CHANGED
@@ -144,15 +144,21 @@ function buildUsageStatus(amount: UsageAmount): UsageStatus {
144
144
  }
145
145
 
146
146
  function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit {
147
- const window: UsageWindow | undefined =
148
- row.window ??
149
- (row.resetsAt
147
+ // Kimi puts `resetTime` on the limit `detail`, not on `window`, so a
148
+ // window built from `duration`/`timeUnit` alone carries no resetsAt.
149
+ // Fall back to the row-level reset so `omp usage` can render
150
+ // "resets in …" for the 5h window too.
151
+ const window: UsageWindow | undefined = row.window
152
+ ? row.window.resetsAt !== undefined || row.resetsAt === undefined
153
+ ? row.window
154
+ : { ...row.window, resetsAt: row.resetsAt }
155
+ : row.resetsAt
150
156
  ? {
151
157
  id: "default",
152
158
  label: "Usage window",
153
159
  resetsAt: row.resetsAt,
154
160
  }
155
- : undefined);
161
+ : undefined;
156
162
 
157
163
  const amount = buildUsageAmount(row);
158
164
  return {