@code-yeongyu/senpi-ai 2026.11.9 → 2026.11.10-1

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.
@@ -0,0 +1,14 @@
1
+ import type { Api, CacheRetention, Model } from "../types.ts";
2
+ /**
3
+ * Which OpenAI prompt-cache contract a Responses-lane request runs under
4
+ * (https://developers.openai.com/api/docs/guides/prompt-caching, "Cache lifetime"):
5
+ *
6
+ * - `chatgpt-subscription`: signed-in ChatGPT usage (Codex backend or Sign in with ChatGPT). OpenAI keeps this
7
+ * cache with no expiry (senpi#6290); no API doc publishes a lifetime, so it carries no TTL to schedule around.
8
+ * - `api-long`: GPT-5.6+ on a request that sends `prompt_cache_options.ttl: "12h"` (12-hour minimum lifetime).
9
+ * - `api-extended`: GPT-5.6+ at the default `"30m"` minimum lifetime.
10
+ * - `api-short`: earlier models, and gateways that proxy GPT-5.6+ ids without OpenAI's contract.
11
+ */
12
+ export type OpenAIPromptCacheLane = "chatgpt-subscription" | "api-long" | "api-extended" | "api-short";
13
+ export declare function classifyOpenAIPromptCacheLane(model: Model<Api>, retention: CacheRetention): OpenAIPromptCacheLane;
14
+ //# sourceMappingURL=openai-prompt-cache-lane.d.ts.map
@@ -0,0 +1,58 @@
1
+ /** GPT-5.6 and later (`gpt-5.6-*`, `gpt-5.10`, `gpt-6-*`, `gpt-7`, ...). */
2
+ const OPENAI_EXTENDED_CACHE_MODEL_ID = /^gpt-(?:5\.(?:[6-9]|\d{2,})|(?:[6-9]|\d{2,})(?:\.\d+)?)(?:-|$)/i;
3
+ /** The Codex backend serves only signed-in ChatGPT usage, whatever provider id a models.json entry gives it. */
4
+ const CHATGPT_CODEX_BACKEND_HOST = "chatgpt.com";
5
+ /** Sign in with ChatGPT routes plan usage to the API host, so only the `siwc` compat flag tells it from an API key. */
6
+ const SIWC_HOST = "api.openai.com";
7
+ function hostnameOf(baseUrl) {
8
+ try {
9
+ return new URL(baseUrl).hostname.toLowerCase();
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ function responsesCompat(model) {
16
+ return model.compat;
17
+ }
18
+ function isChatGptSubscription(model) {
19
+ const host = hostnameOf(model.baseUrl);
20
+ if (model.api === "openai-codex-responses")
21
+ return host === CHATGPT_CODEX_BACKEND_HOST;
22
+ return model.api === "openai-responses" && host === SIWC_HOST && responsesCompat(model)?.siwc === true;
23
+ }
24
+ /**
25
+ * The OpenAI-operated Responses lanes serving a GPT-5.6+ model, where OpenAI documents a cache lifetime of at
26
+ * least 30 minutes. Gateways that merely proxy the same model ids keep the short lifetime: their routing does
27
+ * not carry that contract.
28
+ */
29
+ function hasExtendedPromptCache(model) {
30
+ if (model.api === "openai-responses") {
31
+ if (responsesCompat(model)?.supportsExplicitPromptCacheMode === true)
32
+ return true;
33
+ if (model.provider !== "openai" && hostnameOf(model.baseUrl) !== "api.openai.com")
34
+ return false;
35
+ }
36
+ return OPENAI_EXTENDED_CACHE_MODEL_ID.test(model.id);
37
+ }
38
+ /**
39
+ * Mirrors the `openai-responses` adapter's `getPromptCacheOptions()`: `"12h"` is sent only on `long` retention,
40
+ * to a model that accepts `prompt_cache_options` outside Sign in with ChatGPT, unless compat opts out of long
41
+ * retention. Azure and Codex-protocol requests carry no `prompt_cache_options`.
42
+ */
43
+ function sendsTwelveHourTtl(model, retention) {
44
+ const compat = responsesCompat(model);
45
+ return (retention === "long" &&
46
+ model.api === "openai-responses" &&
47
+ compat?.siwc !== true &&
48
+ compat?.supportsExplicitPromptCacheMode === true &&
49
+ compat.supportsLongCacheRetention !== false);
50
+ }
51
+ export function classifyOpenAIPromptCacheLane(model, retention) {
52
+ if (isChatGptSubscription(model))
53
+ return "chatgpt-subscription";
54
+ if (sendsTwelveHourTtl(model, retention))
55
+ return "api-long";
56
+ return hasExtendedPromptCache(model) ? "api-extended" : "api-short";
57
+ }
58
+ //# sourceMappingURL=openai-prompt-cache-lane.js.map
@@ -2,16 +2,19 @@ import type { AnthropicMessagesCompat, Api, Model, OpenAICompletionsCompat, Prov
2
2
  export declare const PROMPT_CACHE_TTL_SHORT_SECONDS = 300;
3
3
  export declare const PROMPT_CACHE_TTL_LONG_SECONDS = 3600;
4
4
  /**
5
- * OpenAI GPT-5.6 and later: `prompt_cache_options.ttl` accepts only `"30m"`, and the cache stays
6
- * eligible at least 30 minutes after the latest write or reuse.
5
+ * OpenAI GPT-5.6 and later: the default `prompt_cache_options.ttl: "30m"` keeps the cache eligible at least
6
+ * 30 minutes after the latest write or reuse.
7
7
  */
8
8
  export declare const PROMPT_CACHE_TTL_OPENAI_EXTENDED_SECONDS = 1800;
9
+ /** OpenAI GPT-5.6 and later on the Responses API: `prompt_cache_options.ttl: "12h"`, a 12-hour minimum lifetime. */
10
+ export declare const PROMPT_CACHE_TTL_OPENAI_LONG_SECONDS = 43200;
9
11
  /**
10
12
  * How long a provider promises to keep a prompt prefix cached.
11
13
  *
12
14
  * - `ttl`: an explicit expiry contract; `ttlSeconds` is the conservative lifetime after the last write or reuse.
13
- * - `best-effort`: the provider caches automatically but promises no expiry (direct DeepSeek clears unused entries
14
- * after hours to days), so nothing needs to wake or ping solely to preserve the cache.
15
+ * - `best-effort`: the provider caches automatically with no expiry senpi can schedule around (direct DeepSeek
16
+ * clears unused entries after hours to days; signed-in ChatGPT keeps its cache with no expiry), so nothing needs
17
+ * to wake or ping solely to preserve the cache.
15
18
  * - `none`: caching is disabled or the lane has no known cache contract.
16
19
  */
17
20
  export type PromptCacheLifetime = {
@@ -1,16 +1,25 @@
1
+ import { classifyOpenAIPromptCacheLane } from "./openai-prompt-cache-lane.js";
1
2
  import { getProviderEnvValue } from "./provider-env.js";
2
3
  export const PROMPT_CACHE_TTL_SHORT_SECONDS = 300;
3
4
  export const PROMPT_CACHE_TTL_LONG_SECONDS = 3600;
4
5
  /**
5
- * OpenAI GPT-5.6 and later: `prompt_cache_options.ttl` accepts only `"30m"`, and the cache stays
6
- * eligible at least 30 minutes after the latest write or reuse.
6
+ * OpenAI GPT-5.6 and later: the default `prompt_cache_options.ttl: "30m"` keeps the cache eligible at least
7
+ * 30 minutes after the latest write or reuse.
7
8
  */
8
9
  export const PROMPT_CACHE_TTL_OPENAI_EXTENDED_SECONDS = 1800;
10
+ /** OpenAI GPT-5.6 and later on the Responses API: `prompt_cache_options.ttl: "12h"`, a 12-hour minimum lifetime. */
11
+ export const PROMPT_CACHE_TTL_OPENAI_LONG_SECONDS = 43_200;
9
12
  const NO_PROMPT_CACHE = { kind: "none" };
10
13
  const BEST_EFFORT_PROMPT_CACHE = { kind: "best-effort" };
11
14
  function ttl(ttlSeconds) {
12
15
  return { kind: "ttl", ttlSeconds };
13
16
  }
17
+ const OPENAI_LANE_PROMPT_CACHE_LIFETIME = {
18
+ "chatgpt-subscription": BEST_EFFORT_PROMPT_CACHE,
19
+ "api-long": ttl(PROMPT_CACHE_TTL_OPENAI_LONG_SECONDS),
20
+ "api-extended": ttl(PROMPT_CACHE_TTL_OPENAI_EXTENDED_SECONDS),
21
+ "api-short": ttl(PROMPT_CACHE_TTL_SHORT_SECONDS),
22
+ };
14
23
  function hostnameOf(baseUrl) {
15
24
  try {
16
25
  return new URL(baseUrl).hostname.toLowerCase();
@@ -333,23 +342,6 @@ function resolveOpenAIResponsesCacheRetention(cacheRetention, env) {
333
342
  function isDirectDeepSeekModel(model) {
334
343
  return model.provider === "deepseek" || hostnameOf(model.baseUrl) === "api.deepseek.com";
335
344
  }
336
- /** GPT-5.6 and later (`gpt-5.6-*`, `gpt-5.10`, `gpt-6-*`, `gpt-7`, ...). */
337
- const OPENAI_EXTENDED_CACHE_MODEL_ID = /^gpt-(?:5\.(?:[6-9]|\d{2,})|(?:[6-9]|\d{2,})(?:\.\d+)?)(?:-|$)/i;
338
- /**
339
- * The OpenAI-operated Responses lanes (OpenAI API, Azure OpenAI, ChatGPT subscription) serving a GPT-5.6+
340
- * model, where OpenAI documents a cache lifetime of at least 30 minutes. Gateways that merely proxy the same
341
- * model ids keep the conservative short lifetime because their routing does not carry that contract.
342
- */
343
- function hasOpenAIExtendedPromptCache(model) {
344
- if (model.api === "openai-responses") {
345
- const responsesModel = model;
346
- if (responsesModel.compat?.supportsExplicitPromptCacheMode === true)
347
- return true;
348
- if (responsesModel.provider !== "openai" && hostnameOf(responsesModel.baseUrl) !== "api.openai.com")
349
- return false;
350
- }
351
- return OPENAI_EXTENDED_CACHE_MODEL_ID.test(model.id);
352
- }
353
345
  /** Env that puts Claude Code on API-key, gateway or cloud billing, where it caches the main conversation for 5 minutes. */
354
346
  const CLAUDE_CODE_API_BILLING_ENV = [
355
347
  "ANTHROPIC_API_KEY",
@@ -429,9 +421,7 @@ export function resolvePromptCacheLifetime(model, env) {
429
421
  const retention = resolveOpenAIResponsesCacheRetention(model.cacheRetention, env);
430
422
  if (retention === "none")
431
423
  return NO_PROMPT_CACHE;
432
- return hasOpenAIExtendedPromptCache(model)
433
- ? ttl(PROMPT_CACHE_TTL_OPENAI_EXTENDED_SECONDS)
434
- : ttl(PROMPT_CACHE_TTL_SHORT_SECONDS);
424
+ return OPENAI_LANE_PROMPT_CACHE_LIFETIME[classifyOpenAIPromptCacheLane(model, retention)];
435
425
  }
436
426
  default:
437
427
  return NO_PROMPT_CACHE;
@@ -239,7 +239,10 @@ export function parseRetryAfterMsMarker(message) {
239
239
  /* ---------- wall-clock reset prose ---------- */
240
240
  // A reset written as a wall clock: "resets 12am (Asia/Seoul)", "resets Oct 2, 9am", "try again at 12:00 AM".
241
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.
242
+ // from `nowMs` to the next such moment, or undefined.
243
+ // Zone rule (senpi#5097): a time with no zone reads in the host's local zone, for every caller, as the user's own
244
+ // clock. A reset in another zone is read correctly only when the text names it ("(UTC)", "(Asia/Seoul)"); a
245
+ // zone-less time far from the truth only makes the hint long, and long hints already defer or fail over.
243
246
  const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
244
247
  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
248
  export function clockResetMs(text, nowMs) {
@@ -164,6 +164,84 @@ function normalizeOptionalNulls(value, schema) {
164
164
  }
165
165
  }
166
166
  }
167
+ /** JSON types a schema accepts, or undefined when its shape does not say (const/enum/$ref/allOf). */
168
+ function acceptedJsonTypes(schema) {
169
+ const types = getSchemaTypes(schema);
170
+ if (types.length > 0)
171
+ return new Set(types);
172
+ const members = schema.anyOf ?? schema.oneOf;
173
+ if (!Array.isArray(members) || members.length === 0)
174
+ return undefined;
175
+ const accepted = new Set();
176
+ for (const member of members) {
177
+ const memberTypes = acceptedJsonTypes(member);
178
+ if (memberTypes === undefined)
179
+ return undefined;
180
+ for (const type of memberTypes)
181
+ accepted.add(type);
182
+ }
183
+ return accepted;
184
+ }
185
+ function parseJsonStructure(text, accepted) {
186
+ const trimmed = text.trim();
187
+ const wantsArray = accepted.has("array") && trimmed.startsWith("[");
188
+ const wantsObject = accepted.has("object") && trimmed.startsWith("{");
189
+ if (!wantsArray && !wantsObject)
190
+ return undefined;
191
+ try {
192
+ const parsed = JSON.parse(trimmed);
193
+ if (wantsArray && Array.isArray(parsed))
194
+ return parsed;
195
+ if (wantsObject && matchesJsonType(parsed, "object"))
196
+ return parsed;
197
+ }
198
+ catch (error) {
199
+ if (!(error instanceof SyntaxError))
200
+ throw error;
201
+ }
202
+ return undefined;
203
+ }
204
+ /**
205
+ * Models sometimes send an array or object argument as its JSON text (senpi#6265: the later calls of a
206
+ * parallel generate_image turn carried `reference_image_paths` as `'["/a.png"]'`). Value.Convert would
207
+ * wrap that text into a one-element array that passes validation, so the tool ran with the JSON text
208
+ * as a path. Decode the text back to the structure the schema declares before conversion, only where
209
+ * the schema rejects strings and the text parses to that declared array/object.
210
+ */
211
+ function decodeJsonEncodedStructures(value, schema) {
212
+ if (typeof value === "string") {
213
+ const accepted = acceptedJsonTypes(schema);
214
+ if (accepted === undefined || accepted.has("string"))
215
+ return value;
216
+ const decoded = parseJsonStructure(value, accepted);
217
+ return decoded === undefined ? value : decodeJsonEncodedStructures(decoded, schema);
218
+ }
219
+ const types = getSchemaTypes(schema);
220
+ if (Array.isArray(value) && types.includes("array")) {
221
+ const items = schema.items;
222
+ for (let index = 0; index < value.length; index++) {
223
+ const itemSchema = Array.isArray(items) ? items[index] : items;
224
+ if (itemSchema)
225
+ value[index] = decodeJsonEncodedStructures(value[index], itemSchema);
226
+ }
227
+ return value;
228
+ }
229
+ if (matchesJsonType(value, "object") && types.includes("object")) {
230
+ const record = value;
231
+ const properties = schema.properties ?? {};
232
+ const additional = schema.additionalProperties;
233
+ for (const key of Object.keys(record)) {
234
+ const propertySchema = Object.hasOwn(properties, key)
235
+ ? properties[key]
236
+ : typeof additional === "object"
237
+ ? additional
238
+ : undefined;
239
+ if (propertySchema)
240
+ record[key] = decodeJsonEncodedStructures(record[key], propertySchema);
241
+ }
242
+ }
243
+ return value;
244
+ }
167
245
  function getValidator(schema) {
168
246
  const key = schema;
169
247
  const cached = validatorCache.get(key);
@@ -324,6 +402,7 @@ export function validateToolArguments(tool, toolCall) {
324
402
  return args;
325
403
  }
326
404
  normalizeOptionalNulls(args, tool.parameters);
405
+ decodeJsonEncodedStructures(args, tool.parameters);
327
406
  Value.Convert(tool.parameters, args);
328
407
  const validator = getValidator(tool.parameters);
329
408
  if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-ai",
3
- "version": "2026.11.9",
3
+ "version": "2026.11.10-1",
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.11.9",
91
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.11.10-1",
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,21 +0,0 @@
1
- /**
2
- * Launch-day edit block. Fable 5.5 is UNRELEASED; these ids are provisional.
3
- * Confirm identity, optional snapshot and Claude Code floor from live evidence.
4
- * Prices, context/output limits and status/deprecation come ONLY from models.dev
5
- * during regeneration, never from a local guessed price card or catalog row.
6
- * Keep recommendations disabled unless the optional promotion commit is wanted.
7
- */
8
- export declare const FABLE55_LAUNCH: {
9
- readonly modelId: "claude-fable-5-5";
10
- readonly mythosModelId: "claude-mythos-5-5";
11
- readonly snapshotId: string | null;
12
- readonly minimumClaudeCodeVersion: string | null;
13
- readonly released: false;
14
- readonly promoteDefaults: false;
15
- /** Server-side refusal fallbacks. Fable 5.1 baseline until Anthropic's launch docs name the 5.5 list. */
16
- readonly refusalFallbackModels: readonly string[];
17
- readonly metadataSource: "models.dev";
18
- };
19
- /** Carry the existing Fable/Mythos routing convention, including gateway spellings. */
20
- export declare function isFable55ModelId(modelId: string): boolean;
21
- //# sourceMappingURL=fable55-launch.d.ts.map
@@ -1,37 +0,0 @@
1
- /**
2
- * Launch-day edit block. Fable 5.5 is UNRELEASED; these ids are provisional.
3
- * Confirm identity, optional snapshot and Claude Code floor from live evidence.
4
- * Prices, context/output limits and status/deprecation come ONLY from models.dev
5
- * during regeneration, never from a local guessed price card or catalog row.
6
- * Keep recommendations disabled unless the optional promotion commit is wanted.
7
- */
8
- export const FABLE55_LAUNCH = {
9
- modelId: "claude-fable-5-5",
10
- mythosModelId: "claude-mythos-5-5",
11
- snapshotId: null,
12
- minimumClaudeCodeVersion: null,
13
- released: false,
14
- promoteDefaults: false,
15
- /** Server-side refusal fallbacks. Fable 5.1 baseline until Anthropic's launch docs name the 5.5 list. */
16
- refusalFallbackModels: ["claude-opus-4-8", "claude-opus-5"],
17
- metadataSource: "models.dev",
18
- };
19
- /** Carry the existing Fable/Mythos routing convention, including gateway spellings. */
20
- export function isFable55ModelId(modelId) {
21
- const normalized = modelId.toLowerCase().replace(/\s+/g, "-");
22
- return [FABLE55_LAUNCH.modelId, FABLE55_LAUNCH.mythosModelId, FABLE55_LAUNCH.snapshotId].some((id) => {
23
- if (!id)
24
- return false;
25
- const family = id.replace(/^claude-/, "");
26
- const dotted = family.replace(/-(\d+)-(\d+)$/, "-$1.$2");
27
- return [family, dotted].some((marker) => {
28
- const at = normalized.indexOf(marker);
29
- if (at < 0)
30
- return false;
31
- const before = normalized[at - 1];
32
- const after = normalized[at + marker.length];
33
- return (!before || /[/@:._-]/.test(before)) && (!after || /[/@:._-]/.test(after));
34
- });
35
- });
36
- }
37
- //# sourceMappingURL=fable55-launch.js.map