@gajae-code/ai 0.18.12 → 0.19.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.
@@ -117,6 +117,14 @@ export interface CpaToolAliasRestoreFailure {
117
117
  */
118
118
  export declare function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined;
119
119
  export declare function isCpaToolAliasRestoreFailure(error: unknown): boolean;
120
+ /**
121
+ * How many breakpoints we are still willing to generate after a gateway has
122
+ * rejected a previous attempt. `explicit` mode normally emits two (a reusable
123
+ * prefix anchor on the last assistant turn plus a refresh point on the current
124
+ * user turn), so stepping down to one still caches the prefix, and only the
125
+ * final step gives caching up entirely.
126
+ */
127
+ type GeneratedCacheBudget = 2 | 1 | 0;
120
128
  export declare const claudeCodeEntrypoint = "sdk-cli";
121
129
  export declare const claudeToolPrefix: string;
122
130
  export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -228,6 +236,8 @@ export type AnthropicClientOptionsResult = {
228
236
  fetchOptions?: AnthropicSdkClientOptions["fetchOptions"];
229
237
  };
230
238
  export declare function resolveGlmZcodeAnthropicBaseUrl(): string;
239
+ /** Compresses request bodies with gzip for first-party Anthropic endpoints only. */
240
+ export declare function wrapAnthropicFetchForGzip(baseFetch: FetchImpl): FetchImpl;
231
241
  export declare function isProviderRetryableError(error: unknown, provider?: string): boolean;
232
242
  export type AnthropicUsageLike = {
233
243
  cache_creation?: {
@@ -261,6 +271,11 @@ type SystemBlockOptions = {
261
271
  export declare function buildAnthropicSystemBlocks(systemPrompt: readonly string[] | undefined, options?: SystemBlockOptions): AnthropicSystemBlock[] | undefined;
262
272
  export declare function normalizeExtraBetas(betas?: string[] | string): string[];
263
273
  export declare function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): AnthropicClientOptionsResult;
274
+ type AnthropicCacheParams = MessageCreateParamsStreaming & {
275
+ cache_control?: AnthropicCacheControl;
276
+ };
277
+ type AnthropicCacheMode = "automatic" | "explicit" | "none";
278
+ export declare function applyPromptCaching(params: AnthropicCacheParams, cacheMode: AnthropicCacheMode, cacheControl?: AnthropicCacheControl, budget?: GeneratedCacheBudget): void;
264
279
  export declare function normalizeCacheControlTtlOrdering(params: MessageCreateParamsStreaming): void;
265
280
  export declare function convertAnthropicMessages(messages: Message[], model: Model<"anthropic-messages">, isOAuthToken: boolean, options?: {
266
281
  repairLatestAssistantThinking?: boolean;
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import { scheduler } from "node:timers/promises";
5
5
  import * as tls from "node:tls";
6
+ import { gzipSync } from "node:zlib";
6
7
  import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk";
7
8
  import type {
8
9
  ContentBlockParam,
@@ -34,8 +35,9 @@ import {
34
35
  } from "../adapter-internals/provider-safety-stop";
35
36
  import {
36
37
  getMiniMaxThinkingMode,
37
- hasOpus47ApiRestrictions,
38
+ hasAnthropicModelSamplingRestrictions,
38
39
  mapEffortToAnthropicAdaptiveEffort,
40
+ requiresAnthropicThinkingDisableWhenOff,
39
41
  supportsAnthropicAdaptiveThinkingDisplay as supportsAdaptiveThinkingDisplay,
40
42
  } from "../model-thinking";
41
43
  import { calculateCost } from "../models";
@@ -1586,6 +1588,51 @@ function wrapAnthropicFetchForBoundedRateLimits(baseFetch: FetchImpl, maxRetryDe
1586
1588
  );
1587
1589
  }
1588
1590
 
1591
+ /** Body bytes for a request `BodyInit`, or `undefined` when the body cannot be inspected. */
1592
+ function requestBodyBuffer(body: RequestInit["body"]): Buffer | undefined {
1593
+ if (typeof body === "string") return Buffer.from(body, "utf-8");
1594
+ if (body instanceof ArrayBuffer) return Buffer.from(body);
1595
+ if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
1596
+ return undefined;
1597
+ }
1598
+
1599
+ /** Compresses request bodies with gzip for first-party Anthropic endpoints only. */
1600
+ export function wrapAnthropicFetchForGzip(baseFetch: FetchImpl): FetchImpl {
1601
+ return Object.assign(
1602
+ async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
1603
+ const url = new URL(input instanceof Request ? input.url : String(input), "https://api.anthropic.com");
1604
+ const isFirstPartyAnthropicApi =
1605
+ url.protocol.toLowerCase() === "https:" &&
1606
+ url.hostname.toLowerCase() === "api.anthropic.com" &&
1607
+ (url.port === "" || url.port === "443");
1608
+ const bodyBuffer = init?.body ? requestBodyBuffer(init.body) : undefined;
1609
+ if (!isFirstPartyAnthropicApi || !bodyBuffer) return baseFetch(input, init);
1610
+
1611
+ let compressedBody: Buffer;
1612
+ try {
1613
+ compressedBody = gzipSync(bodyBuffer);
1614
+ } catch (error) {
1615
+ logger.debug("anthropic: gzip compression failed, falling back to uncompressed", {
1616
+ error: error instanceof Error ? error.message : String(error),
1617
+ });
1618
+ return baseFetch(input, init);
1619
+ }
1620
+
1621
+ // The caller's Content-Length describes the uncompressed body; drop it so
1622
+ // fetch recomputes the length for the compressed payload.
1623
+ const headers = new Headers(init?.headers ?? {});
1624
+ headers.set("Content-Encoding", "gzip");
1625
+ headers.delete("Content-Length");
1626
+ logger.debug("anthropic: gzip compression", {
1627
+ uncompressed: bodyBuffer.length,
1628
+ compressed: compressedBody.length,
1629
+ });
1630
+ return baseFetch(input, { ...init, body: compressedBody, headers });
1631
+ },
1632
+ baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {},
1633
+ );
1634
+ }
1635
+
1589
1636
  // The Anthropic SDK logs malformed SSE frames directly before rethrowing them.
1590
1637
  // We surface the resulting provider error ourselves, so keep the SDK quiet.
1591
1638
  const ANTHROPIC_SDK_LOG_LEVEL = "off" as const;
@@ -2289,6 +2336,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2289
2336
  }
2290
2337
  if (droppedForcedToolChoice) {
2291
2338
  delete nextParams.tool_choice;
2339
+ if (options?.thinkingEnabled === false && requiresAnthropicThinkingDisableWhenOff(model.id)) {
2340
+ nextParams.thinking = { type: "disabled" };
2341
+ }
2292
2342
  }
2293
2343
  if (disableStrictTools) {
2294
2344
  dropAnthropicStrictTools(nextParams);
@@ -3477,8 +3527,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
3477
3527
  const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
3478
3528
  const baseFetch = args.fetch ?? fetch;
3479
3529
  const boundedFetch = wrapAnthropicFetchForBoundedRateLimits(baseFetch, args.maxRetryDelayMs);
3530
+ const gzipFetch = wrapAnthropicFetchForGzip(boundedFetch);
3480
3531
  const openCodeGoSessionId = resolveOpenCodeGoSessionId(model, baseUrl, args.providerSessionId, "anthropic");
3481
- const providerScopedFetch = wrapFetchForOpenCodeGoSession(boundedFetch, openCodeGoSessionId);
3532
+ const providerScopedFetch = wrapFetchForOpenCodeGoSession(gzipFetch, openCodeGoSessionId);
3482
3533
  const debugFetch = onSseEvent
3483
3534
  ? wrapFetchForSseDebug(providerScopedFetch, event => onSseEvent(event, model))
3484
3535
  : providerScopedFetch;
@@ -3614,11 +3665,22 @@ function createClient(
3614
3665
  * request drops `thinking`/`output_config`. Reports whether the forced-choice branch
3615
3666
  * applied so the caller can keep the replayed history consistent with it.
3616
3667
  */
3617
- function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming): boolean {
3668
+ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming, modelId: string): boolean {
3618
3669
  const toolChoice = params.tool_choice;
3619
3670
  if (!toolChoice) return false;
3620
3671
  if (toolChoice.type !== "any" && toolChoice.type !== "tool") return false;
3621
- delete params.thinking;
3672
+ if (requiresAnthropicThinkingDisableWhenOff(modelId)) {
3673
+ // Haiku 5+ enables adaptive thinking when `thinking` is omitted, which a forced tool
3674
+ // choice rejects, and applies `output_config.effort` to all response tokens including
3675
+ // tool-call arguments. Send an explicit `disabled` and keep the selected effort;
3676
+ // effort is capped at `high` while thinking is disabled.
3677
+ params.thinking = { type: "disabled" };
3678
+ const effort = params.output_config?.effort;
3679
+ if (effort === "xhigh" || effort === "max") params.output_config = { ...params.output_config, effort: "high" };
3680
+ return true;
3681
+ }
3682
+ // An explicit `disabled` is compatible with forced tool use and is kept.
3683
+ if (params.thinking?.type !== "disabled") delete params.thinking;
3622
3684
  delete params.output_config;
3623
3685
  return true;
3624
3686
  }
@@ -3862,7 +3924,7 @@ function applyExplicitPromptCaching(
3862
3924
  }
3863
3925
  }
3864
3926
 
3865
- function applyPromptCaching(
3927
+ export function applyPromptCaching(
3866
3928
  params: AnthropicCacheParams,
3867
3929
  cacheMode: AnthropicCacheMode,
3868
3930
  cacheControl?: AnthropicCacheControl,
@@ -3871,9 +3933,22 @@ function applyPromptCaching(
3871
3933
  if (!cacheControl || cacheMode === "none" || budget === 0) return;
3872
3934
  validateCacheControls(params);
3873
3935
  if (cacheMode === "automatic") {
3874
- // Automatic mode only ever emits one marker, so any non-zero budget
3875
- // covers it; the zero case already returned above.
3876
- params.cache_control = { ...cacheControl };
3936
+ // A caller layout that already holds four block markers leaves no room for a new root marker;
3937
+ // adding one anyway made validateCacheControls reject the request. An existing root is still replaced.
3938
+ if (params.cache_control || countCacheControlBreakpoints(params) < 4) {
3939
+ params.cache_control = { ...cacheControl };
3940
+ }
3941
+ // #2383's moving breakpoint reasoning holds within a session, not across
3942
+ // sessions: anchor tools + system so new messages can reuse that prefix.
3943
+ if (
3944
+ budget > 1 &&
3945
+ Array.isArray(params.system) &&
3946
+ !params.system.some(block => block.cache_control) &&
3947
+ countCacheControlBreakpoints(params) < 4
3948
+ ) {
3949
+ const lastBlock = params.system.at(-1);
3950
+ if (lastBlock) lastBlock.cache_control = { ...cacheControl };
3951
+ }
3877
3952
  return;
3878
3953
  }
3879
3954
  applyExplicitPromptCaching(params, cacheControl, budget);
@@ -3947,8 +4022,8 @@ function buildParams(
3947
4022
  seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX ? seqs.slice(0, ANTHROPIC_STOP_SEQUENCES_MAX) : seqs;
3948
4023
  }
3949
4024
 
3950
- // Opus 4.7+ rejects non-default sampling parameters with 400 error.
3951
- if (hasOpus47ApiRestrictions(model.id)) {
4025
+ // Opus 4.7+ and Haiku 5+ reject non-default sampling parameters.
4026
+ if (hasAnthropicModelSamplingRestrictions(model.id)) {
3952
4027
  delete params.top_p;
3953
4028
  delete params.top_k;
3954
4029
  delete params.temperature;
@@ -3968,7 +4043,9 @@ function buildParams(
3968
4043
  }
3969
4044
 
3970
4045
  const miniMaxMode = getMiniMaxThinkingMode(model, baseUrl);
3971
- if (model.reasoning && miniMaxMode === "toggle") {
4046
+ if (options?.thinkingEnabled === false && requiresAnthropicThinkingDisableWhenOff(model.id)) {
4047
+ params.thinking = { type: "disabled" };
4048
+ } else if (model.reasoning && miniMaxMode === "toggle") {
3972
4049
  // MiniMax's Messages endpoint defaults to off and only supports a
3973
4050
  // switch. Claude budget_tokens/output_config.effort do not apply.
3974
4051
  if (options?.thinkingEnabled !== undefined) {
@@ -3984,8 +4061,8 @@ function buildParams(
3984
4061
 
3985
4062
  const compat = getAnthropicCompat(model);
3986
4063
  if (mode === "anthropic-adaptive" && !compat.disableAdaptiveThinking) {
3987
- // Starting with Anthropic model Opus 4.7, adaptive thinking content is omitted from the
3988
- // response by default. Opt into summarized reasoning so thinking deltas keep
4064
+ // Opus 4.7+, Fable, and Haiku 5+ omit adaptive thinking content by
4065
+ // default. Opt into summarized reasoning so thinking deltas keep
3989
4066
  // streaming with human-readable content for callers that rely on it.
3990
4067
  const adaptive: { type: "adaptive"; display?: AnthropicThinkingDisplay } = { type: "adaptive" };
3991
4068
  if (supportsAdaptiveThinkingDisplay(model.id)) {
@@ -4043,7 +4120,7 @@ function buildParams(
4043
4120
  // that pair with `thinking`/`redacted_thinking` blocks "cannot be modified", so the
4044
4121
  // replay has to degrade in the same rebuild. Runs before the billing/system payload
4045
4122
  // snapshot so the attribution hash covers the messages actually sent.
4046
- if (disableThinkingIfToolChoiceForced(params) && hasNativeThinkingBlocks(params.messages)) {
4123
+ if (disableThinkingIfToolChoiceForced(params, model.id) && hasNativeThinkingBlocks(params.messages)) {
4047
4124
  params.messages = convertAnthropicMessages(context.messages, model, isOAuthToken, {
4048
4125
  ...thinkingRepair,
4049
4126
  repairAllAssistantThinking: true,
@@ -42,7 +42,7 @@ export declare const ANTIGRAVITY_SYSTEM_INSTRUCTION = "You are Antigravity, a po
42
42
  *
43
43
  * Overridable via PI_AI_ANTIGRAVITY_USER_AGENT environment variable.
44
44
  */
45
- export declare let getAntigravityUserAgent: () => string;
45
+ export declare function getAntigravityUserAgent(): string;
46
46
  export declare const getAntigravityRequestHeaders: () => {
47
47
  "User-Agent": string;
48
48
  };
@@ -93,12 +93,12 @@ This means you do **NOT** need to poll in a loop while waiting for messages or u
93
93
  *
94
94
  * Overridable via PI_AI_ANTIGRAVITY_USER_AGENT environment variable.
95
95
  */
96
- export let getAntigravityUserAgent = () => {
97
- const override = process.env.PI_AI_ANTIGRAVITY_USER_AGENT;
98
- const userAgent = override || "antigravity/cli/1.3.4 (os_type=darwin; arch=arm64; aidev_client; auth_method=oauth)";
99
- getAntigravityUserAgent = () => userAgent;
100
- return userAgent;
101
- };
96
+ export function getAntigravityUserAgent(): string {
97
+ return (
98
+ process.env.PI_AI_ANTIGRAVITY_USER_AGENT ||
99
+ "antigravity/cli/1.3.4 (os_type=darwin; arch=arm64; aidev_client; auth_method=oauth)"
100
+ );
101
+ }
102
102
 
103
103
  export const getAntigravityRequestHeaders = () => ({
104
104
  "User-Agent": getAntigravityUserAgent(),
@@ -138,6 +138,34 @@ function compressRepeatedThinkingParagraphs(thinking: string): string {
138
138
  return compressed ? compressedParts.join("") : thinking;
139
139
  }
140
140
 
141
+ /** Escape untrusted text placed between the stale-tool-result tags. */
142
+ function escapeXmlText(input: string): string {
143
+ if (!/[&<>]/.test(input)) return input;
144
+ let output = "";
145
+ for (const char of input) {
146
+ if (char === "&") output += "&amp;";
147
+ else if (char === "<") output += "&lt;";
148
+ else if (char === ">") output += "&gt;";
149
+ else output += char;
150
+ }
151
+ return output;
152
+ }
153
+
154
+ /** Escape an untrusted tool name or id used in a double-quoted attribute. */
155
+ function escapeXmlAttribute(input: string): string {
156
+ if (!/[&<>"']/.test(input)) return input;
157
+ let output = "";
158
+ for (const char of input) {
159
+ if (char === "&") output += "&amp;";
160
+ else if (char === "<") output += "&lt;";
161
+ else if (char === ">") output += "&gt;";
162
+ else if (char === '"') output += "&quot;";
163
+ else if (char === "'") output += "&apos;";
164
+ else output += char;
165
+ }
166
+ return output;
167
+ }
168
+
141
169
  export function transformMessages<TApi extends Api>(
142
170
  messages: Message[],
143
171
  model: Model<TApi>,
@@ -432,9 +460,12 @@ export function transformMessages<TApi extends Api>(
432
460
  }
433
461
  if (textParts.length > 0) {
434
462
  const errorAttr = msg.isError ? ' is-error="true"' : "";
463
+ const toolAttr = escapeXmlAttribute(msg.toolName);
464
+ const idAttr = escapeXmlAttribute(msg.toolCallId);
465
+ const body = escapeXmlText(textParts.join("\n"));
435
466
  result.push({
436
467
  role: "user",
437
- content: `<stale-tool-result tool="${msg.toolName}" id="${msg.toolCallId}"${errorAttr}>\n${textParts.join("\n")}\n</stale-tool-result>`,
468
+ content: `<stale-tool-result tool="${toolAttr}" id="${idAttr}"${errorAttr}>\n${body}\n</stale-tool-result>`,
438
469
  timestamp: messageTimestamp,
439
470
  } as UserMessage);
440
471
  }
package/src/stream.ts CHANGED
@@ -1005,7 +1005,7 @@ function resolveBedrockThinkingBudget(
1005
1005
  model: Model<"bedrock-converse-stream">,
1006
1006
  options?: SimpleStreamOptions,
1007
1007
  ): { budget: number; level: Effort } | null {
1008
- if (!options?.reasoning || !model.reasoning) return null;
1008
+ if (!options?.reasoning || options.disableReasoning || !model.reasoning) return null;
1009
1009
  const level = requireSupportedEffort(model, options.reasoning);
1010
1010
  const budget = options.thinkingBudgets?.[level] ?? BEDROCK_CLAUDE_THINKING[level];
1011
1011
  return { budget, level };
@@ -1133,7 +1133,7 @@ function mapOptionsForApi<TApi extends Api>(
1133
1133
  });
1134
1134
  }
1135
1135
  // Explicitly disable thinking when reasoning is not specified or model doesn't support it
1136
- const reasoning = options?.reasoning;
1136
+ const reasoning = options?.disableReasoning ? undefined : options?.reasoning;
1137
1137
  if (!reasoning || !model.reasoning) {
1138
1138
  return castApi<"anthropic-messages">({
1139
1139
  ...base,
@@ -1144,7 +1144,7 @@ function mapOptionsForApi<TApi extends Api>(
1144
1144
  });
1145
1145
  }
1146
1146
 
1147
- let thinkingBudget = options.thinkingBudgets?.[reasoning] ?? ANTHROPIC_THINKING[reasoning];
1147
+ let thinkingBudget = options?.thinkingBudgets?.[reasoning] ?? ANTHROPIC_THINKING[reasoning];
1148
1148
  if (thinkingBudget <= 0) {
1149
1149
  return castApi<"anthropic-messages">({
1150
1150
  ...base,
@@ -1238,7 +1238,7 @@ function mapOptionsForApi<TApi extends Api>(
1238
1238
  case "bedrock-converse-stream": {
1239
1239
  const bedrockBase: BedrockOptions = {
1240
1240
  ...base,
1241
- reasoning: options?.reasoning,
1241
+ reasoning: options?.disableReasoning ? undefined : options?.reasoning,
1242
1242
  thinkingBudgets: options?.thinkingBudgets,
1243
1243
  toolChoice: mapAnthropicToolChoice(options?.toolChoice),
1244
1244
  thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
@@ -1,6 +1,7 @@
1
+ import { logger } from "@gajae-code/utils";
1
2
  import * as z from "zod/v4";
2
3
  import { isRetiredModelKey } from "../../model-retirements";
3
- import { getAntigravityUserAgent } from "../../providers/google-gemini-headers";
4
+ import { getAntigravityUserAgent, getGeminiCliUserAgent } from "../../providers/google-gemini-headers";
4
5
  import type { Model } from "../../types";
5
6
 
6
7
  /**
@@ -171,7 +172,7 @@ export interface FetchAntigravityDiscoveryModelsOptions {
171
172
  token: string;
172
173
  /** Optional endpoint override. Defaults to Antigravity fallback endpoints. */
173
174
  endpoint?: string;
174
- /** Deprecated and ignored for antigravity discovery parity. */
175
+ /** Optional Google Cloud project ID for tier-gated model discovery. */
175
176
  project?: string;
176
177
  /** Optional user agent override. */
177
178
  userAgent?: string;
@@ -185,6 +186,8 @@ export interface FetchAntigravityDiscoveryModelsOptions {
185
186
  * Default: `google-antigravity`.
186
187
  */
187
188
  targetProvider?: "google-antigravity" | "google-gemini-cli";
189
+ /** Optional logger for debug diagnostics. */
190
+ logger?: Pick<typeof logger, "debug">;
188
191
  }
189
192
 
190
193
  /**
@@ -199,14 +202,20 @@ export async function fetchAntigravityDiscoveryModels(
199
202
  const fetcher = options.fetcher ?? fetch;
200
203
  const signal = options.signal ?? AbortSignal.timeout(5_000);
201
204
  const targetProvider = options.targetProvider ?? "google-antigravity";
202
- const userAgent = options.userAgent ?? getAntigravityUserAgent();
205
+ const userAgent = options.userAgent ?? getAntigravityDiscoveryUserAgent();
203
206
  const endpoints = options.endpoint
204
207
  ? [trimTrailingSlashes(options.endpoint)]
205
208
  : DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS.map(trimTrailingSlashes);
209
+ const debugLogger = options.logger ?? logger;
206
210
 
207
211
  for (const endpoint of endpoints) {
212
+ const redactedEndpoint = redactAntigravityEndpoint(endpoint);
208
213
  let response: Response;
209
214
  try {
215
+ const requestBody: Record<string, unknown> = {};
216
+ if (options.project) {
217
+ requestBody.project = options.project;
218
+ }
210
219
  response = await fetcher(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, {
211
220
  method: "POST",
212
221
  headers: {
@@ -214,13 +223,19 @@ export async function fetchAntigravityDiscoveryModels(
214
223
  "Content-Type": "application/json",
215
224
  "User-Agent": userAgent,
216
225
  },
217
- body: JSON.stringify({}),
226
+ body: JSON.stringify(requestBody),
218
227
  signal,
219
228
  });
220
229
  } catch {
221
230
  continue;
222
231
  }
223
232
 
233
+ debugLogger.debug("Antigravity discovery endpoint response", {
234
+ endpoint: redactedEndpoint,
235
+ status: response.status,
236
+ statusText: response.statusText,
237
+ });
238
+
224
239
  if (!response.ok) {
225
240
  continue;
226
241
  }
@@ -256,33 +271,37 @@ export async function fetchAntigravityDiscoveryModels(
256
271
  const filteredModelDetails: Record<string, Partial<AntigravityDiscoveryApiModel> & { reason: string }> = {};
257
272
 
258
273
  const models: Model<"google-gemini-cli">[] = [];
274
+ const modelDropReasons: Record<string, string> = {};
259
275
 
260
276
  for (const [modelId, model] of Object.entries(parsed.models ?? {})) {
277
+ let dropReason: string | undefined;
278
+
261
279
  if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId)) {
262
- if (debugEnabled) {
263
- filteredByDenylist.push(modelId);
264
- filteredModelDetails[modelId] = { ...model, reason: "denylist" };
265
- }
266
- continue;
267
- }
268
- if (isRetiredModelKey(targetProvider, modelId)) {
269
- if (debugEnabled) {
270
- filteredByRetirement.push(modelId);
271
- filteredModelDetails[modelId] = { ...model, reason: `retired-${targetProvider}` };
272
- }
273
- continue;
274
- }
275
- if (model.disabled === true) {
276
- if (debugEnabled) {
277
- filteredByDisabled.push(modelId);
278
- filteredModelDetails[modelId] = { ...model, reason: "disabled" };
279
- }
280
- continue;
280
+ dropReason = "denylist";
281
+ } else if (isRetiredModelKey(targetProvider, modelId)) {
282
+ dropReason = "retired";
283
+ } else if (model.disabled === true) {
284
+ dropReason = "disabled";
285
+ } else if (model.isInternal === true && !surfacedModelIds.has(modelId)) {
286
+ dropReason = "isInternal-not-surfaced";
281
287
  }
282
- if (model.isInternal === true && !surfacedModelIds.has(modelId)) {
288
+
289
+ if (dropReason) {
290
+ modelDropReasons[modelId] = dropReason;
283
291
  if (debugEnabled) {
284
- filteredByInternal.push(modelId);
285
- filteredModelDetails[modelId] = { ...model, reason: "internal-not-surfaced" };
292
+ if (dropReason === "denylist") filteredByDenylist.push(modelId);
293
+ else if (dropReason === "retired") filteredByRetirement.push(modelId);
294
+ else if (dropReason === "disabled") filteredByDisabled.push(modelId);
295
+ else filteredByInternal.push(modelId);
296
+ filteredModelDetails[modelId] = {
297
+ ...model,
298
+ reason:
299
+ dropReason === "retired"
300
+ ? `retired-${targetProvider}`
301
+ : dropReason === "isInternal-not-surfaced"
302
+ ? "internal-not-surfaced"
303
+ : dropReason,
304
+ };
286
305
  }
287
306
  continue;
288
307
  }
@@ -317,6 +336,18 @@ export async function fetchAntigravityDiscoveryModels(
317
336
  });
318
337
  }
319
338
 
339
+ if (Object.keys(modelDropReasons).length > 0) {
340
+ debugLogger.debug("Antigravity discovery dropped models", {
341
+ endpoint: redactedEndpoint,
342
+ dropReasons: modelDropReasons,
343
+ });
344
+ }
345
+
346
+ debugLogger.debug("Antigravity discovery kept models", {
347
+ endpoint: redactedEndpoint,
348
+ keptModelIds: models.map(m => m.id),
349
+ });
350
+
320
351
  models.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
321
352
 
322
353
  if (debugEnabled) {
@@ -425,3 +456,15 @@ function toPositiveNumber(value: unknown, fallback: number): number {
425
456
  function trimTrailingSlashes(value: string): string {
426
457
  return value.replace(/\/+$/, "");
427
458
  }
459
+
460
+ export function getAntigravityDiscoveryUserAgent(): string {
461
+ return process.env.PI_AI_ANTIGRAVITY_USER_AGENT ? getAntigravityUserAgent() : getGeminiCliUserAgent();
462
+ }
463
+
464
+ function redactAntigravityEndpoint(value: string): string {
465
+ try {
466
+ return new URL(value).origin;
467
+ } catch {
468
+ return "invalid endpoint";
469
+ }
470
+ }
@@ -1,33 +1,3 @@
1
- /**
2
- * GLM ZCode OAuth flow (UNOFFICIAL, opt-in).
3
- *
4
- * Replicates how the ZCode desktop app turns a Z.AI login into usable GLM model
5
- * access. This is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
6
- * page, broker, and a custom-protocol redirect. It may break at any time and may
7
- * violate ZCode/Z.AI Terms of Service. Endpoints/client id are overridable via
8
- * `ZCODE_OAUTH_*` environment variables.
9
- *
10
- * Verified end-to-end against the ZCode host bundle (`resolveZaiApiKey` /
11
- * `resolveBizApiKey`) and live traffic:
12
- * 1. Authorize: GET {authorize}?redirect_uri=zcode://oauth/callback&response_type=code&client_id=...&state=...
13
- * (custom-protocol redirect → a CLI cannot catch it, so the user pastes the code/redirect URL)
14
- * 2. Broker: POST {broker} { provider:"zai", code, redirect_uri, state }
15
- * → { data: { token: <ZCode JWT>, zai: { access_token: <upstream Z.AI token> } } }
16
- * 3. Business: POST {z/login} { token: <upstream Z.AI token> } → { data: { access_token: <business token> } }
17
- * 4. Provision: with the business token, GET getCustomerInfo → default org/project,
18
- * GET/POST .../api_keys (find/create a key named "zcode-api-key"),
19
- * GET .../api_keys/copy/{id} → secretKey ⇒ a real Z.AI API key "{id}.{secret}".
20
- *
21
- * Credential mapping:
22
- * - `access` = the provisioned **Z.AI API key** ("{id}.{secret}"). Model requests go to
23
- * `https://api.z.ai/api/anthropic/v1/messages` with `Authorization: Bearer <key>`
24
- * (exactly like a dashboard key) — NO zcode.z.ai gateway, NO captcha.
25
- * - `refresh` = the upstream Z.AI OAuth access token (used to re-provision the key).
26
- * The API key is long-lived, so `expires` is set far in the future.
27
- *
28
- * This provider must NEVER force `isOAuth=true`: the key is a plain Z.AI API key, and
29
- * api.z.ai is not api.anthropic.com, so the Anthropic path already emits a plain bearer.
30
- */
31
1
  import { OAuthCallbackFlow } from "./callback-server";
32
2
  import type { OAuthController, OAuthCredentials } from "./types";
33
3
  export declare const GLM_ZCODE_REFRESH_SKEW_MS: number;
@@ -4,8 +4,9 @@
4
4
  * Replicates how the ZCode desktop app turns a Z.AI login into usable GLM model
5
5
  * access. This is NOT an official Z.AI OAuth client: it reuses ZCode's authorize
6
6
  * page, broker, and a custom-protocol redirect. It may break at any time and may
7
- * violate ZCode/Z.AI Terms of Service. Endpoints/client id are overridable via
8
- * `ZCODE_OAUTH_*` environment variables.
7
+ * violate ZCode/Z.AI Terms of Service. Endpoints and the client id are
8
+ * overridable via `ZCODE_OAUTH_*` from the launching shell or user-owned env
9
+ * files. A project `.env` is not a trusted source for those values.
9
10
  *
10
11
  * Verified end-to-end against the ZCode host bundle (`resolveZaiApiKey` /
11
12
  * `resolveBizApiKey`) and live traffic:
@@ -28,6 +29,7 @@
28
29
  * This provider must NEVER force `isOAuth=true`: the key is a plain Z.AI API key, and
29
30
  * api.z.ai is not api.anthropic.com, so the Anthropic path already emits a plain bearer.
30
31
  */
32
+ import { $credentialEnv } from "@gajae-code/utils";
31
33
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions, parseCallbackInput } from "./callback-server";
32
34
  import type { OAuthController, OAuthCredentials } from "./types";
33
35
 
@@ -52,9 +54,18 @@ export const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
52
54
 
53
55
  type FetchImpl = typeof globalThis.fetch;
54
56
 
57
+ /**
58
+ * Trusted override for one `ZCODE_OAUTH_*` name.
59
+ *
60
+ * These values choose where the authorization code and upstream token are sent.
61
+ * `$credentialEnv` keeps the launching shell and user-owned env files and skips
62
+ * a project `.env`. A shell value is also skipped when it repeats that file
63
+ * or the project declaration is dynamic, and when no other trusted source
64
+ * sets the name an empty value still uses the built-in endpoint.
65
+ */
55
66
  function envOr(name: string, fallback: string): string {
56
- const value = process.env[name];
57
- return value && value.trim().length > 0 ? value.trim() : fallback;
67
+ const value = $credentialEnv(name)?.trim();
68
+ return value ? value : fallback;
58
69
  }
59
70
 
60
71
  function resolveAuthorizeUrl(): string {
@@ -63,6 +63,41 @@ interface PKCE {
63
63
  challenge: string;
64
64
  }
65
65
 
66
+ /**
67
+ * Extract a readable error message from OpenAI OAuth error response.
68
+ * Handles both string errors (error + error_description) and object errors (code/type + message).
69
+ */
70
+ function extractOpenAIErrorMessage(body: unknown): string {
71
+ if (typeof body !== "object" || body === null) return "";
72
+
73
+ const obj = body as Record<string, unknown>;
74
+ const error = obj.error;
75
+
76
+ if (typeof error === "string") {
77
+ // String error: "invalid_grant", optionally explained by error_description.
78
+ // Parts are joined so an empty error string never leaves a dangling separator.
79
+ const desc = obj.error_description;
80
+ const parts = desc !== undefined && typeof desc === "string" && desc.length > 0 ? [error, desc] : [error];
81
+ return parts.filter(part => part.length > 0).join(": ");
82
+ }
83
+
84
+ if (typeof error === "object" && error !== null) {
85
+ // Object error: {message, type, code}. `code` wins over `type`, but only when
86
+ // it is a usable string — a numeric or empty code must fall back to the type
87
+ // rather than silently dropping both.
88
+ const err = error as Record<string, unknown>;
89
+ const code = typeof err.code === "string" && err.code.length > 0 ? err.code : err.type;
90
+ const msg = err.message;
91
+
92
+ const parts: string[] = [];
93
+ if (typeof code === "string" && code.length > 0) parts.push(code);
94
+ if (typeof msg === "string" && msg.length > 0) parts.push(msg);
95
+ return parts.join(": ");
96
+ }
97
+
98
+ return "";
99
+ }
100
+
66
101
  class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
67
102
  constructor(
68
103
  ctrl: OAuthController,
@@ -120,9 +155,9 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
120
155
  if (!tokenResponse.ok) {
121
156
  let detail = `${tokenResponse.status}`;
122
157
  try {
123
- const body = (await tokenResponse.json()) as { error?: string; error_description?: string };
124
- if (body.error)
125
- detail = `${tokenResponse.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
158
+ const body = await tokenResponse.json();
159
+ const errorMsg = extractOpenAIErrorMessage(body);
160
+ if (errorMsg) detail = `${tokenResponse.status} ${errorMsg}`;
126
161
  } catch {}
127
162
  throw new Error(`Token exchange failed: ${detail}`);
128
163
  }
@@ -272,9 +307,9 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
272
307
  if (!response.ok) {
273
308
  let detail = `${response.status}`;
274
309
  try {
275
- const body = (await response.json()) as { error?: string; error_description?: string };
276
- if (body.error)
277
- detail = `${response.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
310
+ const body = await response.json();
311
+ const errorMsg = extractOpenAIErrorMessage(body);
312
+ if (errorMsg) detail = `${response.status} ${errorMsg}`;
278
313
  } catch {}
279
314
  throw new Error(`OpenAI Codex token refresh failed: ${detail}`);
280
315
  }