dsh-plugin-subscriptions 0.1.0
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 +93 -0
- package/README.zh.md +93 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +51 -0
- package/lib/auth/rpc.js +83 -0
- package/lib/auth/store.d.ts +90 -0
- package/lib/auth/store.js +137 -0
- package/lib/client/SubscriptionsSection.d.ts +30 -0
- package/lib/client/SubscriptionsSection.js +290 -0
- package/lib/client/index.d.ts +31 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.d.ts +45 -0
- package/lib/client/locales.js +43 -0
- package/lib/client.js +546 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +2932 -0
- package/lib/providers/claude.d.ts +60 -0
- package/lib/providers/claude.js +243 -0
- package/lib/providers/codex.d.ts +96 -0
- package/lib/providers/codex.js +391 -0
- package/lib/providers/common.d.ts +185 -0
- package/lib/providers/common.js +302 -0
- package/lib/providers/grok.d.ts +90 -0
- package/lib/providers/grok.js +337 -0
- package/lib/tools/image-generate.d.ts +60 -0
- package/lib/tools/image-generate.js +142 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +195 -0
- package/lib/translate/anthropic.d.ts +120 -0
- package/lib/translate/anthropic.js +370 -0
- package/lib/translate/resolved.d.ts +35 -0
- package/lib/translate/resolved.js +40 -0
- package/lib/translate/responses.d.ts +127 -0
- package/lib/translate/responses.js +352 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +83 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved-image plumbing for the wire translators. ImageBlocks carry only an
|
|
3
|
+
* attachment reference; the bytes live in the attachment service, which is
|
|
4
|
+
* async I/O. Adapters resolve images BEFORE calling the (pure, synchronous)
|
|
5
|
+
* translators, so the translators see {@link ResolvedImagePart}s with inline
|
|
6
|
+
* base64 data.
|
|
7
|
+
*/
|
|
8
|
+
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
10
|
+
/** An image block with its bytes resolved to inline base64 for the wire. */
|
|
11
|
+
export interface ResolvedImagePart {
|
|
12
|
+
type: 'image';
|
|
13
|
+
/** MIME type verified by the attachment service (e.g. `image/png`). */
|
|
14
|
+
mediaType: string;
|
|
15
|
+
/** Base64-encoded image bytes. */
|
|
16
|
+
dataBase64: string;
|
|
17
|
+
}
|
|
18
|
+
/** Translator input block: a harness block, with images pre-resolved. */
|
|
19
|
+
export type TranslatableBlock = ContentBlock | ResolvedImagePart;
|
|
20
|
+
/** Translator input message: role plus resolved blocks. */
|
|
21
|
+
export interface TranslatableMessage {
|
|
22
|
+
role: 'system' | 'user' | 'assistant';
|
|
23
|
+
content: readonly TranslatableBlock[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve every ImageBlock's attachment reference to inline base64 bytes.
|
|
27
|
+
* Messages without images pass through unchanged. A request carrying an image
|
|
28
|
+
* with no attachment service available fails loudly rather than silently
|
|
29
|
+
* dropping the image.
|
|
30
|
+
* @param messages - the request's conversation messages.
|
|
31
|
+
* @param attachments - the deployment's attachment service, when mounted.
|
|
32
|
+
* @param signal - cancellation for the storage reads.
|
|
33
|
+
* @returns the same messages with image blocks resolved for the translators.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveImages(messages: readonly Message[], attachments: AttachmentStore | undefined, signal?: AbortSignal): Promise<readonly TranslatableMessage[]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved-image plumbing for the wire translators. ImageBlocks carry only an
|
|
3
|
+
* attachment reference; the bytes live in the attachment service, which is
|
|
4
|
+
* async I/O. Adapters resolve images BEFORE calling the (pure, synchronous)
|
|
5
|
+
* translators, so the translators see {@link ResolvedImagePart}s with inline
|
|
6
|
+
* base64 data.
|
|
7
|
+
*/
|
|
8
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
/**
|
|
10
|
+
* Resolve every ImageBlock's attachment reference to inline base64 bytes.
|
|
11
|
+
* Messages without images pass through unchanged. A request carrying an image
|
|
12
|
+
* with no attachment service available fails loudly rather than silently
|
|
13
|
+
* dropping the image.
|
|
14
|
+
* @param messages - the request's conversation messages.
|
|
15
|
+
* @param attachments - the deployment's attachment service, when mounted.
|
|
16
|
+
* @param signal - cancellation for the storage reads.
|
|
17
|
+
* @returns the same messages with image blocks resolved for the translators.
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveImages(messages, attachments, signal) {
|
|
20
|
+
if (!messages.some(message => message.content.some(block => block.type === 'image'))) {
|
|
21
|
+
return messages;
|
|
22
|
+
}
|
|
23
|
+
if (attachments === undefined) {
|
|
24
|
+
throw new LlmError('dsh-plugin-subscriptions: the request carries an image but no attachments service is mounted; '
|
|
25
|
+
+ 'image input requires the harness attachment store', 'UNSUPPORTED');
|
|
26
|
+
}
|
|
27
|
+
return Promise.all(messages.map(async (message) => ({
|
|
28
|
+
role: message.role,
|
|
29
|
+
content: await Promise.all(message.content.map(async (block) => {
|
|
30
|
+
if (block.type !== 'image')
|
|
31
|
+
return block;
|
|
32
|
+
const stored = await attachments.readImage(block.attachment, signal);
|
|
33
|
+
return {
|
|
34
|
+
type: 'image',
|
|
35
|
+
mediaType: stored.ref.mediaType,
|
|
36
|
+
dataBase64: Buffer.from(stored.data).toString('base64'),
|
|
37
|
+
};
|
|
38
|
+
})),
|
|
39
|
+
})));
|
|
40
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate between the harness message vocabulary and the OpenAI Responses
|
|
3
|
+
* API wire format shared by the codex and grok providers: request input
|
|
4
|
+
* assembly, tool schema mapping, and a push-model SSE-event → StreamChunk
|
|
5
|
+
* state machine ({@link ResponsesStreamTranslator}) so tests need no streams.
|
|
6
|
+
*/
|
|
7
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import type { StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
import type { TranslatableMessage } from './resolved.js';
|
|
10
|
+
/** Assembled `instructions` + `input` pair for one Responses request. */
|
|
11
|
+
export interface ResponsesRequestInput {
|
|
12
|
+
/** System text for the top-level `instructions` field; absent when there is none. */
|
|
13
|
+
instructions?: string;
|
|
14
|
+
/** Responses `input` items in conversation order. */
|
|
15
|
+
input: Record<string, unknown>[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Convert harness messages into Responses `instructions` + `input` items.
|
|
19
|
+
* System-role messages become `instructions`; an explicit `system` argument
|
|
20
|
+
* wins over them when both exist. Reasoning blocks are not replayed (v1).
|
|
21
|
+
* Images must arrive pre-resolved ({@link TranslatableMessage}); an unresolved
|
|
22
|
+
* ImageBlock is skipped because its bytes are unreachable here.
|
|
23
|
+
* @param messages - ordered conversation messages with resolved images.
|
|
24
|
+
* @param system - explicit system prompt, which takes precedence.
|
|
25
|
+
* @returns request fields ready to merge into the request body.
|
|
26
|
+
*/
|
|
27
|
+
export declare function toResponsesInput(messages: readonly TranslatableMessage[], system?: string): ResponsesRequestInput;
|
|
28
|
+
/**
|
|
29
|
+
* Map harness tool schemas to Responses function tools.
|
|
30
|
+
* @param tools - tool schemas from the request.
|
|
31
|
+
* @returns Responses `tools` array entries.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toResponsesTools(tools: readonly ToolSchema[]): Record<string, unknown>[];
|
|
34
|
+
/** The subset of Responses SSE event shapes this translator reads. */
|
|
35
|
+
export interface ResponsesStreamEvent {
|
|
36
|
+
type: string;
|
|
37
|
+
item_id?: string;
|
|
38
|
+
content_index?: number;
|
|
39
|
+
summary_index?: number;
|
|
40
|
+
delta?: string;
|
|
41
|
+
item?: {
|
|
42
|
+
type?: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
call_id?: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
arguments?: string;
|
|
47
|
+
content?: Array<{
|
|
48
|
+
type?: string;
|
|
49
|
+
text?: string;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
response?: {
|
|
53
|
+
status?: string;
|
|
54
|
+
usage?: ResponsesUsage;
|
|
55
|
+
error?: {
|
|
56
|
+
code?: string;
|
|
57
|
+
message?: string;
|
|
58
|
+
};
|
|
59
|
+
incomplete_details?: {
|
|
60
|
+
reason?: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
code?: string;
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
/** Responses `usage` object shape. */
|
|
67
|
+
export interface ResponsesUsage {
|
|
68
|
+
input_tokens: number;
|
|
69
|
+
output_tokens: number;
|
|
70
|
+
input_tokens_details?: {
|
|
71
|
+
cached_tokens?: number;
|
|
72
|
+
};
|
|
73
|
+
output_tokens_details?: {
|
|
74
|
+
reasoning_tokens?: number;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Map Responses usage to disjoint harness counts (cached input is subtracted
|
|
79
|
+
* out of `inputTokens` and reported as `cacheReadTokens`).
|
|
80
|
+
* @param usage - wire usage from `response.completed`.
|
|
81
|
+
* @returns harness token usage.
|
|
82
|
+
*/
|
|
83
|
+
export declare function mapResponsesUsage(usage: ResponsesUsage): TokenUsage;
|
|
84
|
+
/**
|
|
85
|
+
* Classify a Responses failure payload into a thrown LlmError.
|
|
86
|
+
* @param code - provider error code, when present.
|
|
87
|
+
* @param message - provider error message, when present.
|
|
88
|
+
* @returns the mapped error (context overflow, quota, otherwise SERVER).
|
|
89
|
+
*/
|
|
90
|
+
export declare function responsesFailure(code: string | undefined, message: string | undefined): LlmError;
|
|
91
|
+
/**
|
|
92
|
+
* Push-model Responses SSE translator: feed each parsed event object to
|
|
93
|
+
* {@link push} and collect the emitted harness StreamChunks. Block indexes
|
|
94
|
+
* are allocated in first-seen order; `usage` is emitted before the terminal
|
|
95
|
+
* `finish`, and nothing is emitted after it. Terminal provider failures
|
|
96
|
+
* throw {@link LlmError}.
|
|
97
|
+
*/
|
|
98
|
+
export declare class ResponsesStreamTranslator {
|
|
99
|
+
private blocks;
|
|
100
|
+
private order;
|
|
101
|
+
private nextIndex;
|
|
102
|
+
private sawToolCall;
|
|
103
|
+
/** Set once `response.completed` produced the terminal finish chunk. */
|
|
104
|
+
terminated: boolean;
|
|
105
|
+
private open;
|
|
106
|
+
private textBlock;
|
|
107
|
+
private reasoningBlock;
|
|
108
|
+
private close;
|
|
109
|
+
/** Close every still-open block for one output item (prefix match on the key). */
|
|
110
|
+
private closeItem;
|
|
111
|
+
/** Close every still-open block (provider ended the response without done events). */
|
|
112
|
+
private closeAll;
|
|
113
|
+
private closeKeyIfOpen;
|
|
114
|
+
/**
|
|
115
|
+
* Process one parsed Responses SSE event.
|
|
116
|
+
* @param event - the parsed event object.
|
|
117
|
+
* @returns the StreamChunks this event produced (possibly none).
|
|
118
|
+
*/
|
|
119
|
+
push(event: ResponsesStreamEvent): StreamChunk[];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Consume a Responses SSE byte stream and yield harness StreamChunks.
|
|
123
|
+
* @param stream - raw response body.
|
|
124
|
+
* @param onActivity - transport-activity callback for the idle watchdog.
|
|
125
|
+
* @returns the chunk stream; throws when the stream ends before `response.completed`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function streamResponses(stream: ReadableStream<Uint8Array>, onActivity?: () => void): AsyncGenerator<StreamChunk>;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate between the harness message vocabulary and the OpenAI Responses
|
|
3
|
+
* API wire format shared by the codex and grok providers: request input
|
|
4
|
+
* assembly, tool schema mapping, and a push-model SSE-event → StreamChunk
|
|
5
|
+
* state machine ({@link ResponsesStreamTranslator}) so tests need no streams.
|
|
6
|
+
*/
|
|
7
|
+
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import { parseSse } from './sse.js';
|
|
9
|
+
/** Flatten a tool result's content to plain text for `function_call_output`. */
|
|
10
|
+
function toolResultText(block) {
|
|
11
|
+
return block.content.map(part => (part.type === 'text' ? part.text : '')).join('');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Convert harness messages into Responses `instructions` + `input` items.
|
|
15
|
+
* System-role messages become `instructions`; an explicit `system` argument
|
|
16
|
+
* wins over them when both exist. Reasoning blocks are not replayed (v1).
|
|
17
|
+
* Images must arrive pre-resolved ({@link TranslatableMessage}); an unresolved
|
|
18
|
+
* ImageBlock is skipped because its bytes are unreachable here.
|
|
19
|
+
* @param messages - ordered conversation messages with resolved images.
|
|
20
|
+
* @param system - explicit system prompt, which takes precedence.
|
|
21
|
+
* @returns request fields ready to merge into the request body.
|
|
22
|
+
*/
|
|
23
|
+
export function toResponsesInput(messages, system) {
|
|
24
|
+
const input = [];
|
|
25
|
+
const systemTexts = [];
|
|
26
|
+
for (const message of messages) {
|
|
27
|
+
if (message.role === 'system') {
|
|
28
|
+
for (const block of message.content) {
|
|
29
|
+
if (block.type === 'text')
|
|
30
|
+
systemTexts.push(block.text);
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const role = message.role;
|
|
35
|
+
let content = [];
|
|
36
|
+
const flushMessage = () => {
|
|
37
|
+
if (content.length === 0)
|
|
38
|
+
return;
|
|
39
|
+
input.push({ type: 'message', role, content });
|
|
40
|
+
content = [];
|
|
41
|
+
};
|
|
42
|
+
for (const block of message.content) {
|
|
43
|
+
switch (block.type) {
|
|
44
|
+
case 'text':
|
|
45
|
+
content.push({ type: role === 'assistant' ? 'output_text' : 'input_text', text: block.text });
|
|
46
|
+
break;
|
|
47
|
+
case 'tool-call':
|
|
48
|
+
flushMessage();
|
|
49
|
+
input.push({
|
|
50
|
+
type: 'function_call',
|
|
51
|
+
call_id: String(block.id),
|
|
52
|
+
name: block.name,
|
|
53
|
+
arguments: block.arguments,
|
|
54
|
+
});
|
|
55
|
+
break;
|
|
56
|
+
case 'tool-result':
|
|
57
|
+
flushMessage();
|
|
58
|
+
input.push({
|
|
59
|
+
type: 'function_call_output',
|
|
60
|
+
call_id: String(block.toolCallId),
|
|
61
|
+
output: toolResultText(block),
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
case 'image':
|
|
65
|
+
if ('dataBase64' in block) {
|
|
66
|
+
content.push({
|
|
67
|
+
type: 'input_image',
|
|
68
|
+
image_url: `data:${block.mediaType};base64,${block.dataBase64}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// An unresolved ImageBlock carries only an attachment reference; the
|
|
72
|
+
// adapter resolves images before translation, so this is skipped.
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
// reasoning (not replayed), unknown blocks.
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
flushMessage();
|
|
80
|
+
}
|
|
81
|
+
const instructions = system ?? (systemTexts.length > 0 ? systemTexts.join('\n\n') : undefined);
|
|
82
|
+
return { ...instructions === undefined ? {} : { instructions }, input };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Map harness tool schemas to Responses function tools.
|
|
86
|
+
* @param tools - tool schemas from the request.
|
|
87
|
+
* @returns Responses `tools` array entries.
|
|
88
|
+
*/
|
|
89
|
+
export function toResponsesTools(tools) {
|
|
90
|
+
return tools.map(tool => ({
|
|
91
|
+
type: 'function',
|
|
92
|
+
name: tool.name,
|
|
93
|
+
description: tool.description,
|
|
94
|
+
parameters: tool.parameters,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Map Responses usage to disjoint harness counts (cached input is subtracted
|
|
99
|
+
* out of `inputTokens` and reported as `cacheReadTokens`).
|
|
100
|
+
* @param usage - wire usage from `response.completed`.
|
|
101
|
+
* @returns harness token usage.
|
|
102
|
+
*/
|
|
103
|
+
export function mapResponsesUsage(usage) {
|
|
104
|
+
const cached = usage.input_tokens_details?.cached_tokens;
|
|
105
|
+
const reasoning = usage.output_tokens_details?.reasoning_tokens;
|
|
106
|
+
return {
|
|
107
|
+
inputTokens: usage.input_tokens - (cached ?? 0),
|
|
108
|
+
outputTokens: usage.output_tokens,
|
|
109
|
+
...cached !== undefined ? { cacheReadTokens: cached } : {},
|
|
110
|
+
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Classify a Responses failure payload into a thrown LlmError.
|
|
115
|
+
* @param code - provider error code, when present.
|
|
116
|
+
* @param message - provider error message, when present.
|
|
117
|
+
* @returns the mapped error (context overflow, quota, otherwise SERVER).
|
|
118
|
+
*/
|
|
119
|
+
export function responsesFailure(code, message) {
|
|
120
|
+
const text = message ?? code ?? 'the provider reported a failed response';
|
|
121
|
+
const detail = `${code ?? ''} ${message ?? ''}`;
|
|
122
|
+
if (code === 'context_window_exceeded' || isContextWindowExceededError(detail)) {
|
|
123
|
+
return new LlmError(text, CONTEXT_WINDOW_EXCEEDED_CODE);
|
|
124
|
+
}
|
|
125
|
+
if ((code !== undefined && /insufficient|quota/i.test(code)) || isQuotaExceededError(detail)) {
|
|
126
|
+
return new LlmError(text, QUOTA_EXCEEDED_CODE);
|
|
127
|
+
}
|
|
128
|
+
return new LlmError(text, 'SERVER');
|
|
129
|
+
}
|
|
130
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
131
|
+
function closeBlock(block) {
|
|
132
|
+
switch (block.kind) {
|
|
133
|
+
case 'text':
|
|
134
|
+
return { type: 'text', text: block.text };
|
|
135
|
+
case 'reasoning':
|
|
136
|
+
return { type: 'reasoning', text: block.text };
|
|
137
|
+
case 'tool-call':
|
|
138
|
+
return {
|
|
139
|
+
type: 'tool-call',
|
|
140
|
+
id: CallId(block.callId),
|
|
141
|
+
name: block.name ?? '',
|
|
142
|
+
arguments: block.text,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Push-model Responses SSE translator: feed each parsed event object to
|
|
148
|
+
* {@link push} and collect the emitted harness StreamChunks. Block indexes
|
|
149
|
+
* are allocated in first-seen order; `usage` is emitted before the terminal
|
|
150
|
+
* `finish`, and nothing is emitted after it. Terminal provider failures
|
|
151
|
+
* throw {@link LlmError}.
|
|
152
|
+
*/
|
|
153
|
+
export class ResponsesStreamTranslator {
|
|
154
|
+
blocks = new Map();
|
|
155
|
+
order = [];
|
|
156
|
+
nextIndex = 0;
|
|
157
|
+
sawToolCall = false;
|
|
158
|
+
/** Set once `response.completed` produced the terminal finish chunk. */
|
|
159
|
+
terminated = false;
|
|
160
|
+
open(key, kind, chunks, callId = '', name) {
|
|
161
|
+
const block = {
|
|
162
|
+
index: this.nextIndex++,
|
|
163
|
+
kind,
|
|
164
|
+
text: '',
|
|
165
|
+
callId,
|
|
166
|
+
...name === undefined ? {} : { name },
|
|
167
|
+
};
|
|
168
|
+
this.blocks.set(key, block);
|
|
169
|
+
this.order.push(block);
|
|
170
|
+
chunks.push({ type: 'block-start', index: block.index, blockType: kind });
|
|
171
|
+
return block;
|
|
172
|
+
}
|
|
173
|
+
textBlock(key, chunks) {
|
|
174
|
+
return this.blocks.get(key) ?? this.open(key, 'text', chunks);
|
|
175
|
+
}
|
|
176
|
+
reasoningBlock(key, chunks) {
|
|
177
|
+
return this.blocks.get(key) ?? this.open(key, 'reasoning', chunks);
|
|
178
|
+
}
|
|
179
|
+
close(key, chunks) {
|
|
180
|
+
const block = this.blocks.get(key);
|
|
181
|
+
if (block === undefined)
|
|
182
|
+
return;
|
|
183
|
+
this.blocks.delete(key);
|
|
184
|
+
chunks.push({ type: 'block-end', index: block.index, block: closeBlock(block) });
|
|
185
|
+
}
|
|
186
|
+
/** Close every still-open block for one output item (prefix match on the key). */
|
|
187
|
+
closeItem(itemId, chunks) {
|
|
188
|
+
for (const key of [...this.blocks.keys()]) {
|
|
189
|
+
if (key.startsWith(`${itemId}:`))
|
|
190
|
+
this.close(key, chunks);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Close every still-open block (provider ended the response without done events). */
|
|
194
|
+
closeAll(chunks) {
|
|
195
|
+
for (const block of this.order)
|
|
196
|
+
this.closeKeyIfOpen(block, chunks);
|
|
197
|
+
}
|
|
198
|
+
closeKeyIfOpen(block, chunks) {
|
|
199
|
+
for (const [key, candidate] of this.blocks) {
|
|
200
|
+
if (candidate === block) {
|
|
201
|
+
this.blocks.delete(key);
|
|
202
|
+
chunks.push({ type: 'block-end', index: block.index, block: closeBlock(block) });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Process one parsed Responses SSE event.
|
|
209
|
+
* @param event - the parsed event object.
|
|
210
|
+
* @returns the StreamChunks this event produced (possibly none).
|
|
211
|
+
*/
|
|
212
|
+
push(event) {
|
|
213
|
+
if (this.terminated)
|
|
214
|
+
return [];
|
|
215
|
+
const chunks = [];
|
|
216
|
+
switch (event.type) {
|
|
217
|
+
case 'response.output_item.added': {
|
|
218
|
+
const item = event.item;
|
|
219
|
+
if (item?.type === 'function_call' && item.id !== undefined) {
|
|
220
|
+
this.sawToolCall = true;
|
|
221
|
+
const callId = item.call_id ?? '';
|
|
222
|
+
const block = this.open(`${item.id}:call`, 'tool-call', chunks, callId, item.name);
|
|
223
|
+
chunks.push({
|
|
224
|
+
type: 'tool-call-delta',
|
|
225
|
+
index: block.index,
|
|
226
|
+
id: CallId(callId),
|
|
227
|
+
...item.name === undefined ? {} : { name: item.name },
|
|
228
|
+
argumentsDelta: '',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return chunks;
|
|
232
|
+
}
|
|
233
|
+
case 'response.output_text.delta': {
|
|
234
|
+
const key = `${event.item_id ?? ''}:text:${String(event.content_index ?? 0)}`;
|
|
235
|
+
const block = this.textBlock(key, chunks);
|
|
236
|
+
block.text += event.delta ?? '';
|
|
237
|
+
chunks.push({ type: 'text-delta', index: block.index, text: event.delta ?? '' });
|
|
238
|
+
return chunks;
|
|
239
|
+
}
|
|
240
|
+
case 'response.reasoning_summary_text.delta':
|
|
241
|
+
case 'response.reasoning_text.delta': {
|
|
242
|
+
const sub = event.summary_index ?? event.content_index ?? 0;
|
|
243
|
+
const key = `${event.item_id ?? ''}:reason:${String(sub)}`;
|
|
244
|
+
const block = this.reasoningBlock(key, chunks);
|
|
245
|
+
block.text += event.delta ?? '';
|
|
246
|
+
chunks.push({ type: 'reasoning-delta', index: block.index, text: event.delta ?? '' });
|
|
247
|
+
return chunks;
|
|
248
|
+
}
|
|
249
|
+
case 'response.function_call_arguments.delta': {
|
|
250
|
+
const key = `${event.item_id ?? ''}:call`;
|
|
251
|
+
let block = this.blocks.get(key);
|
|
252
|
+
if (block === undefined) {
|
|
253
|
+
// The item.added event was missed; open the block from the delta alone.
|
|
254
|
+
this.sawToolCall = true;
|
|
255
|
+
block = this.open(key, 'tool-call', chunks);
|
|
256
|
+
}
|
|
257
|
+
block.text += event.delta ?? '';
|
|
258
|
+
chunks.push({
|
|
259
|
+
type: 'tool-call-delta',
|
|
260
|
+
index: block.index,
|
|
261
|
+
id: CallId(block.callId),
|
|
262
|
+
...block.name === undefined ? {} : { name: block.name },
|
|
263
|
+
argumentsDelta: event.delta ?? '',
|
|
264
|
+
});
|
|
265
|
+
return chunks;
|
|
266
|
+
}
|
|
267
|
+
case 'response.output_item.done': {
|
|
268
|
+
const item = event.item;
|
|
269
|
+
if (item === undefined || item.id === undefined)
|
|
270
|
+
return chunks;
|
|
271
|
+
if (item.type === 'function_call') {
|
|
272
|
+
const key = `${item.id}:call`;
|
|
273
|
+
// The provider may deliver the complete arguments only on done.
|
|
274
|
+
const block = this.blocks.get(key);
|
|
275
|
+
if (block !== undefined && block.text.length === 0 && item.arguments !== undefined) {
|
|
276
|
+
block.text = item.arguments;
|
|
277
|
+
}
|
|
278
|
+
this.close(key, chunks);
|
|
279
|
+
}
|
|
280
|
+
else if (item.type === 'message') {
|
|
281
|
+
if (![...this.blocks.keys()].some(key => key.startsWith(`${item.id}:text:`))) {
|
|
282
|
+
// No deltas arrived for this item; synthesize blocks from the done payload.
|
|
283
|
+
for (const [partIndex, part] of (item.content ?? []).entries()) {
|
|
284
|
+
if (part?.type !== 'output_text' || typeof part.text !== 'string' || part.text.length === 0)
|
|
285
|
+
continue;
|
|
286
|
+
const block = this.open(`${item.id}:text:${partIndex}`, 'text', chunks);
|
|
287
|
+
block.text = part.text;
|
|
288
|
+
this.close(`${item.id}:text:${partIndex}`, chunks);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
this.closeItem(item.id, chunks);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
this.closeItem(item.id, chunks);
|
|
295
|
+
}
|
|
296
|
+
return chunks;
|
|
297
|
+
}
|
|
298
|
+
case 'response.completed': {
|
|
299
|
+
this.terminated = true;
|
|
300
|
+
this.closeAll(chunks);
|
|
301
|
+
const usage = event.response?.usage;
|
|
302
|
+
if (usage !== undefined)
|
|
303
|
+
chunks.push({ type: 'usage', usage: mapResponsesUsage(usage) });
|
|
304
|
+
if (this.order.length === 0) {
|
|
305
|
+
chunks.push({
|
|
306
|
+
type: 'finish',
|
|
307
|
+
reason: {
|
|
308
|
+
kind: 'error',
|
|
309
|
+
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
chunks.push({ type: 'finish', reason: { kind: this.sawToolCall ? 'tool-calls' : 'stop' } });
|
|
315
|
+
}
|
|
316
|
+
return chunks;
|
|
317
|
+
}
|
|
318
|
+
case 'response.failed':
|
|
319
|
+
throw responsesFailure(event.response?.error?.code, event.response?.error?.message);
|
|
320
|
+
case 'response.incomplete':
|
|
321
|
+
throw responsesFailure(event.response?.incomplete_details?.reason, event.response?.error?.message
|
|
322
|
+
?? `the provider reported an incomplete response (${event.response?.incomplete_details?.reason ?? 'unknown reason'})`);
|
|
323
|
+
case 'error':
|
|
324
|
+
throw responsesFailure(event.code, event.message);
|
|
325
|
+
default:
|
|
326
|
+
// response.created, response.in_progress, content_part events, etc.: no chunks.
|
|
327
|
+
return chunks;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Consume a Responses SSE byte stream and yield harness StreamChunks.
|
|
333
|
+
* @param stream - raw response body.
|
|
334
|
+
* @param onActivity - transport-activity callback for the idle watchdog.
|
|
335
|
+
* @returns the chunk stream; throws when the stream ends before `response.completed`.
|
|
336
|
+
*/
|
|
337
|
+
export async function* streamResponses(stream, onActivity) {
|
|
338
|
+
const translator = new ResponsesStreamTranslator();
|
|
339
|
+
for await (const sseEvent of parseSse(stream, onActivity)) {
|
|
340
|
+
let event;
|
|
341
|
+
try {
|
|
342
|
+
event = JSON.parse(sseEvent.data);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, 'MALFORMED_RESPONSE');
|
|
346
|
+
}
|
|
347
|
+
yield* translator.push(event);
|
|
348
|
+
if (translator.terminated)
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
throw new LlmError('Responses SSE stream ended before response.completed', 'STREAM_CLOSED');
|
|
352
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE byte-stream parser (~30 lines of framing): reassembles chunks,
|
|
3
|
+
* splits CRLF/LF lines, joins multi-`data:` payloads, skips comments and
|
|
4
|
+
* non-data fields, and dispatches an event only on its blank-line terminator.
|
|
5
|
+
* An unterminated tail at EOF is truncation and is dropped, matching the
|
|
6
|
+
* spec-strict framing the harness's own adapters use.
|
|
7
|
+
*/
|
|
8
|
+
/** One parsed SSE event. */
|
|
9
|
+
export interface SseEvent {
|
|
10
|
+
/** Joined `data:` lines of the event. */
|
|
11
|
+
data: string;
|
|
12
|
+
/** The `event:` field, when present. */
|
|
13
|
+
event?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Decode an SSE byte stream into events.
|
|
17
|
+
* @param stream - raw response bytes; reads may split anywhere, including mid-UTF-8 sequence.
|
|
18
|
+
* @param onActivity - called on every received chunk and comment line; drives the idle watchdog.
|
|
19
|
+
* @returns events in arrival order.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseSse(stream: ReadableStream<Uint8Array>, onActivity?: () => void): AsyncGenerator<SseEvent>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE byte-stream parser (~30 lines of framing): reassembles chunks,
|
|
3
|
+
* splits CRLF/LF lines, joins multi-`data:` payloads, skips comments and
|
|
4
|
+
* non-data fields, and dispatches an event only on its blank-line terminator.
|
|
5
|
+
* An unterminated tail at EOF is truncation and is dropped, matching the
|
|
6
|
+
* spec-strict framing the harness's own adapters use.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Decode an SSE byte stream into events.
|
|
10
|
+
* @param stream - raw response bytes; reads may split anywhere, including mid-UTF-8 sequence.
|
|
11
|
+
* @param onActivity - called on every received chunk and comment line; drives the idle watchdog.
|
|
12
|
+
* @returns events in arrival order.
|
|
13
|
+
*/
|
|
14
|
+
export async function* parseSse(stream, onActivity) {
|
|
15
|
+
const reader = stream.getReader();
|
|
16
|
+
const decoder = new TextDecoder();
|
|
17
|
+
let pending = '';
|
|
18
|
+
let dataLines = [];
|
|
19
|
+
let eventName;
|
|
20
|
+
try {
|
|
21
|
+
while (true) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
onActivity?.();
|
|
26
|
+
pending += decoder.decode(value, { stream: true });
|
|
27
|
+
let newline = pending.indexOf('\n');
|
|
28
|
+
while (newline >= 0) {
|
|
29
|
+
let line = pending.slice(0, newline);
|
|
30
|
+
pending = pending.slice(newline + 1);
|
|
31
|
+
newline = pending.indexOf('\n');
|
|
32
|
+
if (line.endsWith('\r'))
|
|
33
|
+
line = line.slice(0, -1);
|
|
34
|
+
if (line.length === 0) {
|
|
35
|
+
if (dataLines.length > 0) {
|
|
36
|
+
yield { data: dataLines.join('\n'), ...eventName === undefined ? {} : { event: eventName } };
|
|
37
|
+
}
|
|
38
|
+
dataLines = [];
|
|
39
|
+
eventName = undefined;
|
|
40
|
+
}
|
|
41
|
+
else if (line.startsWith(':')) {
|
|
42
|
+
onActivity?.();
|
|
43
|
+
}
|
|
44
|
+
else if (line.startsWith('data:')) {
|
|
45
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
46
|
+
}
|
|
47
|
+
else if (line.startsWith('event:')) {
|
|
48
|
+
eventName = line.slice(6).replace(/^ /, '');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
}
|