@jxsuite/ai 0.39.2 → 0.39.4
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.
- package/README.md +7 -7
- package/package.json +5 -5
- package/src/chat-state.ts +24 -19
- package/src/gateway/chat.ts +17 -1
- package/src/gateway/index.ts +1 -1
- package/src/gateway/normalize.ts +54 -53
- package/src/gateway/problem.ts +33 -21
- package/src/gateway/sse.ts +2 -2
- package/src/gateway/upstream-error.ts +50 -18
- package/src/harness/index.ts +1 -1
- package/src/harness/policy.ts +1 -1
- package/src/harness/run.ts +46 -27
- package/src/index.ts +1 -0
- package/src/messages/convert.ts +93 -38
- package/src/messages/openai.ts +45 -24
- package/src/messages/types.ts +3 -2
- package/src/streaming-client.ts +181 -111
- package/src/tools.ts +144 -52
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@ The provider-agnostic substrate Jx Studio's assistant is built on: a streaming L
|
|
|
4
4
|
|
|
5
5
|
Governing spec: [`specs/ai.md`](../../specs/ai.md) §1-§2 (Status: Partial). User-facing documentation for the Studio feature lives at [`docs/studio/ai.md`](../../docs/studio/ai.md).
|
|
6
6
|
|
|
7
|
-
| Entrypoint | Exports
|
|
8
|
-
| ------------------------------ |
|
|
9
|
-
| `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, the `ChatUsage` type, `STREAM_EVENT_TYPES`, the streaming types, and the prompt-cache hints (below)
|
|
10
|
-
| `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its eight members, the three factories and their option types, `STREAM_EVENT_TYPES`, `usageEventFromOpenAI` and its `OpenAIUsage` input type, and the prompt-cache hints
|
|
11
|
-
| `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, the call context (`createToolContext`, `createLedger`, `createSessionFacts`, `linkCallSignal`), and their types
|
|
12
|
-
| `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `ChatUsage`, `
|
|
13
|
-
| `@jxsuite/ai/gateway` | `createChatHandler`, `modelsResponse`, `normalizeOpenAIStream`, `encodeSse`, `problemResponse`, `extractUpstreamErrorMessage`, the prompt-cache hints, and the `Upstream`, `GatewayRefusal`, `ChatAdmission` and `ChatGatewayOptions` types |
|
|
7
|
+
| Entrypoint | Exports |
|
|
8
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, the `ChatUsage` type, `STREAM_EVENT_TYPES`, the streaming types, and the prompt-cache hints (below) |
|
|
10
|
+
| `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its eight members, the three factories and their option types, `STREAM_EVENT_TYPES`, `usageEventFromOpenAI` and its `OpenAIUsage` input type, and the prompt-cache hints |
|
|
11
|
+
| `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, the call context (`createToolContext`, `createLedger`, `createSessionFacts`, `linkCallSignal`), and their types |
|
|
12
|
+
| `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `ChatUsage`, `ChatState`; the messages it holds are `LiveMessage`, `LiveToolCall` and `Role` from `@jxsuite/ai/messages`, which this subpath also exports under their former names `Message`, `ToolCallRecord` and `MessageRole` |
|
|
13
|
+
| `@jxsuite/ai/gateway` | `createChatHandler`, `DEFAULT_UPSTREAM_BASE_URL` (the upstream it targets when none is configured), `modelsResponse`, `normalizeOpenAIStream`, `encodeSse`, `problemResponse`, `extractUpstreamErrorMessage`, the prompt-cache hints, and the `Upstream`, `GatewayRefusal`, `ChatAdmission` and `ChatGatewayOptions` types |
|
|
14
14
|
|
|
15
15
|
`toolSuccess` / `toolError` are **not** re-exported by the barrel. Import them from `@jxsuite/ai/tools`. Importing the subpaths instead of the root is also what makes the package tree-shakeable.
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/ai",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.4",
|
|
4
4
|
"description": "AI infrastructure for Jx Suite — streaming LLM client abstraction, tool registry, and reactive chat state",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"upgrade": "bun update --latest && bun install"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jxsuite/protocol": "^
|
|
35
|
-
"@vue/reactivity": "3.5.
|
|
34
|
+
"@jxsuite/protocol": "^3.0.0",
|
|
35
|
+
"@vue/reactivity": "3.5.44"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@cloudflare/workers-types": "^5.
|
|
39
|
-
"@happy-dom/global-registrator": "^
|
|
38
|
+
"@cloudflare/workers-types": "^5.20261016.1",
|
|
39
|
+
"@happy-dom/global-registrator": "^21.0.3"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/chat-state.ts
CHANGED
|
@@ -14,19 +14,21 @@ import { reactive } from "@vue/reactivity";
|
|
|
14
14
|
import { nextMessageId } from "./message-id.ts";
|
|
15
15
|
import { toChatMessages } from "./messages/convert.ts";
|
|
16
16
|
import { toOpenAIMessages } from "./messages/openai.ts";
|
|
17
|
-
import type { LiveMessage, LiveToolCall, OpenAIMessage
|
|
17
|
+
import type { LiveMessage, LiveToolCall, OpenAIMessage } from "./messages/types.ts";
|
|
18
18
|
import type { StreamUsageEvent } from "./streaming-client.ts";
|
|
19
19
|
import type { ToolResult } from "./tools.ts";
|
|
20
20
|
|
|
21
21
|
export type ChatState = "idle" | "streaming" | "error";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The published `@jxsuite/ai/chat-state` names for the transcript types, kept for existing
|
|
25
|
+
* importers.
|
|
26
|
+
*/
|
|
27
|
+
export type {
|
|
28
|
+
LiveMessage as Message,
|
|
29
|
+
LiveToolCall as ToolCallRecord,
|
|
30
|
+
Role as MessageRole,
|
|
31
|
+
} from "./messages/types.ts";
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* The provider's own count for the last request, and where in the transcript it was taken.
|
|
@@ -57,10 +59,10 @@ export interface ChatUsage {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export interface ChatStore {
|
|
60
|
-
messages:
|
|
62
|
+
messages: LiveMessage[];
|
|
61
63
|
status: ChatState;
|
|
62
64
|
streamingContent: string;
|
|
63
|
-
pendingToolCalls:
|
|
65
|
+
pendingToolCalls: LiveToolCall[];
|
|
64
66
|
error: string | null;
|
|
65
67
|
model: string;
|
|
66
68
|
tokenCount: number;
|
|
@@ -126,7 +128,7 @@ export function createChatState(opts: { model?: string } = {}) {
|
|
|
126
128
|
usage: null,
|
|
127
129
|
});
|
|
128
130
|
|
|
129
|
-
let _streamingMessage:
|
|
131
|
+
let _streamingMessage: LiveMessage | null = null;
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
134
|
* Add a user message and prepare for streaming response.
|
|
@@ -138,12 +140,12 @@ export function createChatState(opts: { model?: string } = {}) {
|
|
|
138
140
|
return;
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
const userMsg = {
|
|
143
|
+
const userMsg: LiveMessage = {
|
|
142
144
|
id: uid(),
|
|
143
145
|
role: "user",
|
|
144
146
|
content: text,
|
|
145
147
|
timestamp: Date.now(),
|
|
146
|
-
}
|
|
148
|
+
};
|
|
147
149
|
store.messages.push(userMsg);
|
|
148
150
|
|
|
149
151
|
beginAssistantTurn();
|
|
@@ -172,7 +174,7 @@ export function createChatState(opts: { model?: string } = {}) {
|
|
|
172
174
|
role: "assistant",
|
|
173
175
|
content: "",
|
|
174
176
|
timestamp: Date.now(),
|
|
175
|
-
}
|
|
177
|
+
};
|
|
176
178
|
store.messages.push(_streamingMessage);
|
|
177
179
|
/*
|
|
178
180
|
* Re-read through the reactive proxy so appendDelta / appendToolCallStart mutations notify
|
|
@@ -196,7 +198,7 @@ export function createChatState(opts: { model?: string } = {}) {
|
|
|
196
198
|
toolCallId,
|
|
197
199
|
content,
|
|
198
200
|
timestamp: Date.now(),
|
|
199
|
-
}
|
|
201
|
+
});
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
/**
|
|
@@ -237,12 +239,12 @@ export function createChatState(opts: { model?: string } = {}) {
|
|
|
237
239
|
if (store.status !== "streaming") {
|
|
238
240
|
return;
|
|
239
241
|
}
|
|
240
|
-
const tc = {
|
|
242
|
+
const tc: LiveToolCall = {
|
|
241
243
|
id,
|
|
242
244
|
name,
|
|
243
245
|
arguments: "",
|
|
244
246
|
result: null,
|
|
245
|
-
}
|
|
247
|
+
};
|
|
246
248
|
store.pendingToolCalls.push(tc);
|
|
247
249
|
|
|
248
250
|
if (_streamingMessage) {
|
|
@@ -423,13 +425,16 @@ export function createChatState(opts: { model?: string } = {}) {
|
|
|
423
425
|
const replayed = sent?.role === "assistant" && Boolean(sent.reasoning_content);
|
|
424
426
|
const unreplayed = replayed || !isCount(usage.reasoningTokens) ? 0 : usage.reasoningTokens;
|
|
425
427
|
const contextTokens = Math.max(0, usage.inputTokens + usage.outputTokens - unreplayed);
|
|
426
|
-
|
|
428
|
+
const recorded: ChatUsage = {
|
|
427
429
|
...counts,
|
|
428
430
|
contextTokens,
|
|
429
431
|
lastMessageId: store.messages.at(-1)?.id ?? "",
|
|
430
432
|
messageCount: store.messages.length,
|
|
431
|
-
...(extra.systemTokens === undefined ? {} : { systemTokens: extra.systemTokens }),
|
|
432
433
|
};
|
|
434
|
+
if (extra.systemTokens !== undefined) {
|
|
435
|
+
recorded.systemTokens = extra.systemTokens;
|
|
436
|
+
}
|
|
437
|
+
store.usage = recorded;
|
|
433
438
|
store.tokenCount = contextTokens;
|
|
434
439
|
}
|
|
435
440
|
|
package/src/gateway/chat.ts
CHANGED
|
@@ -11,6 +11,15 @@ import { problemResponse, refusal } from "./problem.ts";
|
|
|
11
11
|
import { encodeSse, SSE_HEADERS } from "./sse.ts";
|
|
12
12
|
import type { ChatGatewayOptions, GatewayRefusal, Upstream } from "./types.ts";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The upstream a host forwards to when it configures none: OpenAI's own chat-completions API.
|
|
16
|
+
*
|
|
17
|
+
* The gateway never applies it itself (which upstream a request may reach is host policy, supplied
|
|
18
|
+
* through `resolveUpstream`). It lives here so every host that falls back to OpenAI, the dev server
|
|
19
|
+
* and the assistant's eval runner among them, names the same URL.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_UPSTREAM_BASE_URL = "https://api.openai.com/v1";
|
|
22
|
+
|
|
14
23
|
/** The refusal's detail for a body that is not JSON. */
|
|
15
24
|
const INVALID_JSON = "Invalid JSON body";
|
|
16
25
|
|
|
@@ -160,6 +169,10 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
|
|
|
160
169
|
JSON, as it was when the server read it with Bun's `req.json()`. The default decoder would
|
|
161
170
|
strip it and accept the body; this extraction changes no byte a client receives. */
|
|
162
171
|
const text = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(bytes);
|
|
172
|
+
/* SAFETY: every member of ChatRequestBody is optional and `unknown`, so the type claims only
|
|
173
|
+
that the parsed value can be destructured; each member is checked (messages, tools) or
|
|
174
|
+
forwarded as `unknown` (model, systemPrompt) below, and a JSON `null` throws at the
|
|
175
|
+
destructuring, as the route always has. */
|
|
163
176
|
body = JSON.parse(text) as ChatRequestBody;
|
|
164
177
|
} catch {
|
|
165
178
|
return problemResponse(refusal("invalidRequest", INVALID_JSON));
|
|
@@ -192,13 +205,16 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
|
|
|
192
205
|
context,
|
|
193
206
|
);
|
|
194
207
|
|
|
208
|
+
// SAFETY: Array.isArray above proved `messages` is an array; its entries are forwarded unread.
|
|
195
209
|
const upstreamBody: UpstreamBody = {
|
|
196
210
|
model,
|
|
197
211
|
messages: [{ role: "system", content: systemPrompt }, ...(messages as unknown[])],
|
|
198
212
|
stream: true,
|
|
199
213
|
stream_options: { include_usage: true },
|
|
200
214
|
};
|
|
201
|
-
|
|
215
|
+
/* The wire has always forwarded `tools` as sent, so a non-array with a length passes through.
|
|
216
|
+
SAFETY: only `length` is read, off a truthy value, and that read is total: a value with no
|
|
217
|
+
numeric `length` yields `undefined` (or its own member), which `> 0` compares as it always has. */
|
|
202
218
|
if (tools && (tools as { length: number }).length > 0) {
|
|
203
219
|
upstreamBody.tools = tools;
|
|
204
220
|
upstreamBody.tool_choice = "auto";
|
package/src/gateway/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export {
|
|
|
23
23
|
isAiSessionId,
|
|
24
24
|
upstreamCacheHints,
|
|
25
25
|
} from "../cache-hints.ts";
|
|
26
|
-
export { createChatHandler } from "./chat.ts";
|
|
26
|
+
export { createChatHandler, DEFAULT_UPSTREAM_BASE_URL } from "./chat.ts";
|
|
27
27
|
export { modelsResponse } from "./models.ts";
|
|
28
28
|
export { normalizeOpenAIStream } from "./normalize.ts";
|
|
29
29
|
export { problemResponse } from "./problem.ts";
|
package/src/gateway/normalize.ts
CHANGED
|
@@ -11,41 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { problemDetails } from "@jxsuite/protocol/problems";
|
|
14
|
-
import type { StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
|
|
14
|
+
import type { OpenAIStreamChunk, StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
|
|
15
15
|
import { problemTypeForStatus } from "./problem.ts";
|
|
16
16
|
import { extractUpstreamErrorMessage } from "./upstream-error.ts";
|
|
17
17
|
|
|
18
|
-
/** A tool-call fragment inside an OpenAI streaming `delta`. */
|
|
19
|
-
interface ToolCallDelta {
|
|
20
|
-
index: number;
|
|
21
|
-
id?: string;
|
|
22
|
-
type?: string;
|
|
23
|
-
function?: { name?: string; arguments?: string };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
|
|
27
|
-
interface OpenAIStreamChunk {
|
|
28
|
-
choices?: {
|
|
29
|
-
index?: number;
|
|
30
|
-
delta?: {
|
|
31
|
-
content?: string;
|
|
32
|
-
/** Thinking models' chain-of-thought: DeepSeek and Volcengine spell it this way... */
|
|
33
|
-
reasoning_content?: string;
|
|
34
|
-
/** ...and OpenRouter this way. Both carry the same thing. */
|
|
35
|
-
reasoning?: string;
|
|
36
|
-
tool_calls?: ToolCallDelta[];
|
|
37
|
-
};
|
|
38
|
-
finish_reason?: string | null;
|
|
39
|
-
}[];
|
|
40
|
-
/** The count `stream_options.include_usage` asks for, normally a final chunk with no choices. */
|
|
41
|
-
usage?: {
|
|
42
|
-
prompt_tokens?: number;
|
|
43
|
-
completion_tokens?: number;
|
|
44
|
-
prompt_tokens_details?: { cached_tokens?: number } | null;
|
|
45
|
-
completion_tokens_details?: { reasoning_tokens?: number } | null;
|
|
46
|
-
} | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
18
|
/**
|
|
50
19
|
* Normalize an upstream usage object into a `usage` frame, or null when it carries no count. The
|
|
51
20
|
* detail figures are included only when the provider sent them: absent is "not said", not zero.
|
|
@@ -54,7 +23,7 @@ interface OpenAIStreamChunk {
|
|
|
54
23
|
* @returns {StreamUsageEvent | null}
|
|
55
24
|
*/
|
|
56
25
|
function usageFrame(usage: OpenAIStreamChunk["usage"]): StreamUsageEvent | null {
|
|
57
|
-
if (!usage ||
|
|
26
|
+
if (!usage || !isCount(usage.prompt_tokens)) {
|
|
58
27
|
return null;
|
|
59
28
|
}
|
|
60
29
|
const frame: StreamUsageEvent = {
|
|
@@ -63,16 +32,37 @@ function usageFrame(usage: OpenAIStreamChunk["usage"]): StreamUsageEvent | null
|
|
|
63
32
|
outputTokens: usage.completion_tokens ?? 0,
|
|
64
33
|
};
|
|
65
34
|
const cached = usage.prompt_tokens_details?.cached_tokens;
|
|
66
|
-
if (
|
|
35
|
+
if (isCount(cached)) {
|
|
67
36
|
frame.cachedInputTokens = cached;
|
|
68
37
|
}
|
|
69
38
|
const reasoning = usage.completion_tokens_details?.reasoning_tokens;
|
|
70
|
-
if (
|
|
39
|
+
if (isCount(reasoning)) {
|
|
71
40
|
frame.reasoningTokens = reasoning;
|
|
72
41
|
}
|
|
73
42
|
return frame;
|
|
74
43
|
}
|
|
75
44
|
|
|
45
|
+
/**
|
|
46
|
+
* True when a usage figure the provider sent is a number. The chunk is upstream JSON, so a figure
|
|
47
|
+
* its interface calls a number is checked before it is reported.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} value - A `usage` member, as the provider sent it
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function isCount(value: unknown): value is number {
|
|
53
|
+
return typeof value === "number";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when a chain-of-thought member the provider sent is a string.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} value - `reasoning_content` or `reasoning`, as the provider sent it
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function isText(value: unknown): value is string {
|
|
63
|
+
return typeof value === "string";
|
|
64
|
+
}
|
|
65
|
+
|
|
76
66
|
/**
|
|
77
67
|
* The stop reasons the stream reports; any other `finish_reason` keeps it reading.
|
|
78
68
|
*
|
|
@@ -94,14 +84,31 @@ function stopReasonOf(reason: string | null | undefined): string | null {
|
|
|
94
84
|
* failed, so nothing can change the status now. `message` stays for the readers that already show
|
|
95
85
|
* it (server.md §4.3).
|
|
96
86
|
*
|
|
97
|
-
* @param {unknown}
|
|
87
|
+
* @param {unknown} cause - What `fetch` rejected with
|
|
88
|
+
* @returns {StreamEvent}
|
|
89
|
+
*/
|
|
90
|
+
export function fetchFailureFrame(cause: unknown): StreamEvent {
|
|
91
|
+
return failureFrame("Network error", cause);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The frame for an exchange that failed: `done: cancelled` for an abort, otherwise an `error` frame
|
|
96
|
+
* whose `message` is `prefix` and the failure's own message.
|
|
97
|
+
*
|
|
98
|
+
* @param {"Network error" | "Stream error"} prefix - Which half of the exchange failed
|
|
99
|
+
* @param {unknown} cause - What the request or the read rejected with
|
|
98
100
|
* @returns {StreamEvent}
|
|
99
101
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
function failureFrame(prefix: "Network error" | "Stream error", cause: unknown): StreamEvent {
|
|
103
|
+
/* SAFETY: `fetch` and a body reader reject with an Error (a TypeError, or the abort's
|
|
104
|
+
AbortError DOMException), and the reads below throw only TypeErrors. Only `name` and `message`
|
|
105
|
+
are read, so a custom non-null abort reason that is not an Error reads both as undefined, as
|
|
106
|
+
it always has. */
|
|
107
|
+
const failure = cause as Error;
|
|
108
|
+
if (failure.name === "AbortError") {
|
|
102
109
|
return { type: "done", stopReason: "cancelled" };
|
|
103
110
|
}
|
|
104
|
-
const message =
|
|
111
|
+
const message = `${prefix}: ${failure.message}`;
|
|
105
112
|
return { message, problem: problemDetails("upstreamFailure", message), type: "error" };
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -191,13 +198,8 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
|
|
|
191
198
|
`finally` keeps that: the upstream must not outlive the reader it streams to. */
|
|
192
199
|
let settled = false;
|
|
193
200
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (done) {
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
buffer += decoder.decode(value, { stream: true });
|
|
201
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
202
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
201
203
|
const lines = buffer.split("\n");
|
|
202
204
|
buffer = lines.pop() || "";
|
|
203
205
|
|
|
@@ -216,6 +218,10 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
|
|
|
216
218
|
|
|
217
219
|
let parsed: OpenAIStreamChunk;
|
|
218
220
|
try {
|
|
221
|
+
/* SAFETY: the interface is the provider's wire shape, and this loop only reads it: each
|
|
222
|
+
member passes optional chaining, a truthiness test or a predicate before it shapes a
|
|
223
|
+
frame, and a chunk too malformed to read (JSON `null`, a non-iterable `tool_calls`)
|
|
224
|
+
throws into the catch below, which reports it as a stream error. */
|
|
219
225
|
parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
|
|
220
226
|
} catch {
|
|
221
227
|
continue;
|
|
@@ -241,7 +247,7 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
|
|
|
241
247
|
request carrying `tools`, so a proxy that swallows these frames makes the NEXT round a
|
|
242
248
|
400 the client cannot repair. */
|
|
243
249
|
const reasoning = delta?.reasoning_content ?? delta?.reasoning;
|
|
244
|
-
if (
|
|
250
|
+
if (isText(reasoning) && reasoning) {
|
|
245
251
|
yield { type: "reasoning", content: reasoning };
|
|
246
252
|
}
|
|
247
253
|
|
|
@@ -288,12 +294,7 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
|
|
|
288
294
|
} catch (error) {
|
|
289
295
|
settled = true;
|
|
290
296
|
void reader.cancel();
|
|
291
|
-
|
|
292
|
-
yield { type: "done", stopReason: "cancelled" };
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
const message = `Stream error: ${(error as Error).message}`;
|
|
296
|
-
yield { message, problem: problemDetails("upstreamFailure", message), type: "error" };
|
|
297
|
+
yield failureFrame("Stream error", error);
|
|
297
298
|
} finally {
|
|
298
299
|
if (!settled) {
|
|
299
300
|
void reader.cancel();
|
package/src/gateway/problem.ts
CHANGED
|
@@ -8,8 +8,18 @@
|
|
|
8
8
|
import { PROBLEM_MEDIA_TYPE } from "@jxsuite/protocol/problem";
|
|
9
9
|
import { problemDetails } from "@jxsuite/protocol/problems";
|
|
10
10
|
import type { ProblemTypeName } from "@jxsuite/protocol/problems";
|
|
11
|
+
import type { ProblemDetails } from "@jxsuite/protocol/problem";
|
|
11
12
|
import type { GatewayRefusal } from "./types.ts";
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A problem body as an unmigrated client reads it: `error` is the line it shows. The gateway takes
|
|
16
|
+
* this view only to ask whether that line is already there, which is a writer's question, not a
|
|
17
|
+
* reader's, so the deprecation on `ProblemDetails.error` does not address it.
|
|
18
|
+
*/
|
|
19
|
+
interface LegacyProblemView {
|
|
20
|
+
readonly error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* A refusal of one declared problem type, answered with that type's own status.
|
|
15
25
|
*
|
|
@@ -35,13 +45,15 @@ export function refusal(name: ProblemTypeName, detail: string): GatewayRefusal {
|
|
|
35
45
|
*/
|
|
36
46
|
export function problemResponse(refused: GatewayRefusal): Response {
|
|
37
47
|
const { code, problem, status } = refused;
|
|
38
|
-
const body = {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
const body: ProblemDetails = { ...problem };
|
|
49
|
+
const legacy: LegacyProblemView = problem;
|
|
50
|
+
if (legacy.error === undefined && problem.detail !== undefined) {
|
|
51
|
+
// The alias is written untyped, as `problemDetails` writes it, so no reader is taught to use it.
|
|
52
|
+
Object.assign(body, { error: problem.detail });
|
|
53
|
+
}
|
|
54
|
+
if (code !== undefined) {
|
|
55
|
+
body.code = code;
|
|
56
|
+
}
|
|
45
57
|
// Response.json would send `application/json`; RFC 9457 §3 is what says this body is a problem.
|
|
46
58
|
return Response.json(body, { headers: { "Content-Type": PROBLEM_MEDIA_TYPE }, status });
|
|
47
59
|
}
|
|
@@ -61,20 +73,20 @@ export function problemResponse(refused: GatewayRefusal): Response {
|
|
|
61
73
|
export function problemTypeForStatus(status: number): ProblemTypeName {
|
|
62
74
|
/* Any other status still becomes SOME type, and which side of 500 it falls on is what decides
|
|
63
75
|
whether the caller is being told it made a mistake or that the upstream did. */
|
|
64
|
-
return STATUS_TYPES
|
|
76
|
+
return STATUS_TYPES.get(status) ?? (status >= 500 ? "internalError" : "invalidRequest");
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
/** The statuses the table distinguishes, each onto a type answered with that same status. */
|
|
68
|
-
const STATUS_TYPES:
|
|
69
|
-
400
|
|
70
|
-
401
|
|
71
|
-
403
|
|
72
|
-
404
|
|
73
|
-
405
|
|
74
|
-
409
|
|
75
|
-
413
|
|
76
|
-
501
|
|
77
|
-
502
|
|
78
|
-
503
|
|
79
|
-
504
|
|
80
|
-
|
|
80
|
+
const STATUS_TYPES: ReadonlyMap<number, ProblemTypeName> = new Map<number, ProblemTypeName>([
|
|
81
|
+
[400, "invalidRequest"],
|
|
82
|
+
[401, "unauthorized"],
|
|
83
|
+
[403, "forbidden"],
|
|
84
|
+
[404, "notFound"],
|
|
85
|
+
[405, "methodNotAllowed"],
|
|
86
|
+
[409, "conflict"],
|
|
87
|
+
[413, "payloadTooLarge"],
|
|
88
|
+
[501, "capabilityUnavailable"],
|
|
89
|
+
[502, "upstreamFailure"],
|
|
90
|
+
[503, "upstreamFailure"],
|
|
91
|
+
[504, "upstreamFailure"],
|
|
92
|
+
]);
|
package/src/gateway/sse.ts
CHANGED
|
@@ -10,11 +10,11 @@ import type { StreamEvent } from "../streaming-client.ts";
|
|
|
10
10
|
* The headers a chat stream is answered with. `no-cache` because a replayed stream is a different
|
|
11
11
|
* turn, and `keep-alive` because the answer is long-lived by design.
|
|
12
12
|
*/
|
|
13
|
-
export const SSE_HEADERS
|
|
13
|
+
export const SSE_HEADERS = {
|
|
14
14
|
"Content-Type": "text/event-stream",
|
|
15
15
|
"Cache-Control": "no-cache",
|
|
16
16
|
Connection: "keep-alive",
|
|
17
|
-
}
|
|
17
|
+
} as const satisfies Readonly<Record<string, string>>;
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Frame `events` as a Server-Sent Events body: `data: <JSON>` and a blank line per frame, and
|
|
@@ -26,29 +26,61 @@ export function extractUpstreamErrorMessage(rawBody: string, fallback: string):
|
|
|
26
26
|
if (!rawBody) {
|
|
27
27
|
return fallback;
|
|
28
28
|
}
|
|
29
|
+
let parsed: unknown;
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (error) {
|
|
38
|
-
const {
|
|
39
|
-
if (
|
|
40
|
-
return
|
|
31
|
+
parsed = JSON.parse(rawBody);
|
|
32
|
+
} catch {
|
|
33
|
+
/* Not JSON: use the raw body. */
|
|
34
|
+
return rawBody;
|
|
35
|
+
}
|
|
36
|
+
// JSON `null`, a primitive or an array carries neither member, so it is the raw body.
|
|
37
|
+
if (isMemberBag(parsed)) {
|
|
38
|
+
if ("error" in parsed) {
|
|
39
|
+
const { error } = parsed;
|
|
40
|
+
if (isText(error)) {
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
if (isMemberBag(error) && "message" in error && Boolean(error.message)) {
|
|
44
|
+
return String(error.message);
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return message;
|
|
47
|
+
if ("errors" in parsed && isEntryList(parsed.errors)) {
|
|
48
|
+
const [first] = parsed.errors;
|
|
49
|
+
if (isMemberBag(first) && "message" in first && Boolean(first.message)) {
|
|
50
|
+
return String(first.message);
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
|
-
} catch {
|
|
51
|
-
/* Not JSON: use the raw body. */
|
|
52
53
|
}
|
|
53
54
|
return rawBody;
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True when a parsed body is a string, empty or not: a flat `error` string is the answer as sent.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} value - A member of the parsed body
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isText(value: unknown): value is string {
|
|
64
|
+
return typeof value === "string";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when a parsed `errors` member is an array, whose first entry is Cloudflare's sentence.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} value - The parsed body's `errors` member
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function isEntryList(value: unknown): value is readonly unknown[] {
|
|
74
|
+
return Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* True when a parsed value is a JSON object (or array) whose members may be probed with `in`; false
|
|
79
|
+
* for `null` and every primitive.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} value - The parsed body, or one of its members
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function isMemberBag(value: unknown): value is object {
|
|
85
|
+
return typeof value === "object" && value !== null;
|
|
86
|
+
}
|
package/src/harness/index.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
export { LaneBusyError, createTurnLock } from "./lock.ts";
|
|
17
17
|
export { fromStreamingClient, joinSystem } from "./model.ts";
|
|
18
|
-
export { DEFAULT_TURN_POLICY } from "./policy.ts";
|
|
18
|
+
export { CHARS_PER_TOKEN, DEFAULT_TURN_POLICY } from "./policy.ts";
|
|
19
19
|
export { runTurn } from "./run.ts";
|
|
20
20
|
|
|
21
21
|
export type {
|
package/src/harness/policy.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { TurnPolicy } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
/** Average characters per token: the estimate every budget used before providers reported counts. */
|
|
11
|
-
const CHARS_PER_TOKEN = 4;
|
|
11
|
+
export const CHARS_PER_TOKEN = 4;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The defaults: five rounds of work, twenty-five rounds in all, and the texts the agent loop has
|