jeo-code 0.7.27 → 0.7.29

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.
@@ -12,9 +12,14 @@ const CLAUDE_CODE_VERSION = "2.1.63";
12
12
  const CLAUDE_CODE_SYSTEM_INSTRUCTION = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
13
13
  const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
14
14
  /** Betas needed for API-key requests: interleaved-thinking enables thinking+tools,
15
- * prompt-caching-scope gives scoped cache breakpoints. */
15
+ * context-management matches gjc's default beta set (sent on ALL requests),
16
+ * prompt-caching-scope gives scoped cache breakpoints.
17
+ * ponytail: fine-grained-tool-streaming-2025-05-14 — gjc sends it and repairs truncated
18
+ * tool-argument JSON; jeo parses accumulated tool JSON without that repair pass, so the
19
+ * beta stays off until a repair step exists. */
16
20
  const ANTHROPIC_API_KEY_BETA = [
17
21
  "interleaved-thinking-2025-05-14",
22
+ "context-management-2025-06-27",
18
23
  "prompt-caching-scope-2026-01-05",
19
24
  ];
20
25
  const ANTHROPIC_OAUTH_BETA = [
@@ -46,8 +51,10 @@ function stripAnthropicPrefix(model: string): string {
46
51
  return model.startsWith("anthropic/") ? model.slice(10) : model;
47
52
  }
48
53
 
49
- function shouldUseClaudeCodeOAuthShape(model: string, credential: Credential): boolean {
50
- return credential.kind === "oauth" && !model.startsWith("claude-3-5-haiku");
54
+ function shouldUseClaudeCodeOAuthShape(model: string, credential: Credential, baseUrl?: string): boolean {
55
+ // Claude-Code OAuth cloaking is for api.anthropic.com only. An OAuth credential
56
+ // against a custom base (e.g. Kimi Code at api.kimi.com/coding) is a plain bearer.
57
+ return credential.kind === "oauth" && !baseUrl && !model.startsWith("claude-3-5-haiku");
51
58
  }
52
59
 
53
60
  function createClaudeCloakingUserId(): string {
@@ -68,9 +75,10 @@ function anthropicSystemBlocks(
68
75
  model: string,
69
76
  credential: Credential,
70
77
  billingPayload: Record<string, unknown>,
78
+ baseUrl?: string,
71
79
  ): AnthropicSystemBlock[] | undefined {
72
80
  const blocks: AnthropicSystemBlock[] = [];
73
- if (shouldUseClaudeCodeOAuthShape(model, credential)) {
81
+ if (shouldUseClaudeCodeOAuthShape(model, credential, baseUrl)) {
74
82
  const billingSeed = systemPrompt ? { ...billingPayload, system: [systemPrompt] } : billingPayload;
75
83
  blocks.push(
76
84
  { type: "text", text: createClaudeBillingHeader(billingSeed) },
@@ -89,17 +97,19 @@ function anthropicSystemBlocks(
89
97
  return blocks;
90
98
  }
91
99
 
92
- /** Anthropic extended-thinking budget by reasoning effort (kept under max_tokens). Cross-provider
93
- * parity (matches Gemini's tiers): minimal/low/medium/high ALL enable thinking with scaling
100
+ /** Anthropic extended-thinking budget by reasoning effort (kept under max_tokens). Tiers match
101
+ * gjc's ANTHROPIC_THINKING table: minimal/low/medium/high ALL enable thinking with scaling
94
102
  * depth — reasoning works at every thinking level (gajae parity: Minimal is a real effort).
95
- * Only an UNSET effort stays non-thinking (the explicit /fast off path). */
96
- function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"], maxTokens: number): number | undefined {
103
+ * Only an UNSET effort stays non-thinking (the explicit /fast off path). The "xhigh" row is
104
+ * correct-by-table even though upstream currently folds xhigh→high before it arrives. */
105
+ function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"] | "xhigh", maxTokens: number): number | undefined {
97
106
  let budget: number;
98
107
  switch (effort) {
99
- case "minimal": budget = 2000; break;
100
- case "low": budget = 4000; break;
101
- case "medium": budget = 10000; break;
102
- case "high": budget = 24000; break;
108
+ case "minimal": budget = 1024; break;
109
+ case "low": budget = 4096; break;
110
+ case "medium": budget = 8192; break;
111
+ case "high": budget = 16384; break;
112
+ case "xhigh": budget = 32768; break;
103
113
  default: return undefined;
104
114
  }
105
115
  return Math.min(budget, Math.max(1024, maxTokens - 1024));
@@ -264,7 +274,7 @@ export function anthropicPayload(
264
274
  // Extended thinking requires max_tokens strictly above the thinking budget.
265
275
  max_tokens: thinkingBudget !== undefined ? Math.max(maxTokens, thinkingBudget + 1024) : maxTokens,
266
276
  };
267
- if (credential.kind === "oauth") payload.metadata = { user_id: createClaudeCloakingUserId() };
277
+ if (shouldUseClaudeCodeOAuthShape(model, credential, options.baseUrl)) payload.metadata = { user_id: createClaudeCloakingUserId() };
268
278
  if (effort !== undefined) {
269
279
  // Enable Claude extended thinking. Extended thinking forbids a custom temperature, so
270
280
  // temperature is only set on the non-thinking path.
@@ -294,7 +304,7 @@ export function anthropicPayload(
294
304
  payload.tool_choice = { type: "auto" };
295
305
  }
296
306
  if (stream) payload.stream = true;
297
- const system = anthropicSystemBlocks(systemPrompt, model, credential, payload);
307
+ const system = anthropicSystemBlocks(systemPrompt, model, credential, payload, options.baseUrl);
298
308
  if (system) payload.system = system;
299
309
  return JSON.stringify(payload);
300
310
  }
@@ -311,7 +321,7 @@ export function anthropicRequest(
311
321
  // Anthropic-compatible providers (z.ai, MiniMax, …) accept the Messages wire
312
322
  // format at their own host; an explicit baseUrl pins `${base}/v1/messages`.
313
323
  url: options.baseUrl ? `${options.baseUrl.replace(/\/$/, "")}/v1/messages` : ANTHROPIC_URL,
314
- headers: headersFor(credential, stream, stripAnthropicPrefix(options.model)),
324
+ headers: { ...headersFor(credential, stream, stripAnthropicPrefix(options.model), options.baseUrl), ...options.extraHeaders },
315
325
  body: anthropicPayload(messages, options, stream, includeTemperature, credential, stripArtifacts),
316
326
  };
317
327
  }
@@ -327,6 +337,22 @@ function isReasoningArtifactError(status: number, detail: string): boolean {
327
337
  return status === 400 && /thinking|signature|redacted_thinking/i.test(detail);
328
338
  }
329
339
 
340
+ /** gjc parity (getSafeAnthropicHeaderEvidence): fold Anthropic's rate-limit response
341
+ * headers into a 429's error detail so the retry classifier can see them. The decisive
342
+ * usage-exhaustion signal (`anthropic-ratelimit-unified-overage-disabled-reason:
343
+ * out_of_credits`) often arrives ONLY as a header — without this, an out-of-credits 429
344
+ * reads like a generic per-minute rate limit and is retried pointlessly. */
345
+ function anthropicRateLimitEvidence(headers: Headers): string {
346
+ const evidence: string[] = [];
347
+ for (const [name, value] of headers) {
348
+ const lower = name.toLowerCase();
349
+ if (lower === "retry-after" || lower === "retry-after-ms" || lower.startsWith("anthropic-ratelimit-")) {
350
+ evidence.push(`${lower}=${value}`);
351
+ }
352
+ }
353
+ return evidence.length > 0 ? ` Anthropic rate-limit evidence: ${evidence.sort().join(", ")}` : "";
354
+ }
355
+
330
356
  async function postAnthropic(
331
357
  messages: Message[],
332
358
  options: CallOptions,
@@ -354,10 +380,13 @@ async function postAnthropic(
354
380
  throw await providerHttpError("Anthropic", response, stream ? "(stream)" : undefined);
355
381
  }
356
382
 
383
+ // 429s carry their rate-limit headers in the DETAIL so isUsageLimitError /
384
+ // friendlyProviderError can classify on the full evidence (gjc parity).
385
+ const enrichedDetail = response.status === 429 ? `${detail}${anthropicRateLimitEvidence(response.headers)}` : detail;
357
386
  throw new ProviderHttpError(
358
387
  "Anthropic",
359
388
  response.status,
360
- detail,
389
+ enrichedDetail,
361
390
  stream ? "(stream)" : undefined,
362
391
  parseRetryAfter(response.headers.get("retry-after")) ?? parseRetryFromBody(detail),
363
392
  );
@@ -543,8 +572,18 @@ function claudeCodeOAuthHeaders(stream: boolean, model: string): Record<string,
543
572
  };
544
573
  }
545
574
 
546
- function headersFor(credential: Credential, stream: boolean, model: string): Record<string, string> {
575
+ function headersFor(credential: Credential, stream: boolean, model: string, baseUrl?: string): Record<string, string> {
547
576
  if (credential.kind === "oauth") {
577
+ // OAuth against a NON-Anthropic base (Kimi Code, …): plain bearer, no Claude-Code
578
+ // cloaking headers/betas. gjc parity: buildAnthropicHeaders' generic-bearer branch.
579
+ if (baseUrl) {
580
+ return {
581
+ accept: stream ? "text/event-stream" : "application/json",
582
+ "content-type": "application/json",
583
+ authorization: `Bearer ${credential.token}`,
584
+ "anthropic-version": "2023-06-01",
585
+ };
586
+ }
548
587
  return {
549
588
  "content-type": "application/json",
550
589
  authorization: `Bearer ${credential.token}`,
@@ -4,7 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
5
  import { providerHttpError } from "./errors";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
- import { geminiThinkingBudget } from "./gemini";
7
+ import { geminiThinkingConfig, getGeminiCliHeaders, type GeminiThinkingConfig } from "./gemini";
8
8
 
9
9
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
10
10
  const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
@@ -37,15 +37,24 @@ function antigravityModelId(model: string): string {
37
37
  return model.replace(/^antigravity\//, "");
38
38
  }
39
39
 
40
- /** The thinking budget actually requested for an Antigravity turn — Claude-via-CCA uses an
41
- * Anthropic-style budget, native Gemini scales via geminiThinkingBudget (which also honours
42
- * in-name depth markers like `-high`/`-low`). Centralised so the request builder and the
43
- * streaming start-signal stay in agreement. */
44
- function antigravityThinkingBudget(options: CallOptions): number | undefined {
40
+ /** The thinkingConfig actually requested for an Antigravity turn — Claude-via-CCA uses an
41
+ * Anthropic-style numeric budget, native Gemini goes through geminiThinkingConfig
42
+ * (gemini-3.x → thinkingLevel enum, 2.5/latest → numeric budget; in-name depth markers
43
+ * like `-high`/`-low` honoured). Centralised so the request builder and the streaming
44
+ * start-signal stay in agreement. */
45
+ function antigravityThinkingConfig(options: CallOptions): GeminiThinkingConfig | undefined {
45
46
  const model = antigravityModelId(options.model);
46
- return model.toLowerCase().includes("claude")
47
- ? antigravityClaudeThinkingBudget(options.reasoningEffort)
48
- : geminiThinkingBudget(model, options.reasoningEffort);
47
+ if (model.toLowerCase().includes("claude")) {
48
+ const budget = antigravityClaudeThinkingBudget(options.reasoningEffort);
49
+ return budget === undefined ? undefined : { includeThoughts: true, thinkingBudget: budget };
50
+ }
51
+ return geminiThinkingConfig(model, options.reasoningEffort);
52
+ }
53
+
54
+ /** True when this turn requested thinking (a thinkingLevel or a positive budget). */
55
+ function antigravityThinkingActive(options: CallOptions): boolean {
56
+ const cfg = antigravityThinkingConfig(options);
57
+ return cfg !== undefined && ("thinkingLevel" in cfg || cfg.thinkingBudget > 0);
49
58
  }
50
59
 
51
60
  function projectIdFor(credential: Credential): string | undefined {
@@ -89,8 +98,8 @@ export async function resolveAntigravityProjectId(
89
98
  // Antigravity-client tokens discover with ANTIGRAVITY metadata; gemini-cli
90
99
  // tokens use the default gemini-cli metadata shape.
91
100
  return discoverGoogleProjectId(token, credential.provider === "antigravity"
92
- ? { metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA }, extraHeaders: { "User-Agent": getAntigravityUserAgent() }, signal: opts.signal }
93
- : { signal: opts.signal });
101
+ ? { metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA }, extraHeaders: { "User-Agent": getAntigravityUserAgent() }, alwaysOnboard: true, signal: opts.signal }
102
+ : { extraHeaders: getGeminiCliHeaders(), signal: opts.signal });
94
103
  });
95
104
  let projectId: string;
96
105
  try {
@@ -165,18 +174,21 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
165
174
  const isClaude = model.toLowerCase().includes("claude");
166
175
  // Upstream Antigravity strips maxOutputTokens for non-Claude models; do the same.
167
176
  if (isClaude) generationConfig.maxOutputTokens = options.maxTokens ?? 4000;
168
- // Apply the thinking level. CCA emits `thought` parts ONLY when thinkingConfig has
169
- // includeThoughts set. Gemini scales via geminiThinkingBudget; Claude-via-CCA needs an
170
- // Anthropic-style budget (gemini's fn returns undefined for claude) PLUS the
171
- // interleaved-thinking beta header below — without both, antigravity Claude (e.g. opus)
172
- // never streamed reasoning while native sonnet did.
173
- const agThinkingBudget = antigravityThinkingBudget(options);
174
- const claudeThinkingOn = isClaude && agThinkingBudget !== undefined;
175
- if (agThinkingBudget !== undefined) {
176
- generationConfig.thinkingConfig = { includeThoughts: true, thinkingBudget: agThinkingBudget };
177
+ // Apply the thinking config. CCA emits `thought` parts ONLY when thinkingConfig has
178
+ // includeThoughts set. Gemini scales via geminiThinkingConfig (level enum on 3.x,
179
+ // numeric budget otherwise); Claude-via-CCA needs an Anthropic-style budget (gemini's
180
+ // fn returns undefined for claude) PLUS the interleaved-thinking beta header below —
181
+ // without both, antigravity Claude (e.g. opus) never streamed reasoning while native
182
+ // sonnet did.
183
+ const agThinking = antigravityThinkingConfig(options);
184
+ const claudeThinkingOn = isClaude && agThinking !== undefined;
185
+ if (agThinking !== undefined) {
186
+ generationConfig.thinkingConfig = agThinking;
177
187
  // Claude (via CCA) enforces max_tokens > thinking.budget_tokens — bump the output cap
178
188
  // above the budget (mirrors the native Anthropic provider) or CCA returns HTTP 400.
179
- if (claudeThinkingOn) generationConfig.maxOutputTokens = Math.max((options.maxTokens ?? 4000), agThinkingBudget + 1024);
189
+ if (claudeThinkingOn && "thinkingBudget" in agThinking) {
190
+ generationConfig.maxOutputTokens = Math.max((options.maxTokens ?? 4000), agThinking.thinkingBudget + 1024);
191
+ }
180
192
  }
181
193
 
182
194
  const request: Record<string, unknown> = {
@@ -288,7 +300,7 @@ export const antigravityAdapter: ProviderAdapter = {
288
300
  if (!response.body) return;
289
301
  // Signal the thinking phase up front (parity with anthropic's content_block_start) so the
290
302
  // UI shows it even before/without `thought` parts — otherwise reasoning looked dead here.
291
- if ((antigravityThinkingBudget(options) ?? 0) > 0) options.onReasoningStart?.();
303
+ if (antigravityThinkingActive(options)) options.onReasoningStart?.();
292
304
  let yielded = false;
293
305
  let usage: CcaUsage | undefined;
294
306
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
@@ -11,7 +11,7 @@ import { serializeToolCalls } from "../../agent/tool-schemas";
11
11
  * Pin an explicit budget: off unless reasoning was requested. Pro-class models
12
12
  * cannot disable thinking (API minimum 128), so they keep a floor instead of 0.
13
13
  * Older models (1.5/2.0) reject `thinkingConfig` entirely → undefined (omit). */
14
- export function geminiThinkingBudget(model: string, effort?: CallOptions["reasoningEffort"], maxTokens?: number): number | undefined {
14
+ export function geminiThinkingBudget(model: string, effort?: CallOptions["reasoningEffort"] | "xhigh", maxTokens?: number): number | undefined {
15
15
  const m = model.toLowerCase();
16
16
  // Reasoning-capable when Gemini >= 2.5 (any 2.5+ minor) or major >= 3 (digit-count
17
17
  // agnostic so gemini-10+ never silently loses thinking the way opus-4-8 did), plus
@@ -22,41 +22,85 @@ export function geminiThinkingBudget(model: string, effort?: CallOptions["reason
22
22
  const thinkingCapable = (major >= 3 || (major === 2 && minor >= 5)) || /flash-latest|pro-latest/.test(m);
23
23
  if (!thinkingCapable) return undefined;
24
24
  const floor = m.includes("pro") ? 128 : 0; // pro-class cannot fully disable thinking
25
- // An UNSET effort normally falls to the floor (off for flash-class). But when the model
26
- // VARIANT name itself encodes a thinking depth — `-high`/`-low` (e.g. gemini-3-pro-high)
27
- // or an explicit `-thinking` suffix (gemini-2.5-flash-thinking) — that selection IS the
28
- // user's thinking opt-in, so it overrides the silent floor and maps to a real budget.
29
- // Unmarked ids (gemini-3-flash, gemini-2.5-flash) keep the off-by-default floor (parity
30
- // with the other providers + the documented thought-token burn protection).
31
- const named: CallOptions["reasoningEffort"] | undefined =
32
- m.includes("-high") ? "high"
33
- : m.includes("-low") ? "low"
34
- : m.includes("thinking") ? "medium"
35
- : undefined;
36
- const effectiveEffort = effort ?? named;
25
+ const effectiveEffort = effort ?? geminiNamedEffort(m);
37
26
  let budget: number;
38
27
  switch (effectiveEffort) {
39
28
  // minimal/low/medium/high ALL enable thinking with scaling depth — reasoning works at
40
29
  // every thinking level (gajae parity: Minimal is a real effort). Only an UNSET effort
41
30
  // (and no in-name depth marker) falls through to the floor.
42
- case "minimal": budget = Math.max(floor, 2000); break;
43
- case "low": budget = 4000; break;
44
- case "medium": budget = 10000; break;
45
- case "high": budget = 24000; break;
31
+ // Tier values mirror gjc's GOOGLE_THINKING table (stream.ts).
32
+ case "minimal": budget = Math.max(floor, 1024); break;
33
+ case "low": budget = 4096; break;
34
+ case "medium": budget = 8192; break;
35
+ case "high": budget = 16384; break;
36
+ case "xhigh": budget = 24575; break;
46
37
  default: budget = floor;
47
38
  }
48
39
  if (typeof maxTokens === "number") budget = Math.min(budget, Math.max(floor, maxTokens - 1024));
49
40
  return budget;
50
41
  }
51
42
 
52
- /** True when this turn was asked to think (a positive budget) — lets the streaming paths
53
- * fire onReasoningStart so the UI shows the thinking phase even before/without any `thought`
54
- * parts arrive (the Gemini/CCA analog of Anthropic's content_block_start thinking signal,
55
- * which is why reasoning otherwise appeared "not to run" on gemini/antigravity). */
43
+ /** In-name thinking depth marker. An UNSET effort normally falls to the floor (off for
44
+ * flash-class). But when the model VARIANT name itself encodes a depth — `-high`/`-low`
45
+ * (e.g. gemini-3-pro-high) or an explicit `-thinking` suffix (gemini-2.5-flash-thinking) —
46
+ * that selection IS the user's thinking opt-in. Unmarked ids (gemini-3-flash,
47
+ * gemini-2.5-flash) keep the off-by-default behavior (parity with the other providers +
48
+ * the documented thought-token burn protection). Expects a lowercased id. */
49
+ function geminiNamedEffort(m: string): CallOptions["reasoningEffort"] | undefined {
50
+ return m.includes("-high") ? "high"
51
+ : m.includes("-low") ? "low"
52
+ : m.includes("thinking") ? "medium"
53
+ : undefined;
54
+ }
55
+
56
+ /** Gemini 3.x is driven by the `thinkingLevel` ENUM, not a numeric budget (gjc
57
+ * inferThinkingControlMode: gemini family with version.major === 3 → "google-level";
58
+ * everything else stays on the numeric thinkingBudget). */
59
+ export function geminiUsesThinkingLevel(model: string): boolean {
60
+ const ver = model.toLowerCase().match(/gemini-(\d+)(?:\.(\d+))?/);
61
+ return !!ver && Number(ver[1]) === 3;
62
+ }
63
+
64
+ /** Effort → Google `thinkingLevel` enum value (gjc mapEffortToGoogleThinkingLevel):
65
+ * minimal→MINIMAL, low→LOW, medium→MEDIUM, high/xhigh→HIGH. */
66
+ export function geminiThinkingLevel(effort: NonNullable<CallOptions["reasoningEffort"]> | "xhigh"): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" {
67
+ switch (effort) {
68
+ case "minimal": return "MINIMAL";
69
+ case "low": return "LOW";
70
+ case "medium": return "MEDIUM";
71
+ case "high":
72
+ case "xhigh": return "HIGH";
73
+ }
74
+ }
75
+
76
+ /** The `thinkingConfig` object for a Gemini request, or undefined to omit it entirely:
77
+ * gemini-3.x → thinkingLevel enum (numeric budgets are NOT sent — gjc emits level XOR
78
+ * budget, never both); with no effort requested (and no in-name marker) the config is
79
+ * omitted so the model keeps its default (gjc parity: thinking not enabled → no config).
80
+ * Other thinking-capable models → numeric thinkingBudget; pre-2.5 → undefined. */
81
+ export type GeminiThinkingConfig =
82
+ | { includeThoughts: true; thinkingLevel: "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" }
83
+ | { includeThoughts: true; thinkingBudget: number };
84
+
85
+ export function geminiThinkingConfig(model: string, effort?: CallOptions["reasoningEffort"], maxTokens?: number): GeminiThinkingConfig | undefined {
86
+ if (geminiUsesThinkingLevel(model)) {
87
+ const effective = effort ?? geminiNamedEffort(model.toLowerCase());
88
+ if (effective === undefined) return undefined;
89
+ return { includeThoughts: true, thinkingLevel: geminiThinkingLevel(effective) };
90
+ }
91
+ const budget = geminiThinkingBudget(model, effort, maxTokens);
92
+ return budget === undefined ? undefined : { includeThoughts: true, thinkingBudget: budget };
93
+ }
94
+
95
+ /** True when this turn was asked to think (a thinkingLevel or a positive budget) — lets
96
+ * the streaming paths fire onReasoningStart so the UI shows the thinking phase even
97
+ * before/without any `thought` parts arrive (the Gemini/CCA analog of Anthropic's
98
+ * content_block_start thinking signal, which is why reasoning otherwise appeared
99
+ * "not to run" on gemini/antigravity). */
56
100
  export function geminiThinkingActive(options: CallOptions): boolean {
57
101
  const model = options.model.replace(/^(google|gemini)\//, "");
58
- const tb = geminiThinkingBudget(model, options.reasoningEffort, options.maxTokens);
59
- return tb !== undefined && tb > 0;
102
+ const cfg = geminiThinkingConfig(model, options.reasoningEffort, options.maxTokens);
103
+ return cfg !== undefined && ("thinkingLevel" in cfg || cfg.thinkingBudget > 0);
60
104
  }
61
105
 
62
106
 
@@ -76,8 +120,8 @@ export function buildGeminiPayload(messages: Message[], options: CallOptions, st
76
120
  if (!geminiModel || geminiModel.startsWith("claude-")) geminiModel = "gemini-2.0-flash";
77
121
 
78
122
  const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
79
- const thinkingBudget = geminiThinkingBudget(geminiModel, options.reasoningEffort, options.maxTokens);
80
- const thinkingEnabled = thinkingBudget !== undefined && !stripArtifacts;
123
+ const thinking = geminiThinkingConfig(geminiModel, options.reasoningEffort, options.maxTokens);
124
+ const thinkingEnabled = thinking !== undefined && !stripArtifacts;
81
125
  // Gemini requires strictly ALTERNATING user/model turns. jeo histories can carry
82
126
  // consecutive same-role messages (a compaction summary prepended before a tool-result,
83
127
  // back-to-back tool results, etc.), so coalesce adjacent same-role turns into one
@@ -126,7 +170,17 @@ export function buildGeminiPayload(messages: Message[], options: CallOptions, st
126
170
 
127
171
  // includeThoughts: required for Gemini to STREAM thought summaries (the `thought:true`
128
172
  // parts thoughtOf() routes to onReasoning) — without it the model thinks silently.
129
- if (thinkingBudget !== undefined) generationConfig.thinkingConfig = { includeThoughts: true, thinkingBudget };
173
+ if (thinking !== undefined) {
174
+ generationConfig.thinkingConfig = thinking;
175
+ if ("thinkingBudget" in thinking && thinking.thinkingBudget > 0) {
176
+ // Additive budget (gjc stream.ts): the caller's maxTokens is the DESIRED OUTPUT;
177
+ // thought tokens ride ON TOP of it, capped at the model's output limit — otherwise
178
+ // thinking silently eats the visible-answer budget (the MAX_TOKENS empty-reply bug).
179
+ // ponytail: 65536 hard cap — swap in the model's catalog max-output once a cheap
180
+ // per-model lookup is available at this layer.
181
+ generationConfig.maxOutputTokens = Math.min((options.maxTokens ?? 4000) + thinking.thinkingBudget, 65536);
182
+ }
183
+ }
130
184
 
131
185
  const payload: Record<string, unknown> = { contents, generationConfig };
132
186
  if (systemPrompt) payload.systemInstruction = { parts: [{ text: systemPrompt }] };
@@ -158,7 +212,7 @@ const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
158
212
 
159
213
  /** gemini-cli identification headers Cloud Code Assist expects (gjc parity). */
160
214
  export function getGeminiCliHeaders(modelId?: string): Record<string, string> {
161
- const version = jeoEnv("GEMINI_CLI_VERSION") || "0.45.2";
215
+ const version = jeoEnv("GEMINI_CLI_VERSION") || "0.49.0";
162
216
  return {
163
217
  "User-Agent": `GeminiCLI/${version}/${modelId ?? "gemini-2.5-flash"} (${process.platform}; ${process.arch}; terminal)`,
164
218
  "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
@@ -1,15 +1,54 @@
1
1
  import type { Credential } from "../../auth";
2
+ import { getKimiCommonHeaders } from "../../auth/flows/kimi";
3
+ import type { CallOptions, ProviderAdapter } from "../types";
4
+ import { anthropicAdapter } from "./anthropic";
2
5
  import { makeOpenAICompatibleAdapter } from "./openai-compatible";
3
6
 
4
7
  /**
5
- * Kimi (Moonshot) — OpenAI-compatible cloud API at https://api.moonshot.ai/v1, keyed
6
- * by KIMI_API_KEY (or `providers.kimi`). The credential (an api_key bearer) is passed
7
- * through; thinking models (kimi-thinking-preview) stream reasoning via
8
- * `reasoning_content`/`<think>`, which the openai adapter routes to onReasoning.
8
+ * Kimi (Moonshot) — two credential-dependent backends (gjc parity):
9
+ * - API key (KIMI_API_KEY / `providers.kimi`): OpenAI-compatible cloud API at
10
+ * https://api.moonshot.ai/v1. Thinking models (kimi-thinking-preview) stream
11
+ * reasoning via `reasoning_content`/`<think>` → onReasoning.
12
+ * - OAuth (Kimi Code subscription, device-code login): Anthropic-compatible API at
13
+ * https://api.kimi.com/coding (`${base}/v1/messages` pinned by the anthropic
14
+ * adapter), authenticated with `Authorization: Bearer` + X-Msh-* device headers.
9
15
  */
10
16
  export const KIMI_BASE_URL = "https://api.moonshot.ai/v1";
17
+ // gjc: KIMI_ANTHROPIC_BASE_URL — the SDK/adapter appends /v1/messages, so no /v1 here.
18
+ export const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
11
19
 
12
- export const kimiAdapter = makeOpenAICompatibleAdapter({ name: "kimi", baseUrl: KIMI_BASE_URL });
20
+ const openaiCompatKimi = makeOpenAICompatibleAdapter({ name: "kimi", baseUrl: KIMI_BASE_URL });
21
+
22
+ /** Route the `kimi/` prefix + OAuth base/headers onto the anthropic adapter. */
23
+ // ponytail: model ids pass through untranslated — the moonshot catalog ids (kimi-latest, …)
24
+ // may not exist on the Kimi Code endpoint (which serves e.g. kimi-for-coding). Upgrade path:
25
+ // add kimi-code entries to model-catalog.ts and map them here per credential kind.
26
+ function prepOAuth(o: CallOptions): CallOptions {
27
+ return {
28
+ ...o,
29
+ model: o.model.startsWith("kimi/") ? o.model.slice(5) : o.model,
30
+ baseUrl: o.baseUrl ?? KIMI_ANTHROPIC_BASE_URL,
31
+ extraHeaders: { ...getKimiCommonHeaders(), ...o.extraHeaders },
32
+ };
33
+ }
34
+
35
+ /** Dispatch on credential kind: OAuth → Kimi Code Anthropic endpoint; else the
36
+ * original OpenAI-compatible moonshot adapter (API-key behavior unchanged). */
37
+ export const kimiAdapter: ProviderAdapter = {
38
+ name: "kimi",
39
+ supportsNativeTools: openaiCompatKimi.supportsNativeTools,
40
+ call: (messages, options, credential) =>
41
+ credential.kind === "oauth"
42
+ ? anthropicAdapter.call(messages, prepOAuth(options), credential)
43
+ : openaiCompatKimi.call(messages, options, credential),
44
+ async *stream(messages, options, credential) {
45
+ if (credential.kind === "oauth") {
46
+ yield* anthropicAdapter.stream!(messages, prepOAuth(options), credential);
47
+ } else {
48
+ yield* openaiCompatKimi.stream!(messages, options, credential);
49
+ }
50
+ },
51
+ };
13
52
 
14
53
  /** Credential carrier for Kimi calls — an api_key bearer (the adapter only reads the
15
54
  * token); a keyless `none` when no key is set. */
@@ -15,11 +15,21 @@ import type { CallOptions, Message } from "../types";
15
15
  import { readSse } from "../sse";
16
16
  import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
17
17
  import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
18
+ import os from "node:os";
19
+ import pkg from "../../../package.json";
18
20
 
19
21
  export const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
20
22
 
21
23
  export const VALID_REASONING_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
22
24
 
25
+ const CODEX_ORIGINATOR = "codex_cli_rs";
26
+
27
+ /** Codex-CLI-shaped User-Agent (gjc getCodexUserAgent parity: `originator/version (platform release; arch)`).
28
+ * Sent on BOTH the OAuth and api-key paths — without it Bun's default UA leaks to the backend. */
29
+ export function codexUserAgent(): string {
30
+ return `${CODEX_ORIGINATOR}/${pkg.version} (${os.platform()} ${os.release()}; ${os.arch()})`;
31
+ }
32
+
23
33
  /** Extract `chatgpt_account_id` from a ChatGPT/Codex OAuth access JWT. */
24
34
  export function extractChatgptAccountId(token: string): string | undefined {
25
35
  const parts = token.split(".");
@@ -93,12 +103,16 @@ export function codexResponsesRequest(
93
103
  const token = credential.kind === "none" ? "" : credential.token;
94
104
  const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
95
105
  const input = buildResponsesInput(messages, options.model, stripArtifacts);
106
+ const promptCacheKey = normalizePromptCacheKey(options.sessionKey);
96
107
  const payload: Record<string, unknown> = {
97
108
  model,
98
109
  instructions: systemPrompt ?? "You are a helpful coding assistant.",
99
110
  input,
100
111
  stream: true, // the Codex backend only streams
101
112
  store: false,
113
+ // Provider-side prompt caching (gjc parity): key the cache on the stable session id
114
+ // so an agent loop replaying the same history each step re-reads a cached prefix.
115
+ ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
102
116
  };
103
117
  if (options.tools?.length) {
104
118
  // Responses API function tools (flat shape). tool_choice "auto" keeps prose + `done`.
@@ -112,6 +126,9 @@ export function codexResponsesRequest(
112
126
  // frame can show the model's thinking instead of a frozen "calling model (Ns)…".
113
127
  payload.reasoning = { effort: options.reasoningEffort, summary: "auto" };
114
128
  }
129
+ // Cap the response length (gjc parity): the computed per-call budget must reach the wire,
130
+ // otherwise the backend free-runs to its own default.
131
+ if (options.maxTokens) payload.max_output_tokens = options.maxTokens;
115
132
  // OAuth → the undocumented ChatGPT/Codex backend (codex headers + account-id).
116
133
  // API key → the public OpenAI Responses API (`/v1/responses`) with a plain Bearer.
117
134
  // Both speak the same Responses schema (the body above), so only url+headers differ.
@@ -122,7 +139,7 @@ export function codexResponsesRequest(
122
139
  payload.include = ["reasoning.encrypted_content"];
123
140
  return {
124
141
  url: `${base}/responses`,
125
- headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream" },
142
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream", "user-agent": codexUserAgent() },
126
143
  body: JSON.stringify(payload),
127
144
  };
128
145
  }
@@ -131,13 +148,30 @@ export function codexResponsesRequest(
131
148
  "content-type": "application/json",
132
149
  authorization: `Bearer ${token}`,
133
150
  "OpenAI-Beta": "responses=experimental",
134
- originator: "codex_cli_rs",
151
+ originator: CODEX_ORIGINATOR,
152
+ "user-agent": codexUserAgent(),
135
153
  accept: "text/event-stream",
136
154
  };
137
155
  if (accountId) headers["chatgpt-account-id"] = accountId;
156
+ // Correlation headers (gjc parity): the Codex backend keys its prompt cache on the
157
+ // session/conversation pair; x-client-request-id aids server-side tracing.
158
+ if (promptCacheKey) {
159
+ headers.session_id = promptCacheKey;
160
+ headers.conversation_id = promptCacheKey;
161
+ headers["x-client-request-id"] = promptCacheKey;
162
+ }
138
163
  return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(payload) };
139
164
  }
140
165
 
166
+ /** gjc parity (normalizeOpenAIResponsesPromptCacheKey): well-formed unicode, ≤64 chars
167
+ * verbatim, longer keys hashed to a stable compact form. */
168
+ export function normalizePromptCacheKey(sessionKey: string | undefined): string | undefined {
169
+ if (!sessionKey || sessionKey.length === 0) return undefined;
170
+ const wellFormed = sessionKey.toWellFormed();
171
+ if (wellFormed.length <= 64) return wellFormed;
172
+ return `pc_${Bun.hash(wellFormed).toString(36)}`;
173
+ }
174
+
141
175
  export interface ResponsesEvent {
142
176
  delta?: string;
143
177
  /** Reasoning-summary text delta (`response.reasoning_summary_text.delta` and the
package/src/ai/types.ts CHANGED
@@ -113,6 +113,12 @@ export interface CallOptions {
113
113
  * `enable_thinking: true`; "zai" → `thinking: {type:"enabled"}`. Set per provider by
114
114
  * the openai-compatible factory; without it a model never emits reasoning to surface. */
115
115
  reasoningFormat?: "openai" | "openrouter" | "qwen" | "zai";
116
+ /** Stable per-conversation key (the session id) for provider-side prompt caching.
117
+ * gjc parity: the Codex/Responses backends key their prompt cache on
118
+ * `prompt_cache_key` (+ `session_id`/`conversation_id` headers on the Codex
119
+ * backend), so an agent loop replaying the same history each step gets cache
120
+ * hits instead of full-prompt re-reads. Optional — absent for one-shot calls. */
121
+ sessionKey?: string;
116
122
  /** Notified before each auto-retry backoff wait (rate limits / transient errors).
117
123
  * NOT forwarded to provider adapters — consumed by the manager's retry layer. */
118
124
  onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
@@ -140,6 +146,10 @@ export interface CallOptions {
140
146
  * these on the wire and re-serialize the structured tool call back into the engine's
141
147
  * canonical {"tool":...}/{"tools":[...]} string; others ignore it. */
142
148
  tools?: NativeToolSchema[];
149
+ /** Extra HTTP headers merged into the outgoing provider request (adapter-specific;
150
+ * currently honored by the anthropic adapter). Used by Anthropic-compatible OAuth
151
+ * providers — e.g. Kimi Code sends its X-Msh-* device headers on every call. */
152
+ extraHeaders?: Record<string, string>;
143
153
  }
144
154
 
145
155
  export interface ProviderAdapter {
@@ -27,6 +27,19 @@ export interface OAuthCallbackFlowOptions {
27
27
 
28
28
  export type CallbackResult = { code: string; state: string };
29
29
 
30
+ /** Thrown when the fixed callback port cannot be bound and the provider forbids a
31
+ * port fallback (fixed redirect URI). Typed so callers (e.g. the OpenAI flow) can
32
+ * detect the bind failure and fall back to the device-code flow instead of
33
+ * string-matching the error message. */
34
+ export class CallbackPortUnavailableError extends Error {
35
+ readonly port: number;
36
+ constructor(port: number) {
37
+ super(`OAuth callback port ${port} unavailable and this provider requires a fixed redirect URI.`);
38
+ this.name = "CallbackPortUnavailableError";
39
+ this.port = port;
40
+ }
41
+ }
42
+
30
43
  // "jeo" wordmark — bold forged monospace lettering on a neon blue→violet→pink
31
44
  // gradient (the mascot's synthwave palette), generated via god-tibo-imagen and
32
45
  // matching jeo-pi's bold-wordmark typographic treatment. Embedded as a data URI
@@ -125,6 +138,7 @@ export abstract class OAuthCallbackFlow {
125
138
  Bun.serve({
126
139
  hostname: this.callbackHostname,
127
140
  port,
141
+ reusePort: false, // gjc parity: never share the port — a busy port must FAIL, not silently split traffic
128
142
  fetch: req => this.#handle(req, expectedState),
129
143
  });
130
144
  try {
@@ -134,9 +148,7 @@ export abstract class OAuthCallbackFlow {
134
148
  return { server, redirectUri };
135
149
  } catch {
136
150
  if (this.fixedRedirectUri) {
137
- throw new Error(
138
- `OAuth callback port ${this.preferredPort} unavailable and this provider requires a fixed redirect URI.`
139
- );
151
+ throw new CallbackPortUnavailableError(this.preferredPort);
140
152
  }
141
153
  const server = serve(0);
142
154
  const redirectUri = `http://${this.callbackHostname}:${server.port}${this.callbackPath}`;
@@ -172,7 +184,7 @@ export abstract class OAuthCallbackFlow {
172
184
  });
173
185
 
174
186
  return new Response(renderHtml(ok, message), {
175
- status: ok ? 200 : 400,
187
+ status: ok ? 200 : 500, // gjc parity: provider/CSRF failures are server-side errors (500), not 400
176
188
  headers: { "content-type": "text/html" },
177
189
  });
178
190
  }
@@ -206,7 +218,9 @@ export abstract class OAuthCallbackFlow {
206
218
  .then(input => {
207
219
  const parsed = parseCallbackInput(input);
208
220
  if (!parsed.code) return null;
209
- if (expectedState && parsed.state !== expectedState) return null; // reject missing OR mismatched state
221
+ // gjc parity: a bare pasted code WITHOUT a state is accepted (the provider
222
+ // may strip it); only an explicitly MISMATCHED state is rejected.
223
+ if (expectedState && parsed.state && parsed.state !== expectedState) return null;
210
224
  return { code: parsed.code, state: parsed.state ?? "" } as CallbackResult;
211
225
  })
212
226
  .catch(() => null),