@gajae-code/ai 0.12.21 → 0.13.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.
@@ -1,5 +1,34 @@
1
1
  import type { Api, Model } from "./types";
2
2
 
3
+ /**
4
+ * Codex GPT-5.6 OAuth context-window policy.
5
+ *
6
+ * Authoritative source and ownership path
7
+ * ---------------------------------------
8
+ * The authenticated Codex backend discovery endpoint (`GET {base}/codex/models`
9
+ * with an OAuth bearer token) is the authoritative runtime source of Codex OAuth
10
+ * model context-window metadata (`models[].context_window`). OpenAI owns that
11
+ * value; GJC consumes it read-only via `fetchCodexModels` and must not silently
12
+ * invent a larger limit without upstream evidence.
13
+ *
14
+ * This bundled policy is a client-side conservative guard, not a primary source.
15
+ * It exists because OpenAI temporarily reverted the GPT-5.6 Sol product context
16
+ * limit from 372K to 272K on 2026-07-13 (staff announcement quoted in
17
+ * gajae-code issues #2240 / #2260), while planning to restore 372K later.
18
+ * `ceiling` must only be raised with an upstream evidence citation; an
19
+ * unverified report that "372K is live again" is not sufficient.
20
+ *
21
+ * Precedence (highest -> lowest)
22
+ * ------------------------------
23
+ * 1. Explicit user per-model override (`contextWindow` in `modelOverrides`).
24
+ * The model registry merges it into `model.contextWindow` before this cap and
25
+ * passes it here; it is honored when a positive finite number, with
26
+ * diagnostics emitted at the registry. Never silently discarded.
27
+ * 2. Live OAuth discovery metadata (`context_window`), forced to the enforced
28
+ * product window for the GPT-5.6 tier.
29
+ * 3. Bundled conservative generic window (`CODEX_GENERIC_CONTEXT_WINDOW`),
30
+ * used when discovery metadata is absent or invalid.
31
+ */
3
32
  export interface CodexGpt56ContextCapPolicy {
4
33
  /**
5
34
  * Usable prompt budget forced for the GPT-5.6 tier on the Codex product
@@ -37,6 +66,9 @@ export function isCodexProductTransport(model: Pick<Model<Api>, "api" | "provide
37
66
  export function isCodexGpt56Tier(model: Pick<Model<Api>, "id">): boolean {
38
67
  return CODEX_GPT_5_6_MODEL_IDS.has(model.id.toLowerCase());
39
68
  }
69
+ export function codexContextOverrideKey(provider: string, modelId: string): string {
70
+ return `${provider.toLowerCase()}:${modelId.toLowerCase()}`;
71
+ }
40
72
 
41
73
  export function resolveCodexGpt56DiscoveryContext(
42
74
  model: Pick<Model<Api>, "api" | "id" | "provider">,
@@ -54,14 +86,34 @@ export function resolveCodexGpt56DiscoveryContext(
54
86
  return policy.enforced;
55
87
  }
56
88
 
89
+ /**
90
+ * Applies the final Codex GPT-5.6 context ceiling, honoring explicit user
91
+ * overrides.
92
+ *
93
+ * `userContextWindowOverrides` maps provider-qualified composite keys
94
+ * (`provider:modelId`, both lowercased, built by
95
+ * {@link codexContextOverrideKey}) to the user's explicit `contextWindow` value
96
+ * (already merged into `model.contextWindow` by the model registry). A tier
97
+ * model present with a positive finite value keeps its value even above
98
+ * `enforced` — the user's explicit, diagnosed choice. Every other tier
99
+ * model is forced to `enforced`, so a stale larger live/cached observation
100
+ * (e.g. a pre-rollback 373K cache) cannot resurface without an override.
101
+ * Because the key is provider-qualified, an override only exempts the exact
102
+ * provider+model pair it was configured for.
103
+ */
57
104
  export function applyFinalCodexGpt56ContextCap<TApi extends Api>(
58
105
  models: readonly Model<TApi>[],
59
106
  policy: CodexGpt56ContextCapPolicy = CODEX_GPT_5_6_CONTEXT_CAP,
107
+ userContextWindowOverrides: ReadonlyMap<string, number> = new Map(),
60
108
  ): Model<TApi>[] {
61
109
  return models.map(model => {
62
110
  if (!isCodexGpt56Tier(model as Model<Api>) || !isCodexProductTransport(model as Model<Api>)) {
63
111
  return model;
64
112
  }
113
+ const userOverride = userContextWindowOverrides.get(codexContextOverrideKey(model.provider, model.id));
114
+ if (userOverride !== undefined && isPositiveFiniteNumber(userOverride)) {
115
+ return model;
116
+ }
65
117
  return { ...model, contextWindow: policy.enforced };
66
118
  });
67
119
  }
@@ -68,6 +68,7 @@ import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } fr
68
68
  import {
69
69
  FirstEventTimeoutError,
70
70
  getProviderFirstEventTimeoutFallbackMs,
71
+ getProviderStreamIdleTimeoutFallbackMs,
71
72
  getStreamFirstEventTimeoutMs,
72
73
  getStreamIdleTimeoutMs,
73
74
  iterateWithIdleTimeout,
@@ -1479,6 +1480,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1479
1480
  let thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope =
1480
1481
  providerSessionState?.thinkingReplayRepairScope ?? "none";
1481
1482
  let thinkingReplayRepairAttempts = providerSessionState?.thinkingReplayRepairAttempts ?? 0;
1483
+ // A scope inherited from an earlier turn can only have come from the
1484
+ // deterministic branch below — the speculative masked-`api_error` probe is
1485
+ // never persisted — so a completed stream must not release it.
1486
+ let thinkingReplayRepairPersistent = thinkingReplayRepairScope !== "none";
1482
1487
  let generatedCacheBudget: GeneratedCacheBudget = providerSessionState?.generatedCacheBudget ?? 2;
1483
1488
  const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
1484
1489
  // Degradation state is cumulative: every fallback rebuild must merge all
@@ -1579,7 +1584,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1579
1584
  truncatedToolCalls.clear();
1580
1585
  sawTerminalStopReason = false;
1581
1586
  };
1582
- const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
1587
+ const idleTimeoutMs =
1588
+ options?.streamIdleTimeoutMs ??
1589
+ getStreamIdleTimeoutMs(getProviderStreamIdleTimeoutFallbackMs(model.provider));
1583
1590
  const firstEventFallbackMs = getProviderFirstEventTimeoutFallbackMs(model.provider);
1584
1591
  const firstEventTimeoutMs =
1585
1592
  options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
@@ -1915,7 +1922,17 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1915
1922
  // branch above fires on an error nobody can classify, so keeping its
1916
1923
  // guess would silently strip native thinking replay from every later
1917
1924
  // turn of the session over what may have been one transient blip.
1918
- if (providerSessionState && (thinkingReplayRepairScope !== "none" || thinkingReplayRepairAttempts > 0)) {
1925
+ //
1926
+ // A deterministic rejection ("cannot be modified" / invalid signature) is
1927
+ // the opposite case: it cites blocks that stay in this session's history,
1928
+ // so releasing the repair here makes the next turn replay the same blocks
1929
+ // and spend another rejected round trip on every turn that follows. That
1930
+ // repair has to outlive the stream it fixed.
1931
+ if (
1932
+ providerSessionState &&
1933
+ !thinkingReplayRepairPersistent &&
1934
+ (thinkingReplayRepairScope !== "none" || thinkingReplayRepairAttempts > 0)
1935
+ ) {
1919
1936
  providerSessionState.thinkingReplayRepairScope = "none";
1920
1937
  providerSessionState.thinkingReplayRepairAttempts = 0;
1921
1938
  }
@@ -2004,6 +2021,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2004
2021
  error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
2005
2022
  });
2006
2023
  thinkingReplayRepairScope = nextScope;
2024
+ // Anything but the masked probe is caused by blocks that remain in
2025
+ // history, so this repair must survive the stream it is about to fix.
2026
+ if (!maskedProxyRejection) thinkingReplayRepairPersistent = true;
2007
2027
  if (providerSessionState) {
2008
2028
  providerSessionState.thinkingReplayRepairAttempts = thinkingReplayRepairAttempts;
2009
2029
  if (!maskedProxyRejection) {
@@ -87,6 +87,8 @@ export interface MockResponse {
87
87
  usage?: Partial<Omit<Usage, "cost">> & { cost?: Partial<Usage["cost"]> };
88
88
  /** Pre-set responseId. */
89
89
  responseId?: string;
90
+ /** Optional typed provider failure metadata for retry/fallback tests. */
91
+ transportFailure?: AssistantMessage["transportFailure"];
90
92
  /** If set, the stream emits a terminal error event instead of completing. */
91
93
  throw?: string | Error;
92
94
  /** Delay before any event is emitted. Honors the call's AbortSignal. */
@@ -363,6 +365,7 @@ async function runMock(
363
365
  provider: model.provider,
364
366
  model: model.id,
365
367
  responseId: response.responseId,
368
+ transportFailure: response.transportFailure,
366
369
  usage: emptyUsage(),
367
370
  stopReason: "stop",
368
371
  timestamp: startedAt,
@@ -36,7 +36,7 @@ import {
36
36
  } from "../utils";
37
37
  import { createAbortSourceTracker } from "../utils/abort";
38
38
  import { AssistantMessageEventStream } from "../utils/event-stream";
39
- import { transportFailureFacts } from "../utils/fallback-transport";
39
+ import { EMPTY_RESPONSE_PROVIDER_CODE, transportFailureFacts } from "../utils/fallback-transport";
40
40
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
41
41
  import {
42
42
  FirstEventTimeoutError,
@@ -320,6 +320,26 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
320
320
  stream_options?: { include_obfuscation?: boolean };
321
321
  };
322
322
 
323
+ export function isOpenCodeGoEmptyCompletedResponse(
324
+ model: Model<"openai-responses">,
325
+ output: AssistantMessage,
326
+ nativeOutputItemCount: number,
327
+ ): boolean {
328
+ return (
329
+ model.provider === "opencode-go" &&
330
+ typeof output.responseId === "string" &&
331
+ output.responseId.length > 0 &&
332
+ output.stopReason === "stop" &&
333
+ output.content.length === 0 &&
334
+ nativeOutputItemCount === 0 &&
335
+ output.usage.input === 0 &&
336
+ output.usage.output === 0 &&
337
+ output.usage.cacheRead === 0 &&
338
+ output.usage.cacheWrite === 0 &&
339
+ output.usage.totalTokens === 0
340
+ );
341
+ }
342
+
323
343
  /**
324
344
  * Generate function for OpenAI Responses API
325
345
  */
@@ -462,11 +482,23 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
462
482
  }
463
483
 
464
484
  output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
485
+ if (isOpenCodeGoEmptyCompletedResponse(model, output, nativeOutputItems.length)) {
486
+ output.stopReason = "error";
487
+ output.errorMessage = "Provider returned an empty response with zero token usage";
488
+ output.transportFailure = {
489
+ kind: "transport",
490
+ providerCode: EMPTY_RESPONSE_PROVIDER_CODE,
491
+ };
492
+ }
465
493
  if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
466
494
 
467
495
  output.duration = Date.now() - startTime;
468
496
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
469
- stream.push({ type: "done", reason: output.stopReason, message: output });
497
+ if (output.stopReason === "error") {
498
+ stream.push({ type: "error", reason: "error", error: output });
499
+ } else {
500
+ stream.push({ type: "done", reason: output.stopReason, message: output });
501
+ }
470
502
  stream.end();
471
503
  } catch (error) {
472
504
  for (const block of output.content) delete (block as { index?: number }).index;
@@ -24,6 +24,8 @@ export interface FallbackTrigger {
24
24
 
25
25
  /** Stable code for streams that time out before producing semantic progress. */
26
26
  export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
27
+ /** Stable code for a nominally successful response with no content or token usage. */
28
+ export const EMPTY_RESPONSE_PROVIDER_CODE = "empty_response";
27
29
 
28
30
  export type TransportHeaders = Headers | Record<string, string | undefined>;
29
31
 
@@ -212,7 +214,8 @@ export function transportFailureFacts(
212
214
  !isAuthCode(normalizedCode) &&
213
215
  !isRateLimitCode(normalizedCode) &&
214
216
  !isContextOverflowCode(normalizedCode) &&
215
- normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
217
+ normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE &&
218
+ normalizedCode !== EMPTY_RESPONSE_PROVIDER_CODE
216
219
  ) {
217
220
  return undefined;
218
221
  }
@@ -320,7 +323,7 @@ export function classifyFallbackTrigger(
320
323
  );
321
324
  const code = codes[0] ?? codes[1] ?? codes[2];
322
325
  const triggerClass: FallbackTriggerClass =
323
- code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
326
+ code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE || code === EMPTY_RESPONSE_PROVIDER_CODE
324
327
  ? "server"
325
328
  : isQuotaCode(code)
326
329
  ? "quota"
@@ -6,6 +6,12 @@ const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
6
6
  const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 600_000;
7
7
  const KIMI_CODE_FIRST_EVENT_TIMEOUT_MS = 300_000;
8
8
 
9
+ const ANTHROPIC_STREAM_IDLE_TIMEOUT_MS = 300_000;
10
+
11
+ export function getProviderStreamIdleTimeoutFallbackMs(provider: string): number | undefined {
12
+ return provider === "anthropic" ? ANTHROPIC_STREAM_IDLE_TIMEOUT_MS : undefined;
13
+ }
14
+
9
15
  export function getProviderFirstEventTimeoutFallbackMs(provider: string): number | undefined {
10
16
  if (provider === "alibaba-token-plan") return ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS;
11
17
  return provider === "kimi-code" ? KIMI_CODE_FIRST_EVENT_TIMEOUT_MS : undefined;
@@ -145,6 +145,7 @@ const NON_OVERFLOW_PROVIDER_CODES = new Set([
145
145
  "rate_limit_error",
146
146
  "rate_limit_exceeded",
147
147
  "too_many_requests",
148
+ "empty_response",
148
149
  ]);
149
150
 
150
151
  function transportCodes(transportFailure: TransportFailureFacts | undefined): string[] {