@oh-my-pi/pi-ai 17.0.4 → 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";
@@ -155,6 +156,33 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
155
156
  "OpenAI responses stream timed out while waiting for the first event";
156
157
  /** Consecutive stale-previous-response failures before chaining is disabled for the session. */
157
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
+ }
158
186
 
159
187
  interface OpenAIResponsesProviderSessionState
160
188
  extends ProviderSessionState,
@@ -453,7 +481,7 @@ const streamOpenAIResponsesOnce = (
453
481
  return payload;
454
482
  };
455
483
  chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
456
- rawRequestDump = {
484
+ const activeRawRequestDump: RawHttpRequestDump = {
457
485
  provider: model.provider,
458
486
  api: output.api,
459
487
  model: model.id,
@@ -461,6 +489,7 @@ const streamOpenAIResponsesOnce = (
461
489
  url: requestUrl,
462
490
  body: chained.params,
463
491
  };
492
+ rawRequestDump = activeRawRequestDump;
464
493
  const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
465
494
  activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
466
495
  "responses",
@@ -508,187 +537,257 @@ const streamOpenAIResponsesOnce = (
508
537
  { provider: model.provider, signal: requestSignal },
509
538
  );
510
539
  };
511
- let openaiStream: AsyncIterable<ResponseStreamEvent>;
512
540
  let strictRetryAvailable = true;
513
541
  let activeStrictToolsApplied = builtParams.strictToolsApplied;
514
542
  let forceDisableStrictTools = false;
515
- while (true) {
516
- try {
517
- openaiStream = await openResponsesStream(chained.params);
518
- if (pendingReasoningEffortFallback) {
519
- rememberOpenAIReasoningEffortFallback(
520
- providerSessionState,
521
- pendingReasoningEffortFallback.key,
522
- pendingReasoningEffortFallback.fallback,
523
- );
524
- pendingReasoningEffortFallback = undefined;
525
- }
526
- break;
527
- } catch (error) {
528
- const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
529
- const reasoningEffortFallback =
530
- activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
531
- ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
532
- explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
533
- })
534
- : undefined;
535
- if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
536
- const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
537
- if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
538
- attemptedReasoningEffortFallbacks.add(retryMarker);
539
- requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
540
- applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
541
- applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
542
- rawRequestDump.body = chained.params;
543
- pendingReasoningEffortFallback = {
544
- key: activeReasoningEffortFallbackKey,
545
- fallback: reasoningEffortFallback,
546
- };
547
- continue;
548
- }
549
- const compiledGrammarTooLarge =
550
- isOpenRouterAnthropicModel(model) &&
551
- isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
552
- const canRetryWithoutStrictTools =
553
- strictRetryAvailable &&
554
- !requestSignal.aborted &&
555
- (compiledGrammarTooLarge ||
556
- shouldRetryWithoutStrictTools(
557
- error,
558
- capturedErrorResponse,
559
- activeStrictToolsApplied,
560
- context.tools,
561
- ));
562
- if (canRetryWithoutStrictTools) {
563
- strictRetryAvailable = false;
564
- forceDisableStrictTools = true;
565
- disableStrictToolsForScope(providerSessionState, strictToolsScope);
566
- 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(
567
647
  model,
568
648
  context,
569
649
  options,
570
650
  providerSessionState,
571
651
  strictToolsScope,
572
- true,
652
+ forceDisableStrictTools,
573
653
  );
574
- const fallbackParams = fallbackBuilt.params;
575
- if (chainState && !chainState.disabled) fallbackParams.store = true;
576
- let fallbackChained: OpenAIResponsesChainedParams =
577
- chainState && !chainState.disabled
578
- ? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
579
- : { params: fallbackParams };
580
- sentPreviousResponseId = fallbackChained.previousResponseId;
581
- fallbackChained = {
582
- ...fallbackChained,
583
- params: await applyPayloadReplacement(fallbackChained.params),
584
- };
585
- chained = fallbackChained;
586
- rawRequestDump.body = chained.params;
587
- activeParams = fallbackParams;
588
- activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
589
- continue;
590
- }
591
- if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
592
- 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;
593
665
  }
594
- const zdrRejection =
595
- error instanceof Error &&
596
- /previous[ _]?response/i.test(error.message) &&
597
- /zero[ _-]?data[ _-]?retention/i.test(error.message);
598
- const isPromptBlocked =
599
- error instanceof Error &&
600
- ((error as { code?: string }).code === "invalid_prompt" ||
601
- /invalid_prompt|Request blocked/i.test(error.message));
602
- if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
603
- throw error;
604
- }
605
- // Server rejected the chain baseline: reset, count the failure (or
606
- // disable categorically on ZDR), and retry once with the full
607
- // transcript. Structurally cannot loop — the retry carries no
608
- // previous_response_id.
609
- if (zdrRejection) {
610
- markOpenAIResponsesChainZeroDataRetention(chainState, error);
611
- // ZDR orgs cannot store responses; the retry uses `store: false`.
612
- } else {
613
- registerOpenAIResponsesChainStaleFailure(chainState, error);
614
- }
615
- sentPreviousResponseId = undefined;
616
- const currentBuilt = buildParams(
617
- model,
618
- context,
619
- options,
620
- providerSessionState,
621
- strictToolsScope,
622
- forceDisableStrictTools,
623
- );
624
- const currentParams = currentBuilt.params;
625
- // Only ZDR forces `store: false` (the org never persists responses). A
626
- // non-ZDR stale baseline is transient, so keep storing: the full-context
627
- // retry must be chainable next turn, and the consecutive stale-failure
628
- // breaker only trips when each retry stores and the next turn re-chains.
629
- currentParams.store = !zdrRejection;
630
- const retryParams = await applyPayloadReplacement(currentParams);
631
- chained = { params: retryParams };
632
- rawRequestDump.body = retryParams;
633
- activeParams = currentParams;
634
- activeStrictToolsApplied = currentBuilt.strictToolsApplied;
635
666
  }
636
- }
667
+ return openaiStream;
668
+ };
669
+ let openaiStream = await openResponsesStreamWithFallbacks();
637
670
  if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
638
671
  stream.push({ type: "start", partial: output });
639
672
 
640
673
  const nativeOutputItems: Array<Record<string, unknown>> = [];
641
- let sawTerminalResponseEvent = false;
642
- const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
643
- idleTimeoutMs,
644
- firstItemTimeoutMs: firstEventTimeoutMs,
645
- firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
646
- errorMessage: "OpenAI responses stream stalled while waiting for the next event",
647
- onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
648
- onIdle: () => requestAbortController.abort(),
649
- abortSignal: options?.signal,
650
- isProgressItem: isOpenAIResponsesProgressEvent,
651
- });
652
- await processResponsesStream(timedOpenaiStream, output, stream, model, {
653
- onFirstToken: () => {
654
- if (!firstTokenTime) firstTokenTime = performance.now();
655
- },
656
- onOutputItemDone: item => {
657
- // `processResponsesStream` hands over a private clone already; no
658
- // second deep copy needed (reasoning items carry multi-KB blobs).
659
- nativeOutputItems.push(item as unknown as Record<string, unknown>);
660
- },
661
- onCompleted: () => {
662
- sawTerminalResponseEvent = true;
663
- },
664
- requestServiceTier: options?.serviceTier,
665
- });
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
+ })();
666
708
 
667
- const localAbortReason = abortTracker.getLocalAbortReason();
668
- if (localAbortReason) {
669
- throw localAbortReason;
670
- }
671
- if (abortTracker.wasCallerAbort()) {
672
- throw new AIError.AbortError();
673
- }
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
+ });
674
724
 
675
- // Detect premature stream closure: the HTTP stream ended without the
676
- // provider sending a recognized terminal response event.
677
- // Custom/proxy providers may drop the connection mid-stream; without
678
- // this guard the incomplete output is silently surfaced as a successful
679
- // "stop".
680
- if (!sawTerminalResponseEvent) {
681
- throw new AIError.ProviderResponseError(
682
- "OpenAI responses stream closed before a terminal response event was received",
683
- { provider: model.provider, kind: "incomplete-stream" },
684
- );
685
- }
725
+ const localAbortReason = abortTracker.getLocalAbortReason();
726
+ if (localAbortReason) throw localAbortReason;
727
+ if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
686
728
 
687
- if (output.stopReason === "aborted" || output.stopReason === "error") {
688
- throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
689
- provider: model.provider,
690
- kind: "runtime",
691
- });
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
+ }
692
791
  }
693
792
 
694
793
  output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
@@ -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
 
@@ -1133,18 +1133,19 @@ const OLLAMA_SCHEMA_VALUE_KEYS = new Set([
1133
1133
  ]);
1134
1134
 
1135
1135
  /**
1136
- * Widened stand-in for a `true` / `{}` subschema in an Ollama-bound tool.
1136
+ * Widened stand-in for a `true` / `{}` open subschema on a tool bound for a
1137
+ * backend whose wire cannot encode a bare boolean subschema.
1137
1138
  *
1138
1139
  * `toolWireSchema()` normalizes empty schemas to boolean `true` upstream so
1139
- * grammar-constrained samplers (llama.cpp, etc.) don't treat `{}` as
1140
- * "generate an empty object" (issue #1179). Ollama's Go tool parser can't
1141
- * unmarshal a boolean into its object-shaped `Schema` struct, so this
1142
- * sanitizer replaces every open subschema with an explicit union of every
1143
- * primitive JSON type. Both invariants survive: the wire has no boolean
1144
- * subschema (Go accepts it), and llama.cpp's grammar sees a real value
1145
- * union rather than a closed empty object.
1140
+ * grammar-constrained samplers don't treat `{}` as "generate an empty object"
1141
+ * (issue #1179). Two backends then choke on the bare boolean: Ollama's Go tool
1142
+ * parser can't unmarshal it into its object-shaped `Schema` struct, and
1143
+ * llama.cpp's JSON-schema→GBNF converter has no case for a boolean schema
1144
+ * (issue #5914). Both sanitizers replace the open subschema with an explicit
1145
+ * union of every primitive JSON type the wire has no boolean subschema, and
1146
+ * a grammar sampler sees a real value union rather than a closed empty object.
1146
1147
  */
1147
- const OLLAMA_OPEN_SUBSCHEMA_WIDENING = Object.freeze({
1148
+ const OPEN_SUBSCHEMA_WIDENING = Object.freeze({
1148
1149
  anyOf: [
1149
1150
  { type: "string" },
1150
1151
  { type: "number" },
@@ -1161,8 +1162,8 @@ const OLLAMA_OPEN_SUBSCHEMA_WIDENING = Object.freeze({
1161
1162
  */
1162
1163
  export function sanitizeSchemaForOllama(schema: JsonObject): JsonObject {
1163
1164
  const normalizeNode = (value: unknown): unknown => {
1164
- if (value === true) return OLLAMA_OPEN_SUBSCHEMA_WIDENING;
1165
- if (value === false) return { not: OLLAMA_OPEN_SUBSCHEMA_WIDENING };
1165
+ if (value === true) return OPEN_SUBSCHEMA_WIDENING;
1166
+ if (value === false) return { not: OPEN_SUBSCHEMA_WIDENING };
1166
1167
  if (!isJsonObject(value)) {
1167
1168
  if (!Array.isArray(value)) return value;
1168
1169
  let changed = false;
@@ -1235,6 +1236,97 @@ export function sanitizeSchemaForOllama(schema: JsonObject): JsonObject {
1235
1236
  return normalizeNode(schema) as JsonObject;
1236
1237
  }
1237
1238
 
1239
+ /**
1240
+ * Schema-valued keywords whose bare boolean value must be widened for a
1241
+ * grammar-constrained backend. Excludes `additionalProperties` and
1242
+ * `unevaluatedProperties`: llama.cpp's `_build_object_rule` reads their boolean
1243
+ * form as meaningful closed/open-object semantics, and `additionalProperties:
1244
+ * false` is exactly what `toolWireSchema` emits to pin a strict object shape.
1245
+ */
1246
+ const GRAMMAR_SCHEMA_VALUE_KEYS: Record<string, true> = {
1247
+ items: true,
1248
+ additionalItems: true,
1249
+ contains: true,
1250
+ contentSchema: true,
1251
+ propertyNames: true,
1252
+ if: true,
1253
+ // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword
1254
+ then: true,
1255
+ else: true,
1256
+ not: true,
1257
+ unevaluatedItems: true,
1258
+ };
1259
+
1260
+ /**
1261
+ * Rewrites the one JSON Schema form that grammar-constrained OpenAI-compatible
1262
+ * backends (llama.cpp, LM Studio, vLLM) cannot compile to GBNF: a bare boolean
1263
+ * subschema. `toolWireSchema` normalizes `{}` open subschemas to boolean `true`
1264
+ * (issue #1179); llama.cpp's `json-schema-to-grammar.cpp` `visit()` has no case
1265
+ * for a boolean schema and throws `Unrecognized schema: true` → HTTP 400 before
1266
+ * the model is consulted (issue #5914).
1267
+ *
1268
+ * Narrower than {@link sanitizeSchemaForOllama}: only genuine subschema slots
1269
+ * are widened. Boolean `additionalProperties`/`unevaluatedProperties` stay
1270
+ * intact because the converter reads those as closed/open-object grammar
1271
+ * semantics, and dropping `additionalProperties: false` would silently reopen
1272
+ * every declared object.
1273
+ */
1274
+ export function sanitizeSchemaForGrammar(schema: JsonObject): JsonObject {
1275
+ const normalizeNode = (value: unknown, isSubschema: boolean): unknown => {
1276
+ if (value === true) return isSubschema ? OPEN_SUBSCHEMA_WIDENING : value;
1277
+ if (value === false) return isSubschema ? { not: OPEN_SUBSCHEMA_WIDENING } : value;
1278
+ if (Array.isArray(value)) {
1279
+ let changed = false;
1280
+ const output = value.map(item => {
1281
+ const next = normalizeNode(item, isSubschema);
1282
+ if (next !== item) changed = true;
1283
+ return next;
1284
+ });
1285
+ return changed ? output : value;
1286
+ }
1287
+ if (!isJsonObject(value)) return value;
1288
+
1289
+ let changed = false;
1290
+ const output: JsonObject = {};
1291
+ for (const key in value) {
1292
+ if (!Object.hasOwn(value, key)) continue;
1293
+ const child = value[key];
1294
+ let next = child;
1295
+ if (Object.hasOwn(SUBSCHEMA_MAP_KEYS, key) && isJsonObject(child)) {
1296
+ let mapChanged = false;
1297
+ const mapOutput: JsonObject = {};
1298
+ for (const childKey in child) {
1299
+ if (!Object.hasOwn(child, childKey)) continue;
1300
+ const mapChild = child[childKey];
1301
+ const normalizedChild = normalizeNode(mapChild, true);
1302
+ if (normalizedChild !== mapChild) mapChanged = true;
1303
+ mapOutput[childKey] = normalizedChild;
1304
+ }
1305
+ next = mapChanged ? mapOutput : child;
1306
+ } else if (Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key) && Array.isArray(child)) {
1307
+ let arrayChanged = false;
1308
+ const arrayOutput = child.map(item => {
1309
+ const normalizedItem = normalizeNode(item, true);
1310
+ if (normalizedItem !== item) arrayChanged = true;
1311
+ return normalizedItem;
1312
+ });
1313
+ next = arrayChanged ? arrayOutput : child;
1314
+ } else if (Object.hasOwn(GRAMMAR_SCHEMA_VALUE_KEYS, key)) {
1315
+ next = normalizeNode(child, true);
1316
+ } else if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child !== "boolean") {
1317
+ // Boolean form is meaningful closed/open-object grammar semantics and
1318
+ // stays intact; the object form is a genuine subschema whose interior
1319
+ // may still hold bare booleans emitted by `toolWireSchema`.
1320
+ next = normalizeNode(child, true);
1321
+ }
1322
+ if (next !== child) changed = true;
1323
+ output[key] = next;
1324
+ }
1325
+ return changed ? output : value;
1326
+ };
1327
+ return normalizeNode(schema, true) as JsonObject;
1328
+ }
1329
+
1238
1330
  // ---------------------------------------------------------------------------
1239
1331
  // OpenAI Responses — schema-valued normalization
1240
1332
  // ---------------------------------------------------------------------------