@code-yeongyu/senpi-ai 2026.10.9 → 2026.10.10-3

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.
@@ -1,6 +1,12 @@
1
1
  import type { Api, Model } from "../types.ts";
2
2
  /** The wire identity a forced tool_choice refusal is remembered under. */
3
3
  export type ForcedToolChoiceTarget = Pick<Model<Api>, "api" | "provider" | "baseUrl" | "id">;
4
+ /**
5
+ * Reads `stream` until it has produced content (or ended, or `maxBuffered` contentless events went
6
+ * by), so a refusal a gateway sends in-band after answering 200 is raised while the request can still
7
+ * be retried. Such an error is marked as a request refusal; the events read are replayed in order.
8
+ */
9
+ export declare function primeStreamUntilContent<T>(stream: AsyncIterable<T>, hasContent: (event: T) => boolean, maxBuffered?: number): Promise<AsyncIterable<T>>;
4
10
  export declare function isForcedToolChoiceUnsupportedError(error: unknown, sentForcedToolChoice: boolean): boolean;
5
11
  /**
6
12
  * A 400 to a request that forced a tool and whose message names that very tool: its `tools.N` /
@@ -30,8 +30,75 @@ function errorMessage(error) {
30
30
  }
31
31
  return String(error);
32
32
  }
33
+ /** Marks an error a stream raised in-band, after its 200 response but before any content (senpi#2801). */
34
+ const IN_BAND_BEFORE_CONTENT = Symbol.for("senpi.forcedToolChoice.inBandBeforeContent");
35
+ function isInBandBeforeContent(error) {
36
+ return isRecord(error) && error[IN_BAND_BEFORE_CONTENT] === true;
37
+ }
38
+ function markInBandBeforeContent(error) {
39
+ const marked = isRecord(error) ? error : new Error(errorMessage(error));
40
+ try {
41
+ Object.defineProperty(marked, IN_BAND_BEFORE_CONTENT, { value: true });
42
+ }
43
+ catch {
44
+ // A frozen error keeps its own identity; it is then judged like any other in-band error.
45
+ }
46
+ return marked;
47
+ }
48
+ /**
49
+ * A request refusal: the HTTP 400 that rejects the request, or the same refusal a gateway that has
50
+ * already answered 200 (a keepalive stream) sends in-band before any content (senpi#2801).
51
+ */
52
+ function isRequestRefusal(error) {
53
+ const status = extractHttpStatus(error);
54
+ return status === 400 || (status === undefined && isInBandBeforeContent(error));
55
+ }
56
+ /**
57
+ * Reads `stream` until it has produced content (or ended, or `maxBuffered` contentless events went
58
+ * by), so a refusal a gateway sends in-band after answering 200 is raised while the request can still
59
+ * be retried. Such an error is marked as a request refusal; the events read are replayed in order.
60
+ */
61
+ export async function primeStreamUntilContent(stream, hasContent, maxBuffered = 64) {
62
+ const iterator = stream[Symbol.asyncIterator]();
63
+ const buffered = [];
64
+ let ended = false;
65
+ try {
66
+ while (buffered.length < maxBuffered) {
67
+ const next = await iterator.next();
68
+ if (next.done) {
69
+ ended = true;
70
+ break;
71
+ }
72
+ buffered.push(next.value);
73
+ if (hasContent(next.value))
74
+ break;
75
+ }
76
+ }
77
+ catch (error) {
78
+ throw markInBandBeforeContent(error);
79
+ }
80
+ return {
81
+ async *[Symbol.asyncIterator]() {
82
+ try {
83
+ yield* buffered;
84
+ if (ended)
85
+ return;
86
+ while (true) {
87
+ const next = await iterator.next();
88
+ if (next.done)
89
+ return;
90
+ yield next.value;
91
+ }
92
+ }
93
+ finally {
94
+ if (!ended)
95
+ await iterator.return?.();
96
+ }
97
+ },
98
+ };
99
+ }
33
100
  export function isForcedToolChoiceUnsupportedError(error, sentForcedToolChoice) {
34
- if (!sentForcedToolChoice || extractHttpStatus(error) !== 400) {
101
+ if (!sentForcedToolChoice || !isRequestRefusal(error)) {
35
102
  return false;
36
103
  }
37
104
  const message = errorMessage(error);
@@ -77,7 +144,7 @@ function escapeRegExp(text) {
77
144
  * no tool, or another one, is not this refusal.
78
145
  */
79
146
  export function refusalNamesForcedTool(error, params) {
80
- if (extractHttpStatus(error) !== 400)
147
+ if (!isRequestRefusal(error))
81
148
  return false;
82
149
  const name = forcedToolName(params.tool_choice);
83
150
  if (name === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-ai",
3
- "version": "2026.10.9",
3
+ "version": "2026.10.10-3",
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.9",
91
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.10-3",
92
92
  "@google/genai": "2.23.0",
93
93
  "@smithy/node-http-handler": "4.12.1",
94
94
  "http-proxy-agent": "9.1.0",