@gajae-code/ai 0.17.1 → 0.17.2

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.2] - 2026-09-18
6
+
7
+ ### Added
8
+
9
+ - Union Alpha Free on OpenCode Go and Zen with Anthropic Messages routing, image input, reasoning, and the published free-tier limits.
10
+
11
+ ### Fixed
12
+
13
+ - Share Cursor HTTP/2 write error and close listeners across pending frames to avoid listener-limit warnings during write bursts while preserving write-failure and drain-timeout handling.
14
+
15
+ - Discover local OpenCodex models through the public `/v1/models` endpoint instead of the admin-only management API, preserving public context, input, and reasoning capabilities.
16
+
17
+ - Preserve OpenCode protocol-specific base URLs during model discovery and recover reviewed Union Alpha limits from pre-catalogue discovery caches.
18
+
5
19
  ## [0.17.1] - 2026-09-17
6
20
 
7
21
  ## [0.17.0] - 2026-09-17
@@ -28,14 +28,18 @@ export declare function enrichModelThinking<TApi extends Api>(model: ApiModel<TA
28
28
  * canonical rules, replacing any existing `thinking`.
29
29
  */
30
30
  export declare function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi>;
31
+ /**
32
+ * Native MiniMax thinking semantics, scoped to first-party regional routes.
33
+ * M3 supports adaptive/disabled; M2.x always thinks, even when disabled is sent.
34
+ * https://platform.minimax.io/docs/api-reference/text-openai-api#thinking-control
35
+ * https://platform.minimax.io/docs/api-reference/text-anthropic-api#thinking-control
36
+ */
37
+ export declare function getMiniMaxThinkingMode(model: ApiModel<Api>, resolvedBaseUrl?: string): "toggle" | "always-on" | undefined;
31
38
  /**
32
39
  * Returns whether the configured transport has an audited user-facing reasoning control.
33
- *
34
- * Custom OpenAI-compatible endpoints fail closed: declaring a model as reasoning-capable
35
- * is not enough to prove that the proxy accepts OpenAI reasoning parameters. Unknown
36
- * endpoints must opt in with `compat.supportsReasoningEffort: true`; providers using a
37
- * non-OpenAI request shape must also declare `compat.thinkingFormat`. Bundled providers
38
- * remain governed by their catalog and compatibility metadata.
40
+ * Custom OpenAI-compatible endpoints must opt in with supportsReasoningEffort and,
41
+ * for non-OpenAI request shapes, thinkingFormat. Native MiniMax switches are
42
+ * separate from reasoning_effort, which those endpoints do not support.
39
43
  */
40
44
  export declare function modelSupportsReasoningControl<TApi extends Api>(model: ApiModel<TApi>, resolvedBaseUrl?: string): boolean;
41
45
  /**
@@ -97,8 +97,8 @@ export interface OpenCodeModelManagerConfig {
97
97
  apiKey?: string;
98
98
  baseUrl?: string;
99
99
  }
100
- export declare function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
101
- export declare function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
100
+ export declare function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
101
+ export declare function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
102
102
  export declare function commandCodeModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
103
103
  export interface OllamaModelManagerConfig {
104
104
  apiKey?: string;
@@ -12,7 +12,12 @@
12
12
  * advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
13
13
  * framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
14
14
  * or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
15
- * codes as h2-fallback triggers as well.
15
+ * codes as h2-fallback triggers as well. `ConnectionRefused` is raised before
16
+ * the request is written, but a reset or a close does not prove the peer never
17
+ * consumed the body — it may have processed the request and died before
18
+ * answering. Replaying those two on h1 would duplicate the side effect, so
19
+ * `ConnectionReset` and `ConnectionClosed` fall back only for replay-safe
20
+ * methods; anything else rethrows the original error.
16
21
  *
17
22
  * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
18
23
  * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.17.1",
4
+ "version": "0.17.2",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -41,8 +41,8 @@
41
41
  "@agentclientprotocol/sdk": "1.3.0",
42
42
  "@anthropic-ai/sdk": "^0.94.0",
43
43
  "@bufbuild/protobuf": "^2.12.0",
44
- "@gajae-code/natives": "0.17.1",
45
- "@gajae-code/utils": "0.17.1",
44
+ "@gajae-code/natives": "0.17.2",
45
+ "@gajae-code/utils": "0.17.2",
46
46
  "openai": "^6.36.0",
47
47
  "partial-json": "^0.1.7",
48
48
  "zod": "4.4.3"
@@ -553,15 +553,18 @@ function fingerprintStatic<TApi extends Api>(models: readonly Model<TApi>[]): st
553
553
 
554
554
  function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamicModel: Model<TApi>): Model<TApi> {
555
555
  const supportsImage = existingModel.input.includes("image") || dynamicModel.input.includes("image");
556
- // Before this exact Go model was curated, ID-only discovery cached the
556
+ // Before these exact OpenCode models were curated, ID-only discovery cached the
557
557
  // non-reasoning Completions defaults. A fresh authoritative cache can skip
558
558
  // discovery after an upgrade, so recover its limits from reviewed static
559
- // Responses metadata here. Do not reinterpret individual numeric limits or
559
+ // metadata here. Do not reinterpret individual numeric limits or
560
560
  // apply this correction to reviewed discovery rows or other model IDs.
561
- const hasPreReviewMuseLimits =
562
- existingModel.provider === "opencode-go" &&
563
- existingModel.id === "muse-spark-1.3-contributor" &&
564
- existingModel.api === "openai-responses" &&
561
+ const hasPreReviewOpenCodeLimits =
562
+ ((existingModel.provider === "opencode-go" &&
563
+ existingModel.id === "muse-spark-1.3-contributor" &&
564
+ existingModel.api === "openai-responses") ||
565
+ ((existingModel.provider === "opencode-go" || existingModel.provider === "opencode-zen") &&
566
+ existingModel.id === "union-alpha" &&
567
+ existingModel.api === "anthropic-messages")) &&
565
568
  existingModel.reasoning &&
566
569
  dynamicModel.api === "openai-completions" &&
567
570
  !dynamicModel.reasoning &&
@@ -594,10 +597,10 @@ function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamic
594
597
  cacheRead: preferDiscoveryCost(dynamicModel.cost.cacheRead, existingModel.cost.cacheRead),
595
598
  cacheWrite: preferDiscoveryCost(dynamicModel.cost.cacheWrite, existingModel.cost.cacheWrite),
596
599
  },
597
- contextWindow: hasPreReviewMuseLimits
600
+ contextWindow: hasPreReviewOpenCodeLimits
598
601
  ? existingModel.contextWindow
599
602
  : preferDiscoveryLimit(dynamicModel.contextWindow, existingModel.contextWindow),
600
- maxTokens: hasPreReviewMuseLimits
603
+ maxTokens: hasPreReviewOpenCodeLimits
601
604
  ? existingModel.maxTokens
602
605
  : preferDiscoveryLimit(dynamicModel.maxTokens, existingModel.maxTokens),
603
606
  headers: dynamicModel.headers ? { ...existingModel.headers, ...dynamicModel.headers } : existingModel.headers,
@@ -28,14 +28,18 @@ export declare function enrichModelThinking<TApi extends Api>(model: ApiModel<TA
28
28
  * canonical rules, replacing any existing `thinking`.
29
29
  */
30
30
  export declare function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi>;
31
+ /**
32
+ * Native MiniMax thinking semantics, scoped to first-party regional routes.
33
+ * M3 supports adaptive/disabled; M2.x always thinks, even when disabled is sent.
34
+ * https://platform.minimax.io/docs/api-reference/text-openai-api#thinking-control
35
+ * https://platform.minimax.io/docs/api-reference/text-anthropic-api#thinking-control
36
+ */
37
+ export declare function getMiniMaxThinkingMode(model: ApiModel<Api>, resolvedBaseUrl?: string): "toggle" | "always-on" | undefined;
31
38
  /**
32
39
  * Returns whether the configured transport has an audited user-facing reasoning control.
33
- *
34
- * Custom OpenAI-compatible endpoints fail closed: declaring a model as reasoning-capable
35
- * is not enough to prove that the proxy accepts OpenAI reasoning parameters. Unknown
36
- * endpoints must opt in with `compat.supportsReasoningEffort: true`; providers using a
37
- * non-OpenAI request shape must also declare `compat.thinkingFormat`. Bundled providers
38
- * remain governed by their catalog and compatibility metadata.
40
+ * Custom OpenAI-compatible endpoints must opt in with supportsReasoningEffort and,
41
+ * for non-OpenAI request shapes, thinkingFormat. Native MiniMax switches are
42
+ * separate from reasoning_effort, which those endpoints do not support.
39
43
  */
40
44
  export declare function modelSupportsReasoningControl<TApi extends Api>(model: ApiModel<TApi>, resolvedBaseUrl?: string): boolean;
41
45
  /**
@@ -181,7 +181,7 @@ export function enrichModelThinking<TApi extends Api>(model: ApiModel<TApi>): Ap
181
181
  if (cached !== undefined) {
182
182
  return cached as ApiModel<TApi>;
183
183
  }
184
- const normalizedThinking = normalizeThinkingConfig(model.thinking);
184
+ const normalizedThinking = getMiniMaxThinkingMode(model) ? undefined : normalizeThinkingConfig(model.thinking);
185
185
  let result: ApiModel<TApi>;
186
186
  if (isGroqCompoundReasoningUnsupported(model)) {
187
187
  result =
@@ -228,20 +228,61 @@ export function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): A
228
228
  return { ...model, thinking: inferModelThinking(model) };
229
229
  }
230
230
 
231
+ /**
232
+ * Native MiniMax thinking semantics, scoped to first-party regional routes.
233
+ * M3 supports adaptive/disabled; M2.x always thinks, even when disabled is sent.
234
+ * https://platform.minimax.io/docs/api-reference/text-openai-api#thinking-control
235
+ * https://platform.minimax.io/docs/api-reference/text-anthropic-api#thinking-control
236
+ */
237
+ export function getMiniMaxThinkingMode(
238
+ model: ApiModel<Api>,
239
+ resolvedBaseUrl?: string,
240
+ ): "toggle" | "always-on" | undefined {
241
+ const isDirectRoute =
242
+ (model.api === "anthropic-messages" && (model.provider === "minimax" || model.provider === "minimax-cn")) ||
243
+ (model.api === "openai-completions" &&
244
+ (model.provider === "minimax-code" || model.provider === "minimax-code-cn"));
245
+ if (!isDirectRoute) return undefined;
246
+ // Provider identity survives baseUrl overrides. Only the normalized native
247
+ // endpoint, not a provider label or a matching hostname suffix, proves this contract.
248
+ try {
249
+ const endpoint = new URL(resolvedBaseUrl ?? model.baseUrl);
250
+ const host = model.provider.endsWith("-cn") ? "api.minimaxi.com" : "api.minimax.io";
251
+ const path = endpoint.pathname.replace(/\/+$/, "");
252
+ const validPath =
253
+ model.api === "anthropic-messages" ? path === "/anthropic" || path === "/anthropic/v1" : path === "/v1";
254
+ if (
255
+ endpoint.origin !== `https://${host}` ||
256
+ !validPath ||
257
+ endpoint.username ||
258
+ endpoint.password ||
259
+ endpoint.search ||
260
+ endpoint.hash
261
+ )
262
+ return undefined;
263
+ } catch {
264
+ return undefined;
265
+ }
266
+ if (model.id === "MiniMax-M3" || model.id === "MiniMax-M3[1m]") return "toggle";
267
+ if (/^MiniMax-M2(?:[.\-[]|$)/.test(model.id)) return "always-on";
268
+ return undefined;
269
+ }
270
+
231
271
  /**
232
272
  * Returns whether the configured transport has an audited user-facing reasoning control.
233
- *
234
- * Custom OpenAI-compatible endpoints fail closed: declaring a model as reasoning-capable
235
- * is not enough to prove that the proxy accepts OpenAI reasoning parameters. Unknown
236
- * endpoints must opt in with `compat.supportsReasoningEffort: true`; providers using a
237
- * non-OpenAI request shape must also declare `compat.thinkingFormat`. Bundled providers
238
- * remain governed by their catalog and compatibility metadata.
273
+ * Custom OpenAI-compatible endpoints must opt in with supportsReasoningEffort and,
274
+ * for non-OpenAI request shapes, thinkingFormat. Native MiniMax switches are
275
+ * separate from reasoning_effort, which those endpoints do not support.
239
276
  */
240
277
  export function modelSupportsReasoningControl<TApi extends Api>(
241
278
  model: ApiModel<TApi>,
242
279
  resolvedBaseUrl?: string,
243
280
  ): boolean {
244
281
  if (!model.reasoning) return false;
282
+ // MiniMax's native thinking switch is not OpenAI reasoning_effort. M2.x
283
+ // always thinks; M3 supports adaptive/disabled, but no effort or budget.
284
+ const miniMaxMode = getMiniMaxThinkingMode(model, resolvedBaseUrl);
285
+ if (miniMaxMode) return miniMaxMode === "toggle";
245
286
  if (model.api === "openai-completions") {
246
287
  const completionsModel = model as ApiModel<"openai-completions">;
247
288
  const explicitSupport = completionsModel.compat?.supportsReasoningEffort;
@@ -777,6 +818,11 @@ function inferDefaultEffort<TApi extends Api>(model: ApiModel<TApi>, parsedModel
777
818
  }
778
819
 
779
820
  function inferModelThinking<TApi extends Api>(model: ApiModel<TApi>): ThinkingConfig {
821
+ if (getMiniMaxThinkingMode(model) === "toggle") {
822
+ // The existing persisted effort value represents the single enabled state;
823
+ // UI consumers label it "on", and transports send no effort or budget.
824
+ return { mode: "effort", minLevel: Effort.High, maxLevel: Effort.High };
825
+ }
780
826
  const parsedModel = parseKnownModel(model.id);
781
827
  const efforts = inferSupportedEfforts(parsedModel, model);
782
828
  const minLevel = efforts[0];
package/src/models.json CHANGED
@@ -66611,6 +66611,31 @@
66611
66611
  "minLevel": "minimal",
66612
66612
  "maxLevel": "xhigh"
66613
66613
  }
66614
+ },
66615
+ "union-alpha": {
66616
+ "id": "union-alpha",
66617
+ "name": "Union Alpha Free",
66618
+ "api": "anthropic-messages",
66619
+ "provider": "opencode-go",
66620
+ "baseUrl": "https://opencode.ai/zen/go",
66621
+ "reasoning": true,
66622
+ "input": [
66623
+ "text",
66624
+ "image"
66625
+ ],
66626
+ "cost": {
66627
+ "input": 0,
66628
+ "output": 0,
66629
+ "cacheRead": 0,
66630
+ "cacheWrite": 0
66631
+ },
66632
+ "contextWindow": 262144,
66633
+ "maxTokens": 131072,
66634
+ "thinking": {
66635
+ "mode": "budget",
66636
+ "minLevel": "minimal",
66637
+ "maxLevel": "xhigh"
66638
+ }
66614
66639
  }
66615
66640
  },
66616
66641
  "opencode-zen": {
@@ -68629,6 +68654,31 @@
68629
68654
  },
68630
68655
  "contextWindow": 131072,
68631
68656
  "maxTokens": 131072
68657
+ },
68658
+ "union-alpha": {
68659
+ "id": "union-alpha",
68660
+ "name": "Union Alpha Free",
68661
+ "api": "anthropic-messages",
68662
+ "provider": "opencode-zen",
68663
+ "baseUrl": "https://opencode.ai/zen",
68664
+ "reasoning": true,
68665
+ "input": [
68666
+ "text",
68667
+ "image"
68668
+ ],
68669
+ "cost": {
68670
+ "input": 0,
68671
+ "output": 0,
68672
+ "cacheRead": 0,
68673
+ "cacheWrite": 0
68674
+ },
68675
+ "contextWindow": 262144,
68676
+ "maxTokens": 131072,
68677
+ "thinking": {
68678
+ "mode": "budget",
68679
+ "minLevel": "minimal",
68680
+ "maxLevel": "xhigh"
68681
+ }
68632
68682
  }
68633
68683
  },
68634
68684
  "opengateway": {
@@ -856,44 +856,41 @@ export interface OpenCodeModelManagerConfig {
856
856
  }
857
857
 
858
858
  function openCodeModelManagerOptions(
859
- providerId: "opencode-go" | "opencode-zen" | "commandcode-goat",
859
+ providerId: "opencode-go" | "opencode-zen",
860
860
  defaultBaseUrl: string,
861
861
  config?: OpenCodeModelManagerConfig,
862
- ): ModelManagerOptions<"openai-completions"> {
862
+ ): ModelManagerOptions<Api> {
863
863
  const apiKey = config?.apiKey;
864
864
  const baseUrl = config?.baseUrl ?? defaultBaseUrl;
865
- const references =
866
- providerId === "opencode-go" ? createBundledReferenceMap<"openai-completions">(providerId) : undefined;
865
+ const references = createBundledReferenceMap<Api>(providerId);
867
866
  return {
868
867
  providerId,
869
868
  ...(apiKey && {
870
869
  fetchDynamicModels: () =>
871
- fetchOpenAICompatibleModels({
870
+ fetchOpenAICompatibleModels<Api>({
872
871
  api: "openai-completions",
873
872
  provider: providerId,
874
873
  baseUrl,
875
874
  apiKey,
876
- ...(providerId === "opencode-go" && {
877
- mapModel: (entry, defaults) => {
878
- const reference = references?.get(defaults.id);
879
- const model = mapWithBundledReference(entry, defaults, reference);
880
- return applyOpenCodeGoOfficialMetadata(model);
881
- },
882
- }),
875
+ mapModel: (entry, defaults) => {
876
+ const model = mapWithBundledReference(entry, defaults, references.get(defaults.id));
877
+ // Discovery uses /v1/models, but the Anthropic client appends
878
+ // /v1/messages itself. Preserve the configured origin, not /v1/v1.
879
+ if (model.api === "anthropic-messages") {
880
+ model.baseUrl = model.baseUrl.replace(/\/v1\/?$/u, "");
881
+ }
882
+ return providerId === "opencode-go" ? applyOpenCodeGoOfficialMetadata(model) : model;
883
+ },
883
884
  }),
884
885
  }),
885
886
  };
886
887
  }
887
888
 
888
- export function opencodeZenModelManagerOptions(
889
- config?: OpenCodeModelManagerConfig,
890
- ): ModelManagerOptions<"openai-completions"> {
889
+ export function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api> {
891
890
  return openCodeModelManagerOptions("opencode-zen", "https://opencode.ai/zen/v1", config);
892
891
  }
893
892
 
894
- export function opencodeGoModelManagerOptions(
895
- config?: OpenCodeModelManagerConfig,
896
- ): ModelManagerOptions<"openai-completions"> {
893
+ export function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api> {
897
894
  return openCodeModelManagerOptions("opencode-go", "https://opencode.ai/zen/go/v1", config);
898
895
  }
899
896
 
@@ -2378,7 +2375,9 @@ function createOpenCodeApiResolution(
2378
2375
  }
2379
2376
 
2380
2377
  const OPENCODE_GO_BASE_PATH = "https://opencode.ai/zen/go";
2381
- const OPENCODE_ZEN_API_RESOLUTION = createOpenCodeApiResolution("https://opencode.ai/zen");
2378
+ const OPENCODE_ZEN_API_RESOLUTION = createOpenCodeApiResolution("https://opencode.ai/zen", {
2379
+ "union-alpha": "anthropic-messages",
2380
+ });
2382
2381
  const OPENCODE_GO_CHAT_COMPLETIONS_MODEL_IDS = [
2383
2382
  "deepseek-v4-flash",
2384
2383
  "deepseek-v4-flash-vision-exp",
@@ -2401,6 +2400,7 @@ const OPENCODE_GO_MESSAGES_MODEL_IDS = [
2401
2400
  "qwen3.7-max",
2402
2401
  "qwen3.7-plus",
2403
2402
  "qwen3.8-flash",
2403
+ "union-alpha",
2404
2404
  ] as const;
2405
2405
  const OPENCODE_GO_API_OVERRIDES: Readonly<Record<string, Api>> = {
2406
2406
  ...Object.fromEntries(OPENCODE_GO_CHAT_COMPLETIONS_MODEL_IDS.map(id => [id, "openai-completions"])),
@@ -2658,6 +2658,14 @@ interface OpenCodeGoOfficialModelMetadata {
2658
2658
  }
2659
2659
 
2660
2660
  const OPENCODE_GO_OFFICIAL_MODELS: Readonly<Record<string, OpenCodeGoOfficialModelMetadata>> = {
2661
+ "union-alpha": {
2662
+ name: "Union Alpha Free",
2663
+ contextWindow: 262_144,
2664
+ maxTokens: 131_072,
2665
+ input: ["text", "image"],
2666
+ reasoning: true,
2667
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2668
+ },
2661
2669
  "deepseek-v4-flash": {
2662
2670
  name: "DeepSeek V4 Flash",
2663
2671
  contextWindow: 1_000_000,
@@ -26,6 +26,7 @@ import {
26
26
  PROVIDER_SAFETY_STOP_ADAPTER_CAPABILITY,
27
27
  } from "../adapter-internals/provider-safety-stop";
28
28
  import {
29
+ getMiniMaxThinkingMode,
29
30
  hasOpus47ApiRestrictions,
30
31
  mapEffortToAnthropicAdaptiveEffort,
31
32
  supportsAnthropicAdaptiveThinkingDisplay as supportsAdaptiveThinkingDisplay,
@@ -3610,7 +3611,14 @@ function buildParams(
3610
3611
  );
3611
3612
  }
3612
3613
 
3613
- if (model.reasoning) {
3614
+ const miniMaxMode = getMiniMaxThinkingMode(model, baseUrl);
3615
+ if (model.reasoning && miniMaxMode === "toggle") {
3616
+ // MiniMax's Messages endpoint defaults to off and only supports a
3617
+ // switch. Claude budget_tokens/output_config.effort do not apply.
3618
+ if (options?.thinkingEnabled !== undefined) {
3619
+ params.thinking = { type: options.thinkingEnabled ? "adaptive" : "disabled" };
3620
+ }
3621
+ } else if (model.reasoning && miniMaxMode !== "always-on") {
3614
3622
  if (options?.thinkingEnabled) {
3615
3623
  const mode = model.thinking?.mode;
3616
3624
  const requestedEffort = options.reasoning;
@@ -676,6 +676,12 @@ const CURSOR_WRITE_DRAIN_TIMEOUT_MS = 5_000;
676
676
  const CURSOR_MAX_PENDING_SHELL_WRITE_BYTES = 1024 * 1024;
677
677
  const pendingCursorWrites = new WeakMap<object, Set<Promise<void>>>();
678
678
  const cursorWriteErrors = new WeakMap<object, unknown>();
679
+ interface CursorWriteListeners {
680
+ finishes: Set<(error?: unknown) => void>;
681
+ onError: (error: unknown) => void;
682
+ onClose: () => void;
683
+ }
684
+ const cursorWriteListeners = new WeakMap<object, CursorWriteListeners>();
679
685
 
680
686
  function closeStalledCursorRequest(request: http2.ClientHttp2Stream): void {
681
687
  // A request whose peer stopped reading may never invoke a write callback. Close
@@ -833,25 +839,44 @@ function writeCursorFrame(request: http2.ClientHttp2Stream, frame: Uint8Array):
833
839
  // late transport error cannot surface as an unhandled rejection in the gap.
834
840
  completion.promise.catch(() => {});
835
841
  pending.add(completion.promise);
842
+ let listeners = cursorWriteListeners.get(request);
843
+ if (!listeners) {
844
+ const finishes = new Set<(error?: unknown) => void>();
845
+ const onError = (error: unknown) => {
846
+ for (const finish of [...finishes]) finish(error);
847
+ };
848
+ listeners = {
849
+ finishes,
850
+ onError,
851
+ onClose: () => onError(new Error("Cursor request closed before write completed")),
852
+ };
853
+ cursorWriteListeners.set(request, listeners);
854
+ }
855
+ const shared = listeners;
836
856
  const finish = (error?: unknown) => {
837
857
  if (completed) return;
838
858
  completed = true;
839
859
  pending.delete(completion.promise);
840
860
  if (error != null && !cursorWriteErrors.has(request)) cursorWriteErrors.set(request, error);
841
- if (typeof request.removeListener === "function") {
842
- request.removeListener("close", onClose);
843
- request.removeListener("error", finish);
861
+ shared.finishes.delete(finish);
862
+ if (shared.finishes.size === 0) {
863
+ if (typeof request.removeListener === "function") {
864
+ request.removeListener("close", shared.onClose);
865
+ request.removeListener("error", shared.onError);
866
+ }
867
+ cursorWriteListeners.delete(request);
868
+ // Keep the pending set and first error until the final drain observes them.
844
869
  }
845
870
  if (error == null) completion.resolve();
846
871
  else completion.reject(error);
847
872
  };
848
- const onClose = () => finish(new Error("Cursor request closed before write completed"));
873
+ shared.finishes.add(finish);
849
874
  try {
850
875
  // The real HTTP/2 stream always exposes EventEmitter methods. Keep the
851
876
  // test seam tolerant of a minimal writer stub as well.
852
- if (typeof request.once === "function") {
853
- request.once("close", onClose);
854
- request.once("error", finish);
877
+ if (shared.finishes.size === 1 && typeof request.once === "function") {
878
+ request.once("close", shared.onClose);
879
+ request.once("error", shared.onError);
855
880
  }
856
881
  return request.write(frame, finish) !== false;
857
882
  } catch (error) {
@@ -18,6 +18,7 @@ import {
18
18
  } from "../adapter-internals/provider-safety-stop";
19
19
  import {
20
20
  type Effort,
21
+ getMiniMaxThinkingMode,
21
22
  getSupportedEfforts,
22
23
  isGroqCompoundReasoningUnsupported,
23
24
  modelSupportsReasoningControl,
@@ -364,7 +365,7 @@ type OpenAICompletionsParams = Omit<OpenAI.Chat.Completions.ChatCompletionCreate
364
365
  top_k?: number;
365
366
  min_p?: number;
366
367
  repetition_penalty?: number;
367
- thinking?: { type: "enabled" | "disabled" };
368
+ thinking?: { type: "enabled" | "disabled" | "adaptive" };
368
369
  enable_thinking?: boolean;
369
370
  chat_template_kwargs?: { enable_thinking: boolean };
370
371
  reasoning?: { effort?: string } | { enabled: false };
@@ -1576,7 +1577,12 @@ function buildParams(
1576
1577
  delete params.tool_choice;
1577
1578
  }
1578
1579
 
1579
- if (supportsReasoningParams && compat.thinkingFormat === "zai" && model.reasoning) {
1580
+ if (supportsReasoningParams && getMiniMaxThinkingMode(model, resolvedBaseUrl) === "toggle") {
1581
+ // MiniMax-M3 accepts an on/off switch, not reasoning_effort. Omitting
1582
+ // the switch preserves the OpenAI-compatible endpoint's default (on).
1583
+ if (options?.disableReasoning) params.thinking = { type: "disabled" };
1584
+ else if (options?.reasoning) params.thinking = { type: "adaptive" };
1585
+ } else if (supportsReasoningParams && compat.thinkingFormat === "zai" && model.reasoning) {
1580
1586
  // Z.ai uses binary thinking: { type: "enabled" | "disabled" }
1581
1587
  // Must explicitly disable since z.ai defaults to thinking enabled.
1582
1588
  const enabled = options?.reasoning && !options?.disableReasoning;
@@ -31,6 +31,11 @@ interface CatalogRow {
31
31
  maxTokens?: unknown;
32
32
  reasoning?: unknown;
33
33
  input?: unknown;
34
+ capabilities?: {
35
+ context_length?: unknown;
36
+ input_modalities?: unknown;
37
+ supports_reasoning?: unknown;
38
+ };
34
39
  }
35
40
 
36
41
  export interface OpenCodexEndpoint {
@@ -120,6 +125,9 @@ function asPositiveNumber(value: unknown, fallback: number): number {
120
125
 
121
126
  function normalizeCatalogPayload(payload: unknown): CatalogRow[] {
122
127
  if (Array.isArray(payload)) return payload as CatalogRow[];
128
+ if (payload && typeof payload === "object" && Array.isArray((payload as { data?: unknown }).data)) {
129
+ return (payload as { data: CatalogRow[] }).data;
130
+ }
123
131
  if (payload && typeof payload === "object" && Array.isArray((payload as { models?: unknown }).models)) {
124
132
  return (payload as { models: CatalogRow[] }).models;
125
133
  }
@@ -127,12 +135,14 @@ function normalizeCatalogPayload(payload: unknown): CatalogRow[] {
127
135
  }
128
136
 
129
137
  function normalizeModel(row: CatalogRow, endpoint: OpenCodexEndpoint): Model<"openai-responses"> | undefined {
138
+ if (!row || typeof row !== "object") return undefined;
130
139
  const rawId = typeof row.id === "string" ? row.id.trim() : typeof row.model === "string" ? row.model.trim() : "";
131
140
  if (!rawId || rawId.includes("\n")) return undefined;
132
141
  const publicId = `opencodex/${rawId}`;
142
+ const modalities = row.input ?? row.capabilities?.input_modalities;
133
143
  const input =
134
- Array.isArray(row.input) && row.input.every(value => value === "text" || value === "image")
135
- ? row.input
144
+ Array.isArray(modalities) && modalities.every(value => value === "text" || value === "image")
145
+ ? modalities
136
146
  : ["text"];
137
147
  return {
138
148
  id: publicId,
@@ -142,10 +152,10 @@ function normalizeModel(row: CatalogRow, endpoint: OpenCodexEndpoint): Model<"op
142
152
  provider: "opencodex",
143
153
  baseUrl: `${endpoint.baseUrl}/v1`,
144
154
  compat: { supportsServiceTier: true },
145
- reasoning: row.reasoning !== false,
155
+ reasoning: (row.reasoning ?? row.capabilities?.supports_reasoning) !== false,
146
156
  input,
147
157
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
148
- contextWindow: asPositiveNumber(row.contextWindow, 128_000),
158
+ contextWindow: asPositiveNumber(row.contextWindow ?? row.capabilities?.context_length, 128_000),
149
159
  maxTokens: asPositiveNumber(row.maxTokens, 16_384),
150
160
  };
151
161
  }
@@ -154,7 +164,7 @@ export async function fetchOpenCodexModels(): Promise<readonly Model<"openai-res
154
164
  const endpoint = await resolveOpenCodexEndpoint();
155
165
  if (!endpoint) return null;
156
166
  try {
157
- const rows = normalizeCatalogPayload(await fetchJson(`${endpoint.baseUrl}/api/models`));
167
+ const rows = normalizeCatalogPayload(await fetchJson(`${endpoint.baseUrl}/v1/models`));
158
168
  const models = rows
159
169
  .map(row => normalizeModel(row, endpoint))
160
170
  .filter((model): model is Model<"openai-responses"> => model !== undefined);
package/src/stream.ts CHANGED
@@ -27,6 +27,7 @@ function markManagedAttemptValidated<T extends object>(options: T): T {
27
27
  import { getCustomApi } from "./api-registry";
28
28
  import type { Effort } from "./model-thinking";
29
29
  import {
30
+ getMiniMaxThinkingMode,
30
31
  mapEffortToAnthropicAdaptiveEffort,
31
32
  mapEffortToGoogleThinkingLevel,
32
33
  requireSupportedEffort,
@@ -978,6 +979,16 @@ function mapOptionsForApi<TApi extends Api>(
978
979
 
979
980
  switch (model.api) {
980
981
  case "anthropic-messages": {
982
+ const miniMaxMode = getMiniMaxThinkingMode(model);
983
+ if (miniMaxMode) {
984
+ return castApi<"anthropic-messages">({
985
+ ...base,
986
+ thinkingEnabled:
987
+ miniMaxMode === "toggle" ? !!options?.reasoning && !options?.disableReasoning : undefined,
988
+ toolChoice: mapAnthropicToolChoice(options?.toolChoice),
989
+ serviceTier: options?.serviceTier,
990
+ });
991
+ }
981
992
  // Explicitly disable thinking when reasoning is not specified or model doesn't support it
982
993
  const reasoning = options?.reasoning;
983
994
  if (!reasoning || !model.reasoning) {
@@ -1094,7 +1105,12 @@ function mapOptionsForApi<TApi extends Api>(
1094
1105
  return castApi<"openai-completions">({
1095
1106
  ...base,
1096
1107
  reasoning: resolveOpenAiReasoningEffort(model, options),
1097
- disableReasoning: options?.disableReasoning,
1108
+ // Agent-level off is represented by an absent effort. MiniMax's
1109
+ // native OpenAI endpoint defaults to on, so send an explicit switch.
1110
+ disableReasoning:
1111
+ getMiniMaxThinkingMode(model) === "toggle"
1112
+ ? !options?.reasoning || options?.disableReasoning
1113
+ : options?.disableReasoning,
1098
1114
  toolChoice: mapOpenAiToolChoice(options?.toolChoice),
1099
1115
  serviceTier: options?.serviceTier,
1100
1116
  });
@@ -12,7 +12,12 @@
12
12
  * advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
13
13
  * framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
14
14
  * or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
15
- * codes as h2-fallback triggers as well.
15
+ * codes as h2-fallback triggers as well. `ConnectionRefused` is raised before
16
+ * the request is written, but a reset or a close does not prove the peer never
17
+ * consumed the body — it may have processed the request and died before
18
+ * answering. Replaying those two on h1 would duplicate the side effect, so
19
+ * `ConnectionReset` and `ConnectionClosed` fall back only for replay-safe
20
+ * methods; anything else rethrows the original error.
16
21
  *
17
22
  * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
18
23
  * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
@@ -47,12 +52,16 @@ export function installH2Fetch(): void {
47
52
  // code; the h1 fallback below re-verifies the certificate itself.
48
53
  "UNKNOWN_CERTIFICATE_VERIFICATION_ERROR",
49
54
  ]);
55
+ /** Fallback codes that may fire *after* the peer consumed the body — replay only when safe. */
56
+ const replayGatedCodes: ReadonlySet<string> = new Set(["ConnectionReset", "ConnectionClosed"]);
50
57
  const wrapper = async function h2fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
51
58
  if (!isHttps(input)) return original(input, init);
52
59
  try {
53
60
  return await original(input, { ...init, protocol: "http2" });
54
61
  } catch (err) {
55
- if (!h2FallbackCodes.has((err as { code?: string }).code ?? "")) throw err;
62
+ const code = (err as { code?: string }).code ?? "";
63
+ if (!h2FallbackCodes.has(code)) throw err;
64
+ if (replayGatedCodes.has(code) && !isReplaySafeRequest(input, init)) throw err;
56
65
  return original(input, init);
57
66
  }
58
67
  } as typeof fetch & PatchedFetch;
@@ -63,6 +72,21 @@ export function installH2Fetch(): void {
63
72
  globalThis.fetch = wrapper;
64
73
  }
65
74
 
75
+ /** Methods a transport-layer retry cannot turn into a second side effect. */
76
+ const replaySafeMethods: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"]);
77
+
78
+ /**
79
+ * Whether replaying this request on a fresh connection is side-effect free.
80
+ *
81
+ * PR #5614 introduces an identically-named helper with the same semantics for
82
+ * `HTTP2StreamReset`; whichever of the two lands second should collapse into
83
+ * this one rather than leaving the repo with two devices doing the same job.
84
+ */
85
+ function isReplaySafeRequest(input: string | URL | Request, init?: RequestInit): boolean {
86
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
87
+ return replaySafeMethods.has(method.toUpperCase());
88
+ }
89
+
66
90
  function isHttps(input: string | URL | Request): boolean {
67
91
  if (typeof input === "string") return input.startsWith("https:");
68
92
  if (input instanceof URL) return input.protocol === "https:";
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { APP_NAME, extractHttpStatusFromError, getLogsDir } from "@gajae-code/utils";
3
+ import { APP_NAME, extractHttpStatusFromError, getEffectiveLogsDir } from "@gajae-code/utils";
4
4
  import { isCopilotTransientModelError } from "./retry.js";
5
5
  import { formatErrorMessageWithRetryAfter } from "./retry-after.js";
6
6
 
@@ -109,7 +109,7 @@ const MAX_RETAINED_DUMPS = 50;
109
109
 
110
110
  /** Directory holding the retained HTTP 400 dumps. */
111
111
  export function httpRequestDumpDir(): string {
112
- return path.join(getLogsDir(), "http-400-requests");
112
+ return path.join(getEffectiveLogsDir(), "http-400-requests");
113
113
  }
114
114
 
115
115
  /**