@code-yeongyu/senpi-ai 2026.10.30-2 → 2026.10.31-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.
Files changed (49) hide show
  1. package/dist/api/anthropic-messages.d.ts +13 -3
  2. package/dist/api/anthropic-messages.js +62 -185
  3. package/dist/api/anthropic-system-blocks.d.ts +12 -0
  4. package/dist/api/anthropic-system-blocks.js +35 -0
  5. package/dist/api/openai-completions.js +19 -2
  6. package/dist/auth/helpers.d.ts +1 -0
  7. package/dist/auth/helpers.js +1 -0
  8. package/dist/auth/oauth/load.d.ts +0 -2
  9. package/dist/auth/oauth/load.js +0 -5
  10. package/dist/auth/pool/slots.d.ts +11 -1
  11. package/dist/auth/pool/slots.js +63 -1
  12. package/dist/auth/types.d.ts +7 -0
  13. package/dist/bun-oauth.js +0 -2
  14. package/dist/compat/extension-oauth-types.d.ts +1 -0
  15. package/dist/models.js +1 -1
  16. package/dist/oauth.d.ts +1 -1
  17. package/dist/oauth.js +1 -1
  18. package/dist/providers/anthropic.js +2 -1
  19. package/dist/providers/chatgpt-subscription.js +2 -0
  20. package/dist/providers/data/.manifest.json +1 -1
  21. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  22. package/dist/providers/data/opengateway.json +1 -1
  23. package/dist/providers/data/openrouter.json +1 -1
  24. package/dist/providers/data/venice.json +1 -1
  25. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  26. package/dist/types.d.ts +16 -0
  27. package/dist/utils/chatgpt-subscription-auth.d.ts +7 -0
  28. package/dist/utils/chatgpt-subscription-auth.js +21 -4
  29. package/dist/utils/prompt-cache-ttl.d.ts +2 -1
  30. package/dist/utils/prompt-cache-ttl.js +2 -0
  31. package/dist/utils/retry-hint.d.ts +3 -1
  32. package/dist/utils/retry-hint.js +80 -2
  33. package/dist/utils/tool-schema-compat.js +35 -0
  34. package/dist/utils/transcript.d.ts +1 -1
  35. package/dist/utils/transcript.js +12 -2
  36. package/dist/utils/validation.js +111 -4
  37. package/package.json +2 -2
  38. package/dist/auth/oauth/anthropic-callback-listener.d.ts +0 -40
  39. package/dist/auth/oauth/anthropic-callback-listener.js +0 -138
  40. package/dist/auth/oauth/anthropic.d.ts +0 -13
  41. package/dist/auth/oauth/anthropic.js +0 -285
  42. package/dist/auth/oauth/authorization-input.d.ts +0 -6
  43. package/dist/auth/oauth/authorization-input.js +0 -29
  44. package/dist/auth/oauth/error-details.d.ts +0 -3
  45. package/dist/auth/oauth/error-details.js +0 -20
  46. package/dist/utils/claude-code-version-cache.d.ts +0 -12
  47. package/dist/utils/claude-code-version-cache.js +0 -60
  48. package/dist/utils/claude-code-version.d.ts +0 -78
  49. package/dist/utils/claude-code-version.js +0 -203
package/dist/types.d.ts CHANGED
@@ -513,6 +513,14 @@ export interface SystemMessage {
513
513
  * the original. Avoid integer-like names; JSON objects reorder those.
514
514
  */
515
515
  sections?: Record<string, string | null>;
516
+ /**
517
+ * Leading message only: the start of the rendered prompt that is the same across sessions in the same
518
+ * working directory. Providers with block-level prompt caching (Anthropic) send it as its own block
519
+ * holding the system cache breakpoint when the rendered prompt still starts with it, so another session
520
+ * reads it from cache even when the rest differs; a rewrite that no longer starts with it sends one block.
521
+ * Request-time only; rendered text is unchanged (senpi#4447).
522
+ */
523
+ stablePrefix?: string;
516
524
  /** Complete definitions of tools that become available at this point. */
517
525
  toolsAdded?: Tool[];
518
526
  /** Tools that stop being available at this point. */
@@ -769,6 +777,12 @@ export interface ToolReference {
769
777
  */
770
778
  export interface Context {
771
779
  systemPrompt?: string;
780
+ /**
781
+ * Leading part of `systemPrompt` that is the same across sessions in the same working directory; the rest
782
+ * changes between sessions (memory, MCP instructions). Folded into the leading system message as
783
+ * `stablePrefix` when `systemPrompt` starts with it (senpi#4447).
784
+ */
785
+ systemPromptStablePrefix?: string;
772
786
  messages: Message[];
773
787
  tools?: Tool[];
774
788
  /**
@@ -941,6 +955,8 @@ export interface OpenAICompletionsCompat {
941
955
  supportsMidConvoToolAdditions?: boolean;
942
956
  /** Whether the provider supports the `strict` field in tool definitions. Default: false; generated capable models enable it explicitly. */
943
957
  supportsStrictMode?: boolean;
958
+ /** Whether function tools may mix strict and non-strict sampling in one request. Default: true. */
959
+ mixedStrictTools?: boolean;
944
960
  /**
945
961
  * Provider-specific JSON Schema flavor for tool parameters. `"moonshot-mfjs"`
946
962
  * normalizes schemas for Moonshot / Kimi backends that enforce a stricter subset.
@@ -8,4 +8,11 @@ export declare const CHATGPT_SUBSCRIPTION_AUTH_CLAIM_PATH = "https://api.openai.
8
8
  * browser consumers.
9
9
  */
10
10
  export declare function extractChatGptSubscriptionAccountId(token: string): string | undefined;
11
+ /**
12
+ * The signed-in person a Codex access token belongs to, for telling one saved account from another.
13
+ * `chatgpt_account_id` names the workspace and is shared by every member of a Team workspace, so the
14
+ * identity is the per-member `chatgpt_account_user_id`, or the user id scoped to the workspace when
15
+ * that claim is absent. A token without either has no identity.
16
+ */
17
+ export declare function extractChatGptSubscriptionIdentity(token: string): string | undefined;
11
18
  //# sourceMappingURL=chatgpt-subscription-auth.d.ts.map
@@ -8,6 +8,22 @@ export const CHATGPT_SUBSCRIPTION_AUTH_CLAIM_PATH = "https://api.openai.com/auth
8
8
  * browser consumers.
9
9
  */
10
10
  export function extractChatGptSubscriptionAccountId(token) {
11
+ return claimString(authClaims(token), "chatgpt_account_id");
12
+ }
13
+ /**
14
+ * The signed-in person a Codex access token belongs to, for telling one saved account from another.
15
+ * `chatgpt_account_id` names the workspace and is shared by every member of a Team workspace, so the
16
+ * identity is the per-member `chatgpt_account_user_id`, or the user id scoped to the workspace when
17
+ * that claim is absent. A token without either has no identity.
18
+ */
19
+ export function extractChatGptSubscriptionIdentity(token) {
20
+ const auth = authClaims(token);
21
+ const workspace = claimString(auth, "chatgpt_account_id");
22
+ const user = claimString(auth, "chatgpt_user_id") ?? claimString(auth, "user_id");
23
+ return (claimString(auth, "chatgpt_account_user_id") ??
24
+ (user !== undefined && workspace !== undefined ? `${user}/${workspace}` : undefined));
25
+ }
26
+ function authClaims(token) {
11
27
  try {
12
28
  const parts = token.split(".");
13
29
  const encodedPayload = parts.length === 3 ? parts[1] : undefined;
@@ -18,13 +34,14 @@ export function extractChatGptSubscriptionAccountId(token) {
18
34
  if (!payload || typeof payload !== "object")
19
35
  return undefined;
20
36
  const auth = Reflect.get(payload, CHATGPT_SUBSCRIPTION_AUTH_CLAIM_PATH);
21
- if (!auth || typeof auth !== "object")
22
- return undefined;
23
- const accountId = Reflect.get(auth, "chatgpt_account_id");
24
- return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
37
+ return auth && typeof auth === "object" ? auth : undefined;
25
38
  }
26
39
  catch {
27
40
  return undefined;
28
41
  }
29
42
  }
43
+ function claimString(claims, key) {
44
+ const value = claims === undefined ? undefined : Reflect.get(claims, key);
45
+ return typeof value === "string" && value.length > 0 ? value : undefined;
46
+ }
30
47
  //# sourceMappingURL=chatgpt-subscription-auth.js.map
@@ -24,7 +24,8 @@ export type PromptCacheLifetime = {
24
24
  };
25
25
  export declare function isAnthropicApiBaseUrl(baseUrl: string): boolean;
26
26
  export declare function getAnthropicCompat(model: Model<"anthropic-messages">): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking" | "supportsMidConvoEffort" | "sessionAffinityFormat">> & Pick<AnthropicMessagesCompat, "sessionAffinityFormat">;
27
- export type ResolvedOpenAICompletionsCompat = Omit<Required<OpenAICompletionsCompat>, "vllmPriority" | "cacheControlFormat" | "toolCallFormat" | "deferredToolsMode" | "toolSchemaFlavor" | "supportsPromptCacheKey" | "chatTemplateArgs" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" | "veniceParameters" | "supportsForcedToolChoice" | "supportsWebSearch"> & {
27
+ export type ResolvedOpenAICompletionsCompat = Omit<Required<OpenAICompletionsCompat>, "vllmPriority" | "cacheControlFormat" | "toolCallFormat" | "deferredToolsMode" | "toolSchemaFlavor" | "supportsPromptCacheKey" | "chatTemplateArgs" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" | "veniceParameters" | "supportsForcedToolChoice" | "supportsWebSearch" | "mixedStrictTools"> & {
28
+ mixedStrictTools?: OpenAICompletionsCompat["mixedStrictTools"];
28
29
  supportsWebSearch?: OpenAICompletionsCompat["supportsWebSearch"];
29
30
  /** Declared forced tool_choice support; absent means supported. */
30
31
  supportsForcedToolChoice?: OpenAICompletionsCompat["supportsForcedToolChoice"];
@@ -161,6 +161,7 @@ function detectOpenAICompletionsCompat(model) {
161
161
  zaiToolStream: false,
162
162
  // OpenAI compatibility alone does not imply strict JSON-schema tool support.
163
163
  supportsStrictMode: false,
164
+ mixedStrictTools: true,
164
165
  toolSchemaFlavor: isMoonshot ? "moonshot-mfjs" : undefined,
165
166
  supportsDisabledThinking: true,
166
167
  toolCallFormat: undefined,
@@ -211,6 +212,7 @@ export function getOpenAICompletionsCompat(model) {
211
212
  supportsThinkingTokenBudget: model.compat.supportsThinkingTokenBudget ?? detected.supportsThinkingTokenBudget,
212
213
  thinkingTokenBudgetField: model.compat.thinkingTokenBudgetField ?? detected.thinkingTokenBudgetField,
213
214
  supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
215
+ mixedStrictTools: model.compat.mixedStrictTools ?? detected.mixedStrictTools,
214
216
  toolSchemaFlavor: model.compat.toolSchemaFlavor ?? detected.toolSchemaFlavor,
215
217
  toolCallFormat: model.compat.toolCallFormat ?? detected.toolCallFormat,
216
218
  supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,
@@ -6,7 +6,8 @@
6
6
  * 2. retry-after header (strict integer delta-seconds `^\d+$`, else HTTP-date)
7
7
  * 3. x-ratelimit-reset{,-requests,-tokens} (epoch seconds, max-wins)
8
8
  * 4. JSON retryDelay strings (ms/s/m units, recursive, max-wins; numeric rejected)
9
- * 5. body prose (ms-marker, seconds-marker, relative, resets-in, resets-at ISO8601)
9
+ * 5. body prose (ms-marker, seconds-marker, relative, resets-in, resets-at ISO8601,
10
+ * wall clock with an optional zone: "resets 12am (Asia/Seoul)")
10
11
  *
11
12
  * Eligibility gate: status 429 OR body marks rate-limit error.
12
13
  * Explicit zero = 0 (beats lower-precedence positives).
@@ -21,4 +22,5 @@ export interface RetryHintInput {
21
22
  export declare function extract429RetryAfterMs(input: RetryHintInput, nowMs?: number): number | undefined;
22
23
  export declare function appendRetryAfterMsMarker(message: string, hintMs: number): string;
23
24
  export declare function parseRetryAfterMsMarker(message: string): number | undefined;
25
+ export declare function clockResetMs(text: string, nowMs: number): number | undefined;
24
26
  //# sourceMappingURL=retry-hint.d.ts.map
@@ -6,7 +6,8 @@
6
6
  * 2. retry-after header (strict integer delta-seconds `^\d+$`, else HTTP-date)
7
7
  * 3. x-ratelimit-reset{,-requests,-tokens} (epoch seconds, max-wins)
8
8
  * 4. JSON retryDelay strings (ms/s/m units, recursive, max-wins; numeric rejected)
9
- * 5. body prose (ms-marker, seconds-marker, relative, resets-in, resets-at ISO8601)
9
+ * 5. body prose (ms-marker, seconds-marker, relative, resets-in, resets-at ISO8601,
10
+ * wall clock with an optional zone: "resets 12am (Asia/Seoul)")
10
11
  *
11
12
  * Eligibility gate: status 429 OR body marks rate-limit error.
12
13
  * Explicit zero = 0 (beats lower-precedence positives).
@@ -145,7 +146,8 @@ function fromBodyProse(bodyText, nowMs) {
145
146
  if (!Number.isNaN(parsed))
146
147
  return clampAbsolute(parsed, nowMs);
147
148
  }
148
- return undefined;
149
+ // resets <wall clock> [(<IANA zone>)], the shape Claude Code writes (senpi#1771)
150
+ return clockResetMs(bodyText, nowMs);
149
151
  }
150
152
  /* ---------- helpers ---------- */
151
153
  function dateHeaderMs(h) {
@@ -234,4 +236,80 @@ export function parseRetryAfterMsMarker(message) {
234
236
  return undefined;
235
237
  return Number.parseInt(m[1], 10);
236
238
  }
239
+ /* ---------- wall-clock reset prose ---------- */
240
+ // A reset written as a wall clock: "resets 12am (Asia/Seoul)", "resets Oct 2, 9am", "try again at 12:00 AM".
241
+ // Shared with the credential pool (reset-time.ts) so both read the text the same way (senpi#1771). Milliseconds
242
+ // from `nowMs` to the next such moment, or undefined; a time with no zone reads in the host's local zone.
243
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
244
+ const CLOCK_RE = /\b(?:resets?|try again)\s+(?:at\s+|on\s+)?(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s?m\b\.?(?:\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*)\))?/i;
245
+ export function clockResetMs(text, nowMs) {
246
+ const match = CLOCK_RE.exec(text);
247
+ if (!match)
248
+ return undefined;
249
+ const [, monthName, dayText, hourText, minuteText, meridiem, zoneText] = match;
250
+ const hour12 = Number.parseInt(hourText ?? "", 10);
251
+ const minute = minuteText === undefined ? 0 : Number.parseInt(minuteText, 10);
252
+ if (hour12 < 1 || hour12 > 12 || minute > 59)
253
+ return undefined;
254
+ const hour = (hour12 % 12) + (meridiem?.toLowerCase() === "p" ? 12 : 0);
255
+ const zone = zoneText ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
256
+ if (!isValidTimeZone(zone))
257
+ return undefined;
258
+ const today = zonedParts(zone, nowMs);
259
+ if (monthName !== undefined) {
260
+ const month = MONTHS.indexOf(monthName.toLowerCase());
261
+ const day = Number.parseInt(dayText ?? "", 10);
262
+ if (month < 0 || day < 1 || day > 31)
263
+ return undefined;
264
+ let target = zonedToEpoch(zone, today.year, month, day, hour, minute);
265
+ if (target <= nowMs)
266
+ target = zonedToEpoch(zone, today.year + 1, month, day, hour, minute);
267
+ return target - nowMs;
268
+ }
269
+ let target = zonedToEpoch(zone, today.year, today.month, today.day, hour, minute);
270
+ if (target <= nowMs)
271
+ target = zonedToEpoch(zone, today.year, today.month, today.day + 1, hour, minute);
272
+ return target - nowMs;
273
+ }
274
+ function isValidTimeZone(zone) {
275
+ try {
276
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
277
+ return true;
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ }
283
+ function zonedParts(zone, epochMs) {
284
+ const parts = new Intl.DateTimeFormat("en-US", {
285
+ timeZone: zone,
286
+ hourCycle: "h23",
287
+ year: "numeric",
288
+ month: "numeric",
289
+ day: "numeric",
290
+ hour: "numeric",
291
+ minute: "numeric",
292
+ second: "numeric",
293
+ }).formatToParts(epochMs);
294
+ const get = (type) => Number.parseInt(parts.find((part) => part.type === type)?.value ?? "0", 10);
295
+ return {
296
+ year: get("year"),
297
+ month: get("month") - 1,
298
+ day: get("day"),
299
+ hour: get("hour"),
300
+ minute: get("minute"),
301
+ second: get("second"),
302
+ };
303
+ }
304
+ function zoneOffsetMs(zone, epochMs) {
305
+ const p = zonedParts(zone, epochMs);
306
+ const wallAsUtc = Date.UTC(p.year, p.month, p.day, p.hour, p.minute, p.second);
307
+ return wallAsUtc - Math.floor(epochMs / 1000) * 1000;
308
+ }
309
+ /** The instant a wall-clock time in `zone` names; two passes settle a DST boundary. */
310
+ function zonedToEpoch(zone, year, month, day, hour, minute) {
311
+ const wall = Date.UTC(year, month, day, hour, minute);
312
+ const first = wall - zoneOffsetMs(zone, wall);
313
+ return wall - zoneOffsetMs(zone, first);
314
+ }
237
315
  //# sourceMappingURL=retry-hint.js.map
@@ -148,16 +148,51 @@ function mergeRootObjectUnion(schema, combiner) {
148
148
  ...(selected === "allOf" ? branchRequiredSets.flatMap((names) => [...names]) : commonBranchRequired),
149
149
  ]),
150
150
  ];
151
+ // A union's merged root drops each branch's own required names; keep them visible to the model (#5138).
152
+ const hints = selected === "allOf"
153
+ ? []
154
+ : objectBranches.flatMap((branch, index) => {
155
+ const own = [...(branchRequiredSets[index] ?? [])].filter((name) => !required.includes(name));
156
+ return own.length === 0 ? [] : [`REQUIRED for ${describeBranch(branch, index)}: ${own.join(", ")}.`];
157
+ });
158
+ const description = hints.length === 0
159
+ ? undefined
160
+ : [typeof schema.description === "string" ? schema.description : "", ...hints].filter(Boolean).join(" ");
151
161
  const rest = { ...schema };
152
162
  for (const key of combiner ? [combiner] : ["anyOf", "oneOf"])
153
163
  delete rest[key];
154
164
  return {
155
165
  ...rest,
166
+ ...(description === undefined ? {} : { description }),
156
167
  type: "object",
157
168
  properties,
158
169
  ...(required.length > 0 ? { required } : {}),
159
170
  };
160
171
  }
172
+ /** Names a union branch by its first literal-valued property (`action=run`), else by position. */
173
+ function describeBranch(branch, index) {
174
+ if (isJsonObject(branch.properties)) {
175
+ for (const [name, propertySchema] of Object.entries(branch.properties)) {
176
+ const values = literalValues(propertySchema);
177
+ if (values.length > 0)
178
+ return `${name}=${values.join("|")}`;
179
+ }
180
+ }
181
+ return `option ${index + 1}`;
182
+ }
183
+ function literalValues(schema) {
184
+ if (!isJsonObject(schema))
185
+ return [];
186
+ if (Object.hasOwn(schema, "const"))
187
+ return [String(schema.const)];
188
+ if (Array.isArray(schema.enum))
189
+ return schema.enum.map(String);
190
+ const branches = schema.anyOf;
191
+ if (!Array.isArray(branches))
192
+ return [];
193
+ const values = branches.map((branch) => literalValues(branch));
194
+ return values.every((value) => value.length === 1) ? values.flat() : [];
195
+ }
161
196
  function normalizeNode(node, isRoot = false) {
162
197
  if (Array.isArray(node)) {
163
198
  return node.map((child) => normalizeNode(child));
@@ -4,7 +4,7 @@ export type { TranscriptContext } from "../types.ts";
4
4
  * Build the leading system message for a prompt and tool set. Returns undefined when
5
5
  * both are empty, so an empty transcript stays empty.
6
6
  */
7
- export declare function createInitialSystemMessage(systemPrompt: string | undefined, tools: Tool[] | undefined): SystemMessage | undefined;
7
+ export declare function createInitialSystemMessage(systemPrompt: string | undefined, tools: Tool[] | undefined, stablePrefix?: string): SystemMessage | undefined;
8
8
  /**
9
9
  * Fold `Context.systemPrompt` and `Context.tools` into a leading system message.
10
10
  * This is the only entry point that produces a {@link TranscriptContext}; every
@@ -3,14 +3,20 @@ import { contentText, getSystemMessageText } from "./text.js";
3
3
  * Build the leading system message for a prompt and tool set. Returns undefined when
4
4
  * both are empty, so an empty transcript stays empty.
5
5
  */
6
- export function createInitialSystemMessage(systemPrompt, tools) {
6
+ export function createInitialSystemMessage(systemPrompt, tools, stablePrefix) {
7
7
  const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
8
8
  const hasTools = tools !== undefined && tools.length > 0;
9
9
  if (!hasSystemPrompt && !hasTools)
10
10
  return undefined;
11
+ const splitsPrompt = hasSystemPrompt &&
12
+ stablePrefix !== undefined &&
13
+ stablePrefix.length > 0 &&
14
+ stablePrefix.length < systemPrompt.length &&
15
+ systemPrompt.startsWith(stablePrefix);
11
16
  return {
12
17
  role: "system",
13
18
  content: systemPrompt ?? "",
19
+ ...(splitsPrompt ? { stablePrefix } : {}),
14
20
  ...(hasTools ? { toolsAdded: tools } : {}),
15
21
  timestamp: 0,
16
22
  };
@@ -22,7 +28,7 @@ export function createInitialSystemMessage(systemPrompt, tools) {
22
28
  * over unchanged (senpi#2095 allowed-tools): it has no system-message representation.
23
29
  */
24
30
  export function normalizeContext(context) {
25
- const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
31
+ const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools, context.systemPromptStablePrefix);
26
32
  const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;
27
33
  return (context.activeToolNames ? { messages, activeToolNames: context.activeToolNames } : { messages });
28
34
  }
@@ -60,9 +66,12 @@ export function getCurrentSystemMessage(messages) {
60
66
  const content = [];
61
67
  const sections = new Map();
62
68
  let timestamp;
69
+ let stablePrefix;
63
70
  for (const message of messages) {
64
71
  if (!isSystemMessage(message))
65
72
  continue;
73
+ if (timestamp === undefined)
74
+ stablePrefix = message.stablePrefix;
66
75
  timestamp ??= message.timestamp;
67
76
  const text = contentText(message.content);
68
77
  if (text.length > 0)
@@ -80,6 +89,7 @@ export function getCurrentSystemMessage(messages) {
80
89
  return {
81
90
  role: "system",
82
91
  content: content.join("\n\n"),
92
+ ...(stablePrefix === undefined ? {} : { stablePrefix }),
83
93
  ...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}),
84
94
  ...(tools.length > 0 ? { toolsAdded: tools } : {}),
85
95
  timestamp: timestamp ?? 0,
@@ -185,6 +185,111 @@ function formatValidationPath(error) {
185
185
  const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
186
186
  return path || "root";
187
187
  }
188
+ function constValues(schema) {
189
+ if (!schema)
190
+ return undefined;
191
+ if ("const" in schema)
192
+ return [schema.const];
193
+ if (Array.isArray(schema.enum) && schema.enum.length > 0)
194
+ return [...schema.enum];
195
+ const members = schema.anyOf ?? schema.oneOf;
196
+ if (!Array.isArray(members) || members.length === 0)
197
+ return undefined;
198
+ const values = [];
199
+ for (const member of members) {
200
+ const memberValues = constValues(member);
201
+ if (!memberValues)
202
+ return undefined;
203
+ values.push(...memberValues);
204
+ }
205
+ return values;
206
+ }
207
+ function discriminatedBranches(members, property) {
208
+ const seen = new Set();
209
+ const branches = [];
210
+ for (const member of members) {
211
+ const propertySchema = member.properties?.[property];
212
+ const values = constValues(propertySchema);
213
+ if (!values || values.some((value) => seen.has(value)))
214
+ return undefined;
215
+ for (const value of values)
216
+ seen.add(value);
217
+ branches.push({
218
+ schema: member,
219
+ values,
220
+ optional: !(member.required ?? []).includes(property),
221
+ defaultValue: propertySchema?.default,
222
+ });
223
+ }
224
+ return branches;
225
+ }
226
+ /** An anyOf/oneOf of object branches that every carry a const-valued property with disjoint values. */
227
+ function findDiscriminatedUnion(schema) {
228
+ const members = schema.anyOf ?? schema.oneOf;
229
+ if (!Array.isArray(members) || members.length < 2)
230
+ return undefined;
231
+ const [first] = members;
232
+ if (!first?.properties || !members.every((member) => member.properties !== undefined))
233
+ return undefined;
234
+ for (const property of Object.keys(first.properties)) {
235
+ const branches = discriminatedBranches(members, property);
236
+ if (!branches)
237
+ continue;
238
+ const defaultValue = schema.properties?.[property]?.default ??
239
+ branches.map((branch) => branch.defaultValue).find((value) => value !== undefined);
240
+ return { property, branches, defaultValue };
241
+ }
242
+ return undefined;
243
+ }
244
+ function selectBranch(union, value) {
245
+ if (value !== undefined) {
246
+ const branch = union.branches.find((candidate) => candidate.values.includes(value));
247
+ return branch ? { branch, value, defaulted: false } : "unknown";
248
+ }
249
+ if (union.defaultValue !== undefined) {
250
+ const branch = union.branches.find((candidate) => candidate.values.includes(union.defaultValue));
251
+ if (branch)
252
+ return { branch, value: union.defaultValue, defaulted: true };
253
+ }
254
+ const optional = union.branches.filter((candidate) => candidate.optional);
255
+ const [only] = optional;
256
+ if (optional.length === 1 && only && only.values.length === 1) {
257
+ return { branch: only, value: only.values[0], defaulted: true };
258
+ }
259
+ return undefined;
260
+ }
261
+ /**
262
+ * For a discriminated union, report only the branch the arguments address, as one line, so the caller
263
+ * sees which field to fix instead of every branch's mismatch. Undefined means use the full error list.
264
+ */
265
+ function formatDiscriminatedUnionError(schema, validator, args) {
266
+ if (typeof args !== "object" || args === null || Array.isArray(args))
267
+ return undefined;
268
+ const union = findDiscriminatedUnion(schema);
269
+ if (!union)
270
+ return undefined;
271
+ const received = args[union.property];
272
+ const selected = selectBranch(union, received);
273
+ if (selected === undefined)
274
+ return undefined;
275
+ if (selected === "unknown") {
276
+ const allowed = union.branches.flatMap((branch) => branch.values).map((value) => JSON.stringify(value));
277
+ return ` - ${union.property}: must be one of ${allowed.join(", ")} (received ${JSON.stringify(received)})`;
278
+ }
279
+ const branchValidator = getSubSchemaValidator(selected.branch.schema);
280
+ if (!branchValidator)
281
+ return undefined;
282
+ const rootErrors = validator
283
+ .Errors(args)
284
+ .filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf")
285
+ .filter((error) => !/^#\/(?:anyOf|oneOf)(?:\/|$)/.test(error.schemaPath));
286
+ const messages = [...rootErrors, ...branchValidator.Errors(args)].map((error) => `${formatValidationPath(error)}: ${error.message}`);
287
+ const unique = [...new Set(messages)];
288
+ if (unique.length === 0)
289
+ return undefined;
290
+ const label = `for ${union.property} ${JSON.stringify(selected.value)}${selected.defaulted ? ` (default when ${union.property} is omitted)` : ""}`;
291
+ return ` - ${label}: ${unique.join("; ")}`;
292
+ }
188
293
  /**
189
294
  * Finds a tool by name and validates the tool call arguments against its TypeBox schema
190
295
  * @param tools Array of tool definitions
@@ -238,10 +343,12 @@ export function validateToolArguments(tool, toolCall) {
238
343
  if (validator.Check(args)) {
239
344
  return args;
240
345
  }
241
- const errors = validator
242
- .Errors(args)
243
- .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
244
- .join("\n") || "Unknown validation error";
346
+ const errors = formatDiscriminatedUnionError(tool.parameters, validator, args) ??
347
+ (validator
348
+ .Errors(args)
349
+ .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
350
+ .join("\n") ||
351
+ "Unknown validation error");
245
352
  const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
246
353
  throw new Error(errorMessage);
247
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-ai",
3
- "version": "2026.10.30-2",
3
+ "version": "2026.10.31-2",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,7 +88,7 @@
88
88
  "dependencies": {
89
89
  "@anthropic-ai/sdk": "0.127.0",
90
90
  "@aws-sdk/client-bedrock-runtime": "3.1136.0",
91
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.30-2",
91
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.31-2",
92
92
  "@google/genai": "2.23.0",
93
93
  "@smithy/node-http-handler": "4.12.1",
94
94
  "google-auth-library": "10.6.2",
@@ -1,40 +0,0 @@
1
- /**
2
- * Loopback callback listener for the Anthropic OAuth flow.
3
- *
4
- * The OAuth client accepts any `localhost` port on the `/callback` path (the
5
- * Claude Code CLI itself binds a random port per login), so the listener
6
- * prefers the historical port 53692 and falls back to an ephemeral port when
7
- * another login - in this process or in another one - still holds it. With a
8
- * fixed port, a second login on the same machine sent its browser redirect to
9
- * the OTHER process's listener, which could only answer "State mismatch".
10
- *
11
- * NOTE: Node-only (http.createServer); reached through the lazy OAuth loader.
12
- */
13
- export type CallbackCode = {
14
- code: string;
15
- state: string;
16
- };
17
- export type CallbackListener = {
18
- /** Bound loopback port; `undefined` when no port could be bound (manual-only login). */
19
- port?: number;
20
- redirectUri: string;
21
- callbackUnavailable?: {
22
- code: string;
23
- };
24
- waitForCode: () => Promise<CallbackCode | null>;
25
- cancelWait: () => void;
26
- close: () => Promise<void>;
27
- };
28
- export type CallbackListenerApis = {
29
- createServer: typeof import("node:http").createServer;
30
- };
31
- export declare const PREFERRED_CALLBACK_PORT = 53692;
32
- export declare const CALLBACK_PATH = "/callback";
33
- export declare function callbackRedirectUri(port: number): string;
34
- /**
35
- * Binds the preferred port, then an ephemeral one; when neither can be bound
36
- * the login continues in manual mode with the registered preferred-port
37
- * redirect URI, so a pasted redirect URL still exchanges.
38
- */
39
- export declare function startCallbackListener(apis: CallbackListenerApis, expectedState: string, host: string): Promise<CallbackListener>;
40
- //# sourceMappingURL=anthropic-callback-listener.d.ts.map