@gajae-code/ai 0.13.0 → 0.13.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,9 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.2] - 2026-08-13
6
+
7
+ ## [0.13.1] - 2026-08-11
8
+
5
9
  ### Fixed
6
10
 
7
11
  - Anthropic thinking-replay repairs caused by a deterministic rejection now stay in force for the rest of the session instead of being released as soon as the repaired retry succeeds. `thinking`/`redacted_thinking` blocks that draw a 400 (`blocks ... cannot be modified`, `Invalid \`signature\` in \`thinking\` block`) stay in the session history, so releasing the repair made the very next turn replay the same blocks and spend another rejected round trip — every turn, indefinitely. Observed against a proxied Claude Code session as a sustained ~50% 400 rate that never converged. The speculative masked-`api_error` probe is still released on the first completed stream, since that one may have been a transient blip (#4011).
12
+ - Anthropic requests no longer spend a rejected round trip discovering that the latest assistant turn cannot replay its thinking block. Anthropic streams a `thinking` block as a start/stop pair with no `thinking_delta` and no `signature_delta` when it withholds reasoning, which lands in history empty and unsigned; `convertAnthropicMessages` then dropped it, and the turn went back carrying only its `tool_use`. Anthropic validates the latest assistant message against the turn it produced and rejects the missing block with 400 `blocks ... cannot be modified`. A captured production request showed exactly this shape — 1214 messages, 29 signed-but-empty thinking blocks preserved, and the latest assistant turn reduced to a bare `tool_use`. The condition is visible locally, so the replay now degrades during the first build instead of after the rejection.
13
+ - The unreplayable-thinking detection now correctly excludes non-signing endpoints (DeepSeek, Z.AI), which replay unsigned blocks verbatim and never validate thinking presence, and signed-but-empty blocks, which `convertAnthropicMessages` forwards natively via the signed-thinking path. The original detection treated empty text as always unreplayable, which degraded valid reasoning on non-signing endpoints and dropped natively replayable signed-empty blocks (#4172).
8
14
 
9
15
  ## [0.12.21] - 2026-08-09
10
16
 
@@ -1,11 +1,12 @@
1
1
  import type { Model, OpenAICompat } from "./types";
2
2
  type ResolvedToolStrictMode = NonNullable<OpenAICompat["toolStrictMode"]> | "mixed";
3
- export type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport" | "supportsResponsesSessionAffinity" | "reservedToolNames">> & {
3
+ export type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport" | "supportsResponsesSessionAffinity" | "supportsServiceTier" | "reservedToolNames">> & {
4
4
  openRouterRouting?: OpenAICompat["openRouterRouting"];
5
5
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
6
6
  extraBody?: OpenAICompat["extraBody"];
7
7
  toolStrictMode: ResolvedToolStrictMode;
8
8
  supportsResponsesSessionAffinity?: OpenAICompat["supportsResponsesSessionAffinity"];
9
+ supportsServiceTier?: OpenAICompat["supportsServiceTier"];
9
10
  /** Optional explicit capability override; resolved via deriveToolChoiceSupport. */
10
11
  toolChoiceSupport?: OpenAICompat["toolChoiceSupport"];
11
12
  };
@@ -79,7 +79,7 @@ type CommonSamplingOptions = Pick<StreamOptions, "temperature" | "topP" | "topK"
79
79
  * Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
80
80
  * temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
81
81
  */
82
- export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, provider: string): void;
82
+ export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, provider: string, supportsServiceTier?: boolean): void;
83
83
  type ReasoningOptions = {
84
84
  reasoning?: string;
85
85
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
@@ -111,10 +111,16 @@ export type ResolvedServiceTier = Exclude<ServiceTier, "openai-only" | "claude-o
111
111
  export declare function resolveServiceTier(serviceTier: ServiceTier | null | undefined, provider: Provider | undefined): ResolvedServiceTier | undefined;
112
112
  /**
113
113
  * True when the (possibly scoped) tier should be sent as an OpenAI-compatible
114
- * `service_tier` request field for providers that support it. Unsupported tiers
115
- * (`"auto"`, `"default"`) and scope mismatches all return false.
114
+ * `service_tier` request field. Custom providers must explicitly opt in through
115
+ * `compat.supportsServiceTier`; unknown providers remain fail-closed.
116
116
  */
117
- export declare function shouldSendServiceTier(serviceTier: ServiceTier | null | undefined, provider: Provider | undefined): boolean;
117
+ export declare function shouldSendServiceTier(serviceTier: ServiceTier | null | undefined, provider: Provider | undefined, supportsServiceTier?: boolean): boolean;
118
+ /**
119
+ * True when a priority tier is realized as a fast-mode request on the provider's
120
+ * wire protocol. Custom OpenAI-compatible proxies opt in explicitly rather than
121
+ * inheriting support merely because their API shape resembles OpenAI.
122
+ */
123
+ export declare function isFastModeEffectiveForProvider(serviceTier: ServiceTier | null | undefined, provider: Provider | undefined, supportsServiceTier?: boolean): boolean;
118
124
  /**
119
125
  * Premium-request weight contributed by sending priority to a provider
120
126
  * that supports it. Mirrors GitHub Copilot's `premiumRequests` accounting
@@ -718,6 +724,12 @@ export interface OpenAICompat extends ToolChoiceCompat {
718
724
  * HTTPS origin automatically; known non-OpenAI providers remain excluded.
719
725
  */
720
726
  supportsResponsesSessionAffinity?: boolean;
727
+ /**
728
+ * Whether an OpenAI-compatible endpoint accepts the `service_tier` request
729
+ * field. Disabled by default for custom providers; opt in only when the proxy
730
+ * preserves or intentionally realizes OpenAI priority processing.
731
+ */
732
+ supportsServiceTier?: boolean;
721
733
  /**
722
734
  * Tool names the provider reserves for its own built-ins and refuses to
723
735
  * accept as custom function declarations. A colliding tool is **dropped**
@@ -957,3 +969,5 @@ export interface Model<TApi extends Api = any> {
957
969
  */
958
970
  isOAuth?: boolean;
959
971
  }
972
+ /** True when a model explicitly opts into OpenAI-compatible `service_tier` forwarding. */
973
+ export declare function modelSupportsServiceTier(model: Pick<Model, "compat"> | undefined): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.13.0",
4
+ "version": "0.13.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",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/utils": "0.13.0",
43
+ "@gajae-code/utils": "0.13.2",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -12,6 +12,7 @@ export type ResolvedOpenAICompat = Required<
12
12
  | "toolStrictMode"
13
13
  | "toolChoiceSupport"
14
14
  | "supportsResponsesSessionAffinity"
15
+ | "supportsServiceTier"
15
16
  | "reservedToolNames"
16
17
  >
17
18
  > & {
@@ -20,6 +21,7 @@ export type ResolvedOpenAICompat = Required<
20
21
  extraBody?: OpenAICompat["extraBody"];
21
22
  toolStrictMode: ResolvedToolStrictMode;
22
23
  supportsResponsesSessionAffinity?: OpenAICompat["supportsResponsesSessionAffinity"];
24
+ supportsServiceTier?: OpenAICompat["supportsServiceTier"];
23
25
  /** Optional explicit capability override; resolved via deriveToolChoiceSupport. */
24
26
  toolChoiceSupport?: OpenAICompat["toolChoiceSupport"];
25
27
  };
@@ -282,6 +284,7 @@ export function resolveOpenAICompat(
282
284
  ("supportsResponsesSessionAffinity" in model.compat
283
285
  ? model.compat.supportsResponsesSessionAffinity
284
286
  : undefined) ?? detected.supportsResponsesSessionAffinity,
287
+ supportsServiceTier: model.compat.supportsServiceTier ?? detected.supportsServiceTier,
285
288
  supportsMultipleSystemMessages:
286
289
  model.compat.supportsMultipleSystemMessages ?? detected.supportsMultipleSystemMessages,
287
290
  supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
@@ -2369,6 +2369,46 @@ function hasNativeThinkingBlocks(messages: MessageParam[]): boolean {
2369
2369
  );
2370
2370
  }
2371
2371
 
2372
+ /**
2373
+ * Would the latest assistant turn lose a thinking block on its way to the wire?
2374
+ *
2375
+ * `convertAnthropicMessages` can only replay a `thinking` block natively when it
2376
+ * still carries the bytes Anthropic signed. A block that arrived as a bare
2377
+ * start/stop pair — no `thinking_delta`, no `signature_delta` — has neither, so
2378
+ * it is silently dropped, and Anthropic rejects the turn it produced for coming
2379
+ * back without it. Same for a `redactedThinking` block whose opaque payload is
2380
+ * gone. Only the latest assistant message is inspected because that is the turn
2381
+ * Anthropic validates against its own output.
2382
+ */
2383
+ function latestAssistantThinkingIsUnreplayable(messages: Message[], model: Model<"anthropic-messages">): boolean {
2384
+ const index = messages.findLastIndex(message => message.role === "assistant");
2385
+ if (index < 0) return false;
2386
+
2387
+ const assistant = messages[index] as AssistantMessage;
2388
+ // Cross-API history degrades to text rather than replaying native blocks, so
2389
+ // nothing is lost and nothing needs repairing.
2390
+ if (assistant.api !== "anthropic-messages") return false;
2391
+ // Endpoints that never sign thinking replay unsigned blocks verbatim.
2392
+ const requiresSignature = !isNonSigningAnthropicEndpoint(model);
2393
+
2394
+ return assistant.content.some(block => {
2395
+ if (block.type === "redactedThinking") return block.data.trim().length === 0;
2396
+ if (block.type !== "thinking") return false;
2397
+ // A block with empty text and no signature cannot go back on the wire:
2398
+ // `convertAnthropicMessages` drops it, and Anthropic rejects the turn for
2399
+ // arriving without it. A block with a valid signature AND non-empty text is
2400
+ // replayable. But a signed block whose text was emptied — e.g. by
2401
+ // clear_thinking_20251015 — carries a stale signature that signing endpoints
2402
+ // reject on replay (issue #4247). Non-signing endpoints replay unsigned
2403
+ // blocks verbatim, so only they treat a missing signature as unreplayable.
2404
+ const hasSignature = !!block.thinkingSignature?.trim();
2405
+ const isEmpty = !block.thinking.trim();
2406
+ if (!hasSignature) return requiresSignature;
2407
+ if (isEmpty && requiresSignature) return true;
2408
+ return false;
2409
+ });
2410
+ }
2411
+
2372
2412
  function mapAnthropicToolChoice(
2373
2413
  toolChoice: NonNullable<ResolveToolChoiceResult["resolvedChoice"]>,
2374
2414
  isOAuthToken: boolean,
@@ -2727,6 +2767,27 @@ function buildParams(
2727
2767
  });
2728
2768
  }
2729
2769
 
2770
+ // Anthropic compares the latest assistant message against the turn it actually
2771
+ // produced, and rejects it when a `thinking`/`redacted_thinking` block that was
2772
+ // in that response is missing. A block Anthropic streamed as a start/stop pair
2773
+ // with no `thinking_delta` and no `signature_delta` lands in history empty and
2774
+ // unsigned, and `convertAnthropicMessages` then drops it: the turn goes back
2775
+ // carrying only its `tool_use`, and the request is rejected before a token
2776
+ // streams. The rejection is recoverable — the repair drops native thinking from
2777
+ // the whole replay — but only after a full round trip has been spent, and the
2778
+ // condition is visible locally, so detect it here and degrade in the first
2779
+ // build instead of paying for the 400 to discover it.
2780
+ if (
2781
+ !thinkingRepair?.repairAllAssistantThinking &&
2782
+ latestAssistantThinkingIsUnreplayable(context.messages, model) &&
2783
+ hasNativeThinkingBlocks(params.messages)
2784
+ ) {
2785
+ params.messages = convertAnthropicMessages(context.messages, model, isOAuthToken, {
2786
+ ...thinkingRepair,
2787
+ repairAllAssistantThinking: true,
2788
+ });
2789
+ }
2790
+
2730
2791
  const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
2731
2792
  const billingSystemPrompts = normalizeSystemPrompts(context.systemPrompt);
2732
2793
  const billingPayload = shouldInjectClaudeCodeInstruction
@@ -1335,7 +1335,7 @@ function buildParams(
1335
1335
  if (options?.frequencyPenalty !== undefined) {
1336
1336
  params.frequency_penalty = options.frequencyPenalty;
1337
1337
  }
1338
- if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
1338
+ if (shouldSendServiceTier(options?.serviceTier, model.provider, compat.supportsServiceTier === true)) {
1339
1339
  const resolved = resolveServiceTier(options?.serviceTier, model.provider);
1340
1340
  if (resolved === "flex" || resolved === "scale" || resolved === "priority") {
1341
1341
  params.service_tier = resolved;
@@ -141,6 +141,7 @@ function normalizeModel(row: CatalogRow, endpoint: OpenCodexEndpoint): Model<"op
141
141
  api: "openai-responses",
142
142
  provider: "opencodex",
143
143
  baseUrl: `${endpoint.baseUrl}/v1`,
144
+ compat: { supportsServiceTier: true },
144
145
  reasoning: row.reasoning !== false,
145
146
  input,
146
147
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -920,6 +920,7 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
920
920
  params: P,
921
921
  options: CommonSamplingOptions | undefined,
922
922
  provider: string,
923
+ supportsServiceTier = false,
923
924
  ): void {
924
925
  if (options?.maxTokens) params.max_output_tokens = options.maxTokens;
925
926
  if (options?.temperature !== undefined) params.temperature = options.temperature;
@@ -928,7 +929,7 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
928
929
  if (options?.minP !== undefined) params.min_p = options.minP;
929
930
  if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
930
931
  if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
931
- if (shouldSendServiceTier(options?.serviceTier, provider)) {
932
+ if (shouldSendServiceTier(options?.serviceTier, provider, supportsServiceTier)) {
932
933
  const resolved = resolveServiceTier(options?.serviceTier, provider);
933
934
  if (resolved === "flex" || resolved === "scale" || resolved === "priority") {
934
935
  params.service_tier = resolved;
@@ -709,7 +709,7 @@ function buildParams(
709
709
  stream_options: model.provider === "openai" ? { include_obfuscation: false } : undefined,
710
710
  };
711
711
 
712
- applyCommonResponsesSamplingParams(params, options, model.provider);
712
+ applyCommonResponsesSamplingParams(params, options, model.provider, model.compat?.supportsServiceTier === true);
713
713
  // TODO: openai responses has no top-level `stop`/`stop_sequences`; surface via reasoning.stop?
714
714
  // `StreamOptions.stopSequences` is intentionally dropped for this provider.
715
715
  // TODO: openai responses has no top-level `frequency_penalty` field as of the current SDK;
@@ -98,8 +98,15 @@ export function transformMessages<TApi extends Api>(
98
98
  if (dropAssistantThinkingForRepair && replaysAsNativeThinking) return [];
99
99
  if (mustPreserveLatestAnthropicThinking) return sanitized;
100
100
  // For same model: keep thinking blocks with signatures (needed for replay)
101
- // even if the thinking text is empty (OpenAI encrypted reasoning)
102
- if (isSameModel && sanitized.thinkingSignature) return sanitized;
101
+ // even if the thinking text is empty — but only for non-Anthropic APIs where
102
+ // the signature represents OpenAI encrypted reasoning. For anthropic-messages,
103
+ // a signed block with empty text means clear_thinking_20251015 stripped the
104
+ // content server-side while the stale signature remained; replaying it
105
+ // produces `thinking ... cannot be modified` 400s on every turn (#4247).
106
+ if (isSameModel && sanitized.thinkingSignature) {
107
+ if (sanitized.thinking.trim() === "" && model.api === "anthropic-messages") return [];
108
+ return sanitized;
109
+ }
103
110
  // Skip empty thinking blocks, convert others to plain text
104
111
  if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
105
112
  if (isSameModel) return sanitized;
package/src/types.ts CHANGED
@@ -257,19 +257,40 @@ export function resolveServiceTier(
257
257
 
258
258
  /**
259
259
  * True when the (possibly scoped) tier should be sent as an OpenAI-compatible
260
- * `service_tier` request field for providers that support it. Unsupported tiers
261
- * (`"auto"`, `"default"`) and scope mismatches all return false.
260
+ * `service_tier` request field. Custom providers must explicitly opt in through
261
+ * `compat.supportsServiceTier`; unknown providers remain fail-closed.
262
262
  */
263
263
  export function shouldSendServiceTier(
264
264
  serviceTier: ServiceTier | null | undefined,
265
265
  provider: Provider | undefined,
266
+ supportsServiceTier = false,
266
267
  ): boolean {
267
268
  const resolved = resolveServiceTier(serviceTier, provider);
268
269
  if (provider === "deepinfra") return resolved === "priority";
269
- if (provider !== "openai" && provider !== "openai-codex") return false;
270
+ if (provider !== "openai" && provider !== "openai-codex" && !supportsServiceTier) return false;
270
271
  return resolved === "flex" || resolved === "scale" || resolved === "priority";
271
272
  }
272
273
 
274
+ /**
275
+ * True when a priority tier is realized as a fast-mode request on the provider's
276
+ * wire protocol. Custom OpenAI-compatible proxies opt in explicitly rather than
277
+ * inheriting support merely because their API shape resembles OpenAI.
278
+ */
279
+ export function isFastModeEffectiveForProvider(
280
+ serviceTier: ServiceTier | null | undefined,
281
+ provider: Provider | undefined,
282
+ supportsServiceTier = false,
283
+ ): boolean {
284
+ if (resolveServiceTier(serviceTier, provider) !== "priority") return false;
285
+ return (
286
+ provider === "openai" ||
287
+ provider === "openai-codex" ||
288
+ provider === "anthropic" ||
289
+ provider === "deepinfra" ||
290
+ supportsServiceTier
291
+ );
292
+ }
293
+
273
294
  /**
274
295
  * Premium-request weight contributed by sending priority to a provider
275
296
  * that supports it. Mirrors GitHub Copilot's `premiumRequests` accounting
@@ -282,12 +303,7 @@ export function getPriorityPremiumRequests(
282
303
  serviceTier: ServiceTier | null | undefined,
283
304
  provider: Provider | undefined,
284
305
  ): number {
285
- if (resolveServiceTier(serviceTier, provider) !== "priority") return 0;
286
- // Only providers that realize `priority` on the wire bill the user.
287
- // Everywhere else, the field is silently dropped and nothing is charged.
288
- return provider === "openai" || provider === "openai-codex" || provider === "anthropic" || provider === "deepinfra"
289
- ? 1
290
- : 0;
306
+ return isFastModeEffectiveForProvider(serviceTier, provider) ? 1 : 0;
291
307
  }
292
308
 
293
309
  export interface ProviderSessionState {
@@ -887,6 +903,12 @@ export interface OpenAICompat extends ToolChoiceCompat {
887
903
  * HTTPS origin automatically; known non-OpenAI providers remain excluded.
888
904
  */
889
905
  supportsResponsesSessionAffinity?: boolean;
906
+ /**
907
+ * Whether an OpenAI-compatible endpoint accepts the `service_tier` request
908
+ * field. Disabled by default for custom providers; opt in only when the proxy
909
+ * preserves or intentionally realizes OpenAI priority processing.
910
+ */
911
+ supportsServiceTier?: boolean;
890
912
  /**
891
913
  * Tool names the provider reserves for its own built-ins and refuses to
892
914
  * accept as custom function declarations. A colliding tool is **dropped**
@@ -1148,3 +1170,8 @@ export interface Model<TApi extends Api = any> {
1148
1170
  */
1149
1171
  isOAuth?: boolean;
1150
1172
  }
1173
+
1174
+ /** True when a model explicitly opts into OpenAI-compatible `service_tier` forwarding. */
1175
+ export function modelSupportsServiceTier(model: Pick<Model, "compat"> | undefined): boolean {
1176
+ return Boolean(model?.compat && "supportsServiceTier" in model.compat && model.compat.supportsServiceTier === true);
1177
+ }