aigetwey 1.0.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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/assets/logo.svg +8 -0
- package/assets/screenshot.png +0 -0
- package/assets/wordmark.svg +9 -0
- package/config.example.yaml +56 -0
- package/dashboard/.env.example +12 -0
- package/dashboard/next-env.d.ts +6 -0
- package/dashboard/next.config.ts +12 -0
- package/dashboard/package-lock.json +1771 -0
- package/dashboard/package.json +29 -0
- package/dashboard/postcss.config.mjs +5 -0
- package/dashboard/src/app/(console)/combos/page.tsx +10 -0
- package/dashboard/src/app/(console)/config/page.tsx +5 -0
- package/dashboard/src/app/(console)/console/page.tsx +92 -0
- package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
- package/dashboard/src/app/(console)/layout.tsx +17 -0
- package/dashboard/src/app/(console)/page.tsx +8 -0
- package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/providers/page.tsx +5 -0
- package/dashboard/src/app/(console)/quota/page.tsx +5 -0
- package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
- package/dashboard/src/app/(console)/tools/page.tsx +5 -0
- package/dashboard/src/app/(console)/usage/page.tsx +24 -0
- package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
- package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
- package/dashboard/src/app/api/login/route.ts +30 -0
- package/dashboard/src/app/api/logout/route.ts +9 -0
- package/dashboard/src/app/api/password/route.ts +34 -0
- package/dashboard/src/app/globals.css +340 -0
- package/dashboard/src/app/icon.svg +8 -0
- package/dashboard/src/app/layout.tsx +28 -0
- package/dashboard/src/app/login/page.tsx +60 -0
- package/dashboard/src/components/AreaChart.tsx +115 -0
- package/dashboard/src/components/Badge.tsx +32 -0
- package/dashboard/src/components/Button.tsx +60 -0
- package/dashboard/src/components/CapacityBadges.tsx +40 -0
- package/dashboard/src/components/Checkbox.tsx +40 -0
- package/dashboard/src/components/CliToolConfig.tsx +63 -0
- package/dashboard/src/components/ConfigEditor.tsx +199 -0
- package/dashboard/src/components/ConfirmModal.tsx +36 -0
- package/dashboard/src/components/CooldownTimer.tsx +42 -0
- package/dashboard/src/components/EndpointView.tsx +439 -0
- package/dashboard/src/components/Icon.tsx +25 -0
- package/dashboard/src/components/KeyReveal.tsx +78 -0
- package/dashboard/src/components/Lamp.tsx +8 -0
- package/dashboard/src/components/LogTable.tsx +223 -0
- package/dashboard/src/components/LogoutButton.tsx +20 -0
- package/dashboard/src/components/ModelPicker.tsx +121 -0
- package/dashboard/src/components/ModelSelectModal.tsx +126 -0
- package/dashboard/src/components/PasswordEditor.tsx +86 -0
- package/dashboard/src/components/PricingEditor.tsx +171 -0
- package/dashboard/src/components/ProviderDetail.tsx +566 -0
- package/dashboard/src/components/ProviderManager.tsx +311 -0
- package/dashboard/src/components/QuotaView.tsx +78 -0
- package/dashboard/src/components/Rail.tsx +82 -0
- package/dashboard/src/components/RichCard.tsx +46 -0
- package/dashboard/src/components/RoutingView.tsx +329 -0
- package/dashboard/src/components/ThemeProvider.tsx +36 -0
- package/dashboard/src/components/ToastProvider.tsx +58 -0
- package/dashboard/src/components/ToolDetail.tsx +475 -0
- package/dashboard/src/components/TopBar.tsx +128 -0
- package/dashboard/src/components/UsageView.tsx +151 -0
- package/dashboard/src/components/ui.tsx +54 -0
- package/dashboard/src/lib/capabilities.ts +318 -0
- package/dashboard/src/lib/cliTools.ts +120 -0
- package/dashboard/src/lib/client.ts +190 -0
- package/dashboard/src/lib/gateway.ts +269 -0
- package/dashboard/src/lib/session.ts +71 -0
- package/dashboard/src/middleware.ts +37 -0
- package/dashboard/tsconfig.json +21 -0
- package/dist/adapters/anthropic.js +289 -0
- package/dist/adapters/anthropic.js.map +1 -0
- package/dist/adapters/gemini.js +268 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openai.js +13 -0
- package/dist/adapters/openai.js.map +1 -0
- package/dist/cli/tray/autostart.js +152 -0
- package/dist/cli/tray/autostart.js.map +1 -0
- package/dist/cli/tray/icon.js +4 -0
- package/dist/cli/tray/icon.js.map +1 -0
- package/dist/cli/tray/tray.js +141 -0
- package/dist/cli/tray/tray.js.map +1 -0
- package/dist/cli/tray/trayRuntime.js +91 -0
- package/dist/cli/tray/trayRuntime.js.map +1 -0
- package/dist/cli.js +361 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +728 -0
- package/dist/config.js.map +1 -0
- package/dist/core/authStore.js +78 -0
- package/dist/core/authStore.js.map +1 -0
- package/dist/core/canonical.js +9 -0
- package/dist/core/canonical.js.map +1 -0
- package/dist/core/console-buffer.js +25 -0
- package/dist/core/console-buffer.js.map +1 -0
- package/dist/core/fallback.js +62 -0
- package/dist/core/fallback.js.map +1 -0
- package/dist/core/handler.js +174 -0
- package/dist/core/handler.js.map +1 -0
- package/dist/core/keypool.js +105 -0
- package/dist/core/keypool.js.map +1 -0
- package/dist/core/quota.js +165 -0
- package/dist/core/quota.js.map +1 -0
- package/dist/core/state.js +52 -0
- package/dist/core/state.js.map +1 -0
- package/dist/db.js +193 -0
- package/dist/db.js.map +1 -0
- package/dist/headroom/compress.js +44 -0
- package/dist/headroom/compress.js.map +1 -0
- package/dist/headroom/detect.js +108 -0
- package/dist/headroom/detect.js.map +1 -0
- package/dist/headroom/process.js +158 -0
- package/dist/headroom/process.js.map +1 -0
- package/dist/inject/caveman.js +30 -0
- package/dist/inject/caveman.js.map +1 -0
- package/dist/inject/index.js +24 -0
- package/dist/inject/index.js.map +1 -0
- package/dist/inject/ponytail.js +19 -0
- package/dist/inject/ponytail.js.map +1 -0
- package/dist/middleware/auth.js +66 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/providers/capabilities.js +246 -0
- package/dist/providers/capabilities.js.map +1 -0
- package/dist/providers/free.js +43 -0
- package/dist/providers/free.js.map +1 -0
- package/dist/providers/pricing.js +224 -0
- package/dist/providers/pricing.js.map +1 -0
- package/dist/providers/vertex.js +97 -0
- package/dist/providers/vertex.js.map +1 -0
- package/dist/routes/admin.js +622 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/health.js +4 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/v1.js +75 -0
- package/dist/routes/v1.js.map +1 -0
- package/dist/rtk/detect.js +50 -0
- package/dist/rtk/detect.js.map +1 -0
- package/dist/rtk/filters.js +85 -0
- package/dist/rtk/filters.js.map +1 -0
- package/dist/rtk/index.js +39 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/server.js +100 -0
- package/dist/server.js.map +1 -0
- package/dist/stream/anthropic-stream.js +239 -0
- package/dist/stream/anthropic-stream.js.map +1 -0
- package/dist/stream/chunk.js +7 -0
- package/dist/stream/chunk.js.map +1 -0
- package/dist/stream/gemini-stream.js +135 -0
- package/dist/stream/gemini-stream.js.map +1 -0
- package/dist/stream/index.js +12 -0
- package/dist/stream/index.js.map +1 -0
- package/dist/stream/openai-stream.js +34 -0
- package/dist/stream/openai-stream.js.map +1 -0
- package/dist/stream/sse.js +64 -0
- package/dist/stream/sse.js.map +1 -0
- package/dist/translator/thinking.js +70 -0
- package/dist/translator/thinking.js.map +1 -0
- package/dist/translator/thinkingUnified.js +322 -0
- package/dist/translator/thinkingUnified.js.map +1 -0
- package/dist/upstream/client.js +120 -0
- package/dist/upstream/client.js.map +1 -0
- package/package.json +76 -0
- package/run.sh +27 -0
- package/src/adapters/anthropic.ts +377 -0
- package/src/adapters/gemini.ts +341 -0
- package/src/adapters/index.ts +17 -0
- package/src/adapters/openai.ts +22 -0
- package/src/cli/tray/autostart.ts +133 -0
- package/src/cli/tray/icon.ts +4 -0
- package/src/cli/tray/tray.ts +156 -0
- package/src/cli/tray/trayRuntime.ts +90 -0
- package/src/cli.ts +379 -0
- package/src/config.ts +777 -0
- package/src/core/authStore.ts +86 -0
- package/src/core/canonical.ts +93 -0
- package/src/core/console-buffer.ts +39 -0
- package/src/core/fallback.ts +116 -0
- package/src/core/handler.ts +236 -0
- package/src/core/keypool.ts +152 -0
- package/src/core/quota.ts +214 -0
- package/src/core/state.ts +65 -0
- package/src/db.ts +280 -0
- package/src/headroom/compress.ts +78 -0
- package/src/headroom/detect.ts +119 -0
- package/src/headroom/process.ts +166 -0
- package/src/inject/caveman.ts +35 -0
- package/src/inject/index.ts +46 -0
- package/src/inject/ponytail.ts +31 -0
- package/src/middleware/auth.ts +76 -0
- package/src/providers/capabilities.ts +297 -0
- package/src/providers/free.ts +53 -0
- package/src/providers/pricing.ts +261 -0
- package/src/providers/vertex.ts +117 -0
- package/src/routes/admin.ts +716 -0
- package/src/routes/health.ts +5 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/v1.ts +87 -0
- package/src/rtk/detect.ts +55 -0
- package/src/rtk/filters.ts +94 -0
- package/src/rtk/index.ts +58 -0
- package/src/server.ts +108 -0
- package/src/stream/anthropic-stream.ts +310 -0
- package/src/stream/chunk.ts +46 -0
- package/src/stream/gemini-stream.ts +158 -0
- package/src/stream/index.ts +23 -0
- package/src/stream/openai-stream.ts +41 -0
- package/src/stream/sse.ts +72 -0
- package/src/translator/thinking.ts +64 -0
- package/src/translator/thinkingUnified.ts +319 -0
- package/src/upstream/client.ts +155 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic streaming <-> canonical (OpenAI) chunks. The hard part.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic streams a structured event sequence:
|
|
5
|
+
* message_start
|
|
6
|
+
* content_block_start / content_block_delta / content_block_stop (per block)
|
|
7
|
+
* message_delta (carries stop_reason + output usage)
|
|
8
|
+
* message_stop
|
|
9
|
+
*
|
|
10
|
+
* OpenAI streams flat `choices[].delta` chunks. Translating requires a state
|
|
11
|
+
* machine: tracking the message id, which block is open, and the mapping
|
|
12
|
+
* between OpenAI tool-call indices and Anthropic block indices.
|
|
13
|
+
*/
|
|
14
|
+
import type { CanonicalChunk, CanonicalDeltaToolCall, ChunkFinishReason } from "./chunk.js";
|
|
15
|
+
import type { SSEEvent } from "./sse.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================
|
|
18
|
+
// Anthropic SSE -> canonical chunks (provider=anthropic, client=openai)
|
|
19
|
+
// ============================================================
|
|
20
|
+
|
|
21
|
+
interface AnthStreamState {
|
|
22
|
+
id: string;
|
|
23
|
+
model: string;
|
|
24
|
+
/** anthropic block index -> openai tool-call index (only tool_use blocks) */
|
|
25
|
+
toolIndexByBlock: Map<number, number>;
|
|
26
|
+
nextToolIndex: number;
|
|
27
|
+
promptTokens: number;
|
|
28
|
+
cachedTokens?: number;
|
|
29
|
+
cacheCreationTokens?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function* streamToCanonical(events: AsyncIterable<SSEEvent>): AsyncGenerator<CanonicalChunk> {
|
|
33
|
+
const state: AnthStreamState = {
|
|
34
|
+
id: "",
|
|
35
|
+
model: "",
|
|
36
|
+
toolIndexByBlock: new Map(),
|
|
37
|
+
nextToolIndex: 0,
|
|
38
|
+
promptTokens: 0,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for await (const ev of events) {
|
|
42
|
+
const data = ev.data.trim();
|
|
43
|
+
if (!data || data === "[DONE]") continue;
|
|
44
|
+
let msg: Record<string, unknown>;
|
|
45
|
+
try {
|
|
46
|
+
msg = JSON.parse(data) as Record<string, unknown>;
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const type = ev.event ?? (msg.type as string | undefined);
|
|
52
|
+
|
|
53
|
+
switch (type) {
|
|
54
|
+
case "message_start": {
|
|
55
|
+
const message = msg.message as
|
|
56
|
+
| { id?: string; model?: string; usage?: Record<string, number> }
|
|
57
|
+
| undefined;
|
|
58
|
+
state.id = message?.id ?? "";
|
|
59
|
+
state.model = message?.model ?? "";
|
|
60
|
+
const u = message?.usage;
|
|
61
|
+
if (u) {
|
|
62
|
+
state.promptTokens = u.input_tokens ?? 0;
|
|
63
|
+
state.cachedTokens = u.cache_read_input_tokens;
|
|
64
|
+
state.cacheCreationTokens = u.cache_creation_input_tokens;
|
|
65
|
+
}
|
|
66
|
+
const startChunk = baseChunk(state, { role: "assistant", content: "" }, null);
|
|
67
|
+
startChunk.usage = {
|
|
68
|
+
prompt_tokens: state.promptTokens,
|
|
69
|
+
cached_tokens: state.cachedTokens,
|
|
70
|
+
cache_creation_tokens: state.cacheCreationTokens,
|
|
71
|
+
};
|
|
72
|
+
yield startChunk;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "content_block_start": {
|
|
77
|
+
const block = msg.content_block as { type?: string; id?: string; name?: string } | undefined;
|
|
78
|
+
const index = msg.index as number;
|
|
79
|
+
if (block?.type === "tool_use") {
|
|
80
|
+
const toolIndex = state.nextToolIndex++;
|
|
81
|
+
state.toolIndexByBlock.set(index, toolIndex);
|
|
82
|
+
const tc: CanonicalDeltaToolCall = {
|
|
83
|
+
index: toolIndex,
|
|
84
|
+
id: block.id,
|
|
85
|
+
type: "function",
|
|
86
|
+
function: { name: block.name, arguments: "" },
|
|
87
|
+
};
|
|
88
|
+
yield baseChunk(state, { tool_calls: [tc] }, null);
|
|
89
|
+
}
|
|
90
|
+
// text/thinking block_start carries no text; deltas follow
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case "content_block_delta": {
|
|
95
|
+
const delta = msg.delta as
|
|
96
|
+
| { type?: string; text?: string; thinking?: string; partial_json?: string }
|
|
97
|
+
| undefined;
|
|
98
|
+
const index = msg.index as number;
|
|
99
|
+
if (delta?.type === "text_delta") {
|
|
100
|
+
yield baseChunk(state, { content: delta.text ?? "" }, null);
|
|
101
|
+
} else if (delta?.type === "thinking_delta") {
|
|
102
|
+
yield baseChunk(state, { reasoning: delta.thinking ?? "" }, null);
|
|
103
|
+
} else if (delta?.type === "input_json_delta") {
|
|
104
|
+
const toolIndex = state.toolIndexByBlock.get(index);
|
|
105
|
+
if (toolIndex !== undefined) {
|
|
106
|
+
const tc: CanonicalDeltaToolCall = {
|
|
107
|
+
index: toolIndex,
|
|
108
|
+
function: { arguments: delta.partial_json ?? "" },
|
|
109
|
+
};
|
|
110
|
+
yield baseChunk(state, { tool_calls: [tc] }, null);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case "message_delta": {
|
|
117
|
+
const delta = msg.delta as { stop_reason?: string | null } | undefined;
|
|
118
|
+
const usage = msg.usage as { output_tokens?: number } | undefined;
|
|
119
|
+
const finish = mapStopReason(delta?.stop_reason);
|
|
120
|
+
const chunk = baseChunk(state, {}, finish);
|
|
121
|
+
chunk.usage = {
|
|
122
|
+
prompt_tokens: state.promptTokens,
|
|
123
|
+
completion_tokens: usage?.output_tokens ?? 0,
|
|
124
|
+
cached_tokens: state.cachedTokens,
|
|
125
|
+
cache_creation_tokens: state.cacheCreationTokens,
|
|
126
|
+
};
|
|
127
|
+
yield chunk;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// content_block_stop, message_stop, ping -> no canonical output
|
|
132
|
+
default:
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function baseChunk(
|
|
139
|
+
state: AnthStreamState,
|
|
140
|
+
delta: CanonicalChunk["choices"][0]["delta"],
|
|
141
|
+
finish: ChunkFinishReason,
|
|
142
|
+
): CanonicalChunk {
|
|
143
|
+
return {
|
|
144
|
+
id: state.id || "chatcmpl-stream",
|
|
145
|
+
model: state.model,
|
|
146
|
+
created: 0,
|
|
147
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mapStopReason(reason: string | null | undefined): ChunkFinishReason {
|
|
152
|
+
switch (reason) {
|
|
153
|
+
case "end_turn":
|
|
154
|
+
case "stop_sequence":
|
|
155
|
+
return "stop";
|
|
156
|
+
case "max_tokens":
|
|
157
|
+
return "length";
|
|
158
|
+
case "tool_use":
|
|
159
|
+
return "tool_calls";
|
|
160
|
+
default:
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================
|
|
166
|
+
// canonical chunks -> Anthropic SSE (provider=openai, client=anthropic)
|
|
167
|
+
// ============================================================
|
|
168
|
+
|
|
169
|
+
interface BuildState {
|
|
170
|
+
id: string;
|
|
171
|
+
model: string;
|
|
172
|
+
started: boolean;
|
|
173
|
+
/** the currently-open anthropic block index, or -1 if none */
|
|
174
|
+
openBlock: number;
|
|
175
|
+
/** what kind of block is open */
|
|
176
|
+
openKind: "text" | "thinking" | "tool" | null;
|
|
177
|
+
nextBlockIndex: number;
|
|
178
|
+
/** openai tool index -> anthropic block index */
|
|
179
|
+
toolBlockByIndex: Map<number, number>;
|
|
180
|
+
promptTokens: number;
|
|
181
|
+
completionTokens: number;
|
|
182
|
+
finish: string | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function ev(event: string, data: Record<string, unknown>): SSEEvent {
|
|
186
|
+
return { event, data: JSON.stringify({ type: event, ...data }) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function* streamFromCanonical(chunks: AsyncIterable<CanonicalChunk>): AsyncGenerator<SSEEvent> {
|
|
190
|
+
const s: BuildState = {
|
|
191
|
+
id: "",
|
|
192
|
+
model: "",
|
|
193
|
+
started: false,
|
|
194
|
+
openBlock: -1,
|
|
195
|
+
openKind: null,
|
|
196
|
+
nextBlockIndex: 0,
|
|
197
|
+
toolBlockByIndex: new Map(),
|
|
198
|
+
promptTokens: 0,
|
|
199
|
+
completionTokens: 0,
|
|
200
|
+
finish: null,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function* closeOpen(): Generator<SSEEvent> {
|
|
204
|
+
if (s.openBlock !== -1) {
|
|
205
|
+
yield ev("content_block_stop", { index: s.openBlock });
|
|
206
|
+
s.openBlock = -1;
|
|
207
|
+
s.openKind = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for await (const chunk of chunks) {
|
|
212
|
+
if (!s.started) {
|
|
213
|
+
s.id = chunk.id || "msg_stream";
|
|
214
|
+
s.model = chunk.model || "";
|
|
215
|
+
s.promptTokens = chunk.usage?.prompt_tokens ?? 0;
|
|
216
|
+
yield ev("message_start", {
|
|
217
|
+
message: {
|
|
218
|
+
id: s.id,
|
|
219
|
+
type: "message",
|
|
220
|
+
role: "assistant",
|
|
221
|
+
model: s.model,
|
|
222
|
+
content: [],
|
|
223
|
+
stop_reason: null,
|
|
224
|
+
stop_sequence: null,
|
|
225
|
+
usage: { input_tokens: s.promptTokens, output_tokens: 0 },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
s.started = true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const choice = chunk.choices?.[0];
|
|
232
|
+
if (!choice) continue;
|
|
233
|
+
const delta = choice.delta ?? {};
|
|
234
|
+
|
|
235
|
+
// reasoning -> thinking block
|
|
236
|
+
if (delta.reasoning) {
|
|
237
|
+
if (s.openKind !== "thinking") {
|
|
238
|
+
yield* closeOpen();
|
|
239
|
+
s.openBlock = s.nextBlockIndex++;
|
|
240
|
+
s.openKind = "thinking";
|
|
241
|
+
yield ev("content_block_start", {
|
|
242
|
+
index: s.openBlock,
|
|
243
|
+
content_block: { type: "thinking", thinking: "" },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
yield ev("content_block_delta", {
|
|
247
|
+
index: s.openBlock,
|
|
248
|
+
delta: { type: "thinking_delta", thinking: delta.reasoning },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// text -> text block
|
|
253
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
254
|
+
if (s.openKind !== "text") {
|
|
255
|
+
yield* closeOpen();
|
|
256
|
+
s.openBlock = s.nextBlockIndex++;
|
|
257
|
+
s.openKind = "text";
|
|
258
|
+
yield ev("content_block_start", { index: s.openBlock, content_block: { type: "text", text: "" } });
|
|
259
|
+
}
|
|
260
|
+
yield ev("content_block_delta", {
|
|
261
|
+
index: s.openBlock,
|
|
262
|
+
delta: { type: "text_delta", text: delta.content },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// tool calls -> tool_use blocks
|
|
267
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
268
|
+
let block = s.toolBlockByIndex.get(tc.index);
|
|
269
|
+
if (block === undefined) {
|
|
270
|
+
yield* closeOpen();
|
|
271
|
+
block = s.nextBlockIndex++;
|
|
272
|
+
s.toolBlockByIndex.set(tc.index, block);
|
|
273
|
+
s.openBlock = block;
|
|
274
|
+
s.openKind = "tool";
|
|
275
|
+
yield ev("content_block_start", {
|
|
276
|
+
index: block,
|
|
277
|
+
content_block: { type: "tool_use", id: tc.id ?? `toolu_${block}`, name: tc.function?.name ?? "", input: {} },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const frag = tc.function?.arguments;
|
|
281
|
+
if (frag) {
|
|
282
|
+
yield ev("content_block_delta", { index: block, delta: { type: "input_json_delta", partial_json: frag } });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (choice.finish_reason) s.finish = choice.finish_reason;
|
|
287
|
+
if (chunk.usage?.completion_tokens) s.completionTokens = chunk.usage.completion_tokens;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// finalize
|
|
291
|
+
yield* closeOpen();
|
|
292
|
+
yield ev("message_delta", {
|
|
293
|
+
delta: { stop_reason: reverseStop(s.finish), stop_sequence: null },
|
|
294
|
+
usage: { output_tokens: s.completionTokens },
|
|
295
|
+
});
|
|
296
|
+
yield ev("message_stop", {});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function reverseStop(reason: string | null): string | null {
|
|
300
|
+
switch (reason) {
|
|
301
|
+
case "stop":
|
|
302
|
+
return "end_turn";
|
|
303
|
+
case "length":
|
|
304
|
+
return "max_tokens";
|
|
305
|
+
case "tool_calls":
|
|
306
|
+
return "tool_use";
|
|
307
|
+
default:
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical streaming unit = OpenAI Chat Completion *chunk*.
|
|
3
|
+
* Provider SSE is translated INTO a stream of these; client SSE is translated
|
|
4
|
+
* OUT of them. Mirrors the non-streaming canonical shape.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface CanonicalDeltaToolCall {
|
|
8
|
+
index: number;
|
|
9
|
+
id?: string;
|
|
10
|
+
type?: "function";
|
|
11
|
+
function?: {
|
|
12
|
+
name?: string;
|
|
13
|
+
/** partial JSON fragment of arguments */
|
|
14
|
+
arguments?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CanonicalChunkDelta {
|
|
19
|
+
role?: "assistant";
|
|
20
|
+
content?: string | null;
|
|
21
|
+
/** reasoning/thinking text, normalized across providers */
|
|
22
|
+
reasoning?: string;
|
|
23
|
+
tool_calls?: CanonicalDeltaToolCall[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CanonicalChunkUsage {
|
|
27
|
+
prompt_tokens?: number;
|
|
28
|
+
completion_tokens?: number;
|
|
29
|
+
cached_tokens?: number;
|
|
30
|
+
cache_creation_tokens?: number;
|
|
31
|
+
reasoning_tokens?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ChunkFinishReason = "stop" | "length" | "tool_calls" | "content_filter" | null;
|
|
35
|
+
|
|
36
|
+
export interface CanonicalChunk {
|
|
37
|
+
id: string;
|
|
38
|
+
model: string;
|
|
39
|
+
created: number;
|
|
40
|
+
choices: Array<{
|
|
41
|
+
index: number;
|
|
42
|
+
delta: CanonicalChunkDelta;
|
|
43
|
+
finish_reason: ChunkFinishReason;
|
|
44
|
+
}>;
|
|
45
|
+
usage?: CanonicalChunkUsage;
|
|
46
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini streaming <-> canonical (OpenAI) chunks.
|
|
3
|
+
*
|
|
4
|
+
* Gemini's SSE (alt=sse) emits `data: {...}` frames, each a partial
|
|
5
|
+
* GenerateContentResponse: candidates[0].content.parts carry text deltas,
|
|
6
|
+
* functionCall parts arrive whole (not fragmented), and the final frame carries
|
|
7
|
+
* finishReason + usageMetadata.
|
|
8
|
+
*/
|
|
9
|
+
import type { CanonicalChunk, CanonicalDeltaToolCall, ChunkFinishReason } from "./chunk.js";
|
|
10
|
+
import type { SSEEvent } from "./sse.js";
|
|
11
|
+
|
|
12
|
+
interface GeminiPart {
|
|
13
|
+
text?: string;
|
|
14
|
+
functionCall?: { name: string; args: Record<string, unknown> };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mapFinish(reason: string | undefined): ChunkFinishReason {
|
|
18
|
+
switch (reason) {
|
|
19
|
+
case "STOP":
|
|
20
|
+
return "stop";
|
|
21
|
+
case "MAX_TOKENS":
|
|
22
|
+
return "length";
|
|
23
|
+
case "SAFETY":
|
|
24
|
+
case "RECITATION":
|
|
25
|
+
return "content_filter";
|
|
26
|
+
default:
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// Gemini SSE -> canonical chunks (provider=gemini)
|
|
33
|
+
// ============================================================
|
|
34
|
+
|
|
35
|
+
export async function* streamToCanonical(events: AsyncIterable<SSEEvent>): AsyncGenerator<CanonicalChunk> {
|
|
36
|
+
let toolIndex = 0;
|
|
37
|
+
let started = false;
|
|
38
|
+
|
|
39
|
+
for await (const ev of events) {
|
|
40
|
+
const data = ev.data.trim();
|
|
41
|
+
if (!data || data === "[DONE]") continue;
|
|
42
|
+
let msg: Record<string, unknown>;
|
|
43
|
+
try {
|
|
44
|
+
msg = JSON.parse(data) as Record<string, unknown>;
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cand = (msg.candidates as Array<{ content?: { parts?: GeminiPart[] }; finishReason?: string }>)?.[0];
|
|
50
|
+
const model = (msg.modelVersion as string | undefined) ?? "";
|
|
51
|
+
const base = (delta: CanonicalChunk["choices"][0]["delta"], finish: ChunkFinishReason): CanonicalChunk => ({
|
|
52
|
+
id: "gemini-stream",
|
|
53
|
+
model,
|
|
54
|
+
created: 0,
|
|
55
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!started) {
|
|
59
|
+
started = true;
|
|
60
|
+
yield base({ role: "assistant", content: "" }, null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const p of cand?.content?.parts ?? []) {
|
|
64
|
+
if (typeof p.text === "string" && p.text.length > 0) {
|
|
65
|
+
yield base({ content: p.text }, null);
|
|
66
|
+
} else if (p.functionCall) {
|
|
67
|
+
const tc: CanonicalDeltaToolCall = {
|
|
68
|
+
index: toolIndex,
|
|
69
|
+
id: `call_${p.functionCall.name}_${toolIndex}`,
|
|
70
|
+
type: "function",
|
|
71
|
+
function: { name: p.functionCall.name, arguments: JSON.stringify(p.functionCall.args ?? {}) },
|
|
72
|
+
};
|
|
73
|
+
toolIndex++;
|
|
74
|
+
yield base({ tool_calls: [tc] }, null);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const usageMetadata = msg.usageMetadata as
|
|
79
|
+
| { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number }
|
|
80
|
+
| undefined;
|
|
81
|
+
if (cand?.finishReason || usageMetadata) {
|
|
82
|
+
const chunk = base({}, mapFinish(cand?.finishReason));
|
|
83
|
+
if (usageMetadata) {
|
|
84
|
+
chunk.usage = {
|
|
85
|
+
prompt_tokens: usageMetadata.promptTokenCount ?? 0,
|
|
86
|
+
completion_tokens: usageMetadata.candidatesTokenCount ?? 0,
|
|
87
|
+
cached_tokens: usageMetadata.cachedContentTokenCount,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
yield chunk;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================
|
|
96
|
+
// canonical chunks -> Gemini SSE (client=gemini)
|
|
97
|
+
// ============================================================
|
|
98
|
+
|
|
99
|
+
export async function* streamFromCanonical(chunks: AsyncIterable<CanonicalChunk>): AsyncGenerator<SSEEvent> {
|
|
100
|
+
let promptTokens = 0;
|
|
101
|
+
let completionTokens = 0;
|
|
102
|
+
let finish: string | null = null;
|
|
103
|
+
|
|
104
|
+
for await (const chunk of chunks) {
|
|
105
|
+
const choice = chunk.choices?.[0];
|
|
106
|
+
if (!choice) continue;
|
|
107
|
+
const delta = choice.delta ?? {};
|
|
108
|
+
const parts: GeminiPart[] = [];
|
|
109
|
+
|
|
110
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
111
|
+
parts.push({ text: delta.content });
|
|
112
|
+
}
|
|
113
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
114
|
+
if (tc.function?.name) {
|
|
115
|
+
let args: Record<string, unknown> = {};
|
|
116
|
+
try {
|
|
117
|
+
args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
118
|
+
} catch {
|
|
119
|
+
args = {};
|
|
120
|
+
}
|
|
121
|
+
parts.push({ functionCall: { name: tc.function.name, args } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (chunk.usage?.prompt_tokens) promptTokens = chunk.usage.prompt_tokens;
|
|
126
|
+
if (chunk.usage?.completion_tokens) completionTokens = chunk.usage.completion_tokens;
|
|
127
|
+
if (choice.finish_reason) finish = reverseFinish(choice.finish_reason);
|
|
128
|
+
|
|
129
|
+
// emit a frame whenever there's content; the terminal frame also carries
|
|
130
|
+
// finishReason + usage.
|
|
131
|
+
if (parts.length > 0 || choice.finish_reason) {
|
|
132
|
+
const payload: Record<string, unknown> = {
|
|
133
|
+
candidates: [
|
|
134
|
+
{ content: { role: "model", parts }, ...(choice.finish_reason ? { finishReason: finish } : {}), index: 0 },
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
if (choice.finish_reason) {
|
|
138
|
+
payload.usageMetadata = {
|
|
139
|
+
promptTokenCount: promptTokens,
|
|
140
|
+
candidatesTokenCount: completionTokens,
|
|
141
|
+
totalTokenCount: promptTokens + completionTokens,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
yield { data: JSON.stringify(payload) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function reverseFinish(reason: string): string {
|
|
150
|
+
switch (reason) {
|
|
151
|
+
case "length":
|
|
152
|
+
return "MAX_TOKENS";
|
|
153
|
+
case "content_filter":
|
|
154
|
+
return "SAFETY";
|
|
155
|
+
default:
|
|
156
|
+
return "STOP";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CanonicalChunk } from "./chunk.js";
|
|
2
|
+
import type { SSEEvent } from "./sse.js";
|
|
3
|
+
import type { WireFormat } from "../core/canonical.js";
|
|
4
|
+
import * as openaiStream from "./openai-stream.js";
|
|
5
|
+
import * as anthropicStream from "./anthropic-stream.js";
|
|
6
|
+
import * as geminiStream from "./gemini-stream.js";
|
|
7
|
+
|
|
8
|
+
export interface StreamAdapter {
|
|
9
|
+
/** provider SSE events -> canonical chunks */
|
|
10
|
+
streamToCanonical(events: AsyncIterable<SSEEvent>): AsyncGenerator<CanonicalChunk>;
|
|
11
|
+
/** canonical chunks -> client SSE events */
|
|
12
|
+
streamFromCanonical(chunks: AsyncIterable<CanonicalChunk>): AsyncGenerator<SSEEvent>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STREAM_ADAPTERS: Record<WireFormat, StreamAdapter> = {
|
|
16
|
+
openai: openaiStream,
|
|
17
|
+
anthropic: anthropicStream,
|
|
18
|
+
gemini: geminiStream,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function streamAdapterFor(format: WireFormat): StreamAdapter {
|
|
22
|
+
return STREAM_ADAPTERS[format];
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI streaming <-> canonical chunks.
|
|
3
|
+
* The canonical chunk IS the OpenAI chunk shape, so these are light passes:
|
|
4
|
+
* - streamToCanonical: parse provider `data: {...}` frames into chunks
|
|
5
|
+
* - streamFromCanonical: serialize chunks back to `data: {...}` + `[DONE]`
|
|
6
|
+
*/
|
|
7
|
+
import type { CanonicalChunk } from "./chunk.js";
|
|
8
|
+
import type { SSEEvent } from "./sse.js";
|
|
9
|
+
|
|
10
|
+
export async function* streamToCanonical(events: AsyncIterable<SSEEvent>): AsyncGenerator<CanonicalChunk> {
|
|
11
|
+
for await (const ev of events) {
|
|
12
|
+
const data = ev.data.trim();
|
|
13
|
+
if (!data || data === "[DONE]") continue;
|
|
14
|
+
let parsed: unknown;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(data);
|
|
17
|
+
} catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
yield normalize(parsed as CanonicalChunk);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Lift vendor reasoning fields into the canonical `delta.reasoning`. */
|
|
25
|
+
function normalize(chunk: CanonicalChunk): CanonicalChunk {
|
|
26
|
+
for (const choice of chunk.choices ?? []) {
|
|
27
|
+
const d = choice.delta as Record<string, unknown> & { reasoning?: string };
|
|
28
|
+
if (d.reasoning === undefined) {
|
|
29
|
+
const vendor = (d["reasoning_content"] as string | undefined) ?? (d["reasoning"] as string | undefined);
|
|
30
|
+
if (vendor) d.reasoning = vendor;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return chunk;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function* streamFromCanonical(chunks: AsyncIterable<CanonicalChunk>): AsyncGenerator<SSEEvent> {
|
|
37
|
+
for await (const chunk of chunks) {
|
|
38
|
+
yield { data: JSON.stringify(chunk) };
|
|
39
|
+
}
|
|
40
|
+
yield { data: "[DONE]" };
|
|
41
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE (Server-Sent Events) parsing & serialization.
|
|
3
|
+
*
|
|
4
|
+
* Providers stream `data: {...}\n\n` frames (Anthropic also sets `event: <type>`).
|
|
5
|
+
* Parse a byte stream into events, let a translator transform them, re-serialize.
|
|
6
|
+
* Parsing is incremental: provider chunks may split mid-frame.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SSEEvent {
|
|
10
|
+
/** the `event:` field, if present (Anthropic uses it; OpenAI does not) */
|
|
11
|
+
event?: string;
|
|
12
|
+
/** the `data:` payload, raw string (may be `[DONE]`) */
|
|
13
|
+
data: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Parse a (possibly chunked) byte stream into SSE events. */
|
|
17
|
+
export async function* parseSSE(stream: AsyncIterable<Uint8Array>): AsyncGenerator<SSEEvent> {
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
let buf = "";
|
|
20
|
+
|
|
21
|
+
for await (const chunk of stream) {
|
|
22
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
23
|
+
|
|
24
|
+
let sep: number;
|
|
25
|
+
// frames are separated by a blank line (\n\n)
|
|
26
|
+
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
|
27
|
+
const frame = buf.slice(0, sep);
|
|
28
|
+
buf = buf.slice(sep + 2);
|
|
29
|
+
const ev = parseFrame(frame);
|
|
30
|
+
if (ev) yield ev;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// flush a trailing frame that lacked the terminator
|
|
35
|
+
const tail = buf.trim();
|
|
36
|
+
if (tail) {
|
|
37
|
+
const ev = parseFrame(tail);
|
|
38
|
+
if (ev) yield ev;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseFrame(frame: string): SSEEvent | null {
|
|
43
|
+
let event: string | undefined;
|
|
44
|
+
const dataLines: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const rawLine of frame.split("\n")) {
|
|
47
|
+
const line = rawLine.replace(/\r$/, "");
|
|
48
|
+
if (!line || line.startsWith(":")) continue; // comment / empty
|
|
49
|
+
const colon = line.indexOf(":");
|
|
50
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
51
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
52
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
53
|
+
|
|
54
|
+
if (field === "event") event = value;
|
|
55
|
+
else if (field === "data") dataLines.push(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (dataLines.length === 0 && event === undefined) return null;
|
|
59
|
+
return { event, data: dataLines.join("\n") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Serialize an event to an SSE frame string. */
|
|
63
|
+
export function serializeSSE(ev: SSEEvent): string {
|
|
64
|
+
let out = "";
|
|
65
|
+
if (ev.event) out += `event: ${ev.event}\n`;
|
|
66
|
+
out += `data: ${ev.data}\n\n`;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function encodeSSE(ev: SSEEvent): Uint8Array {
|
|
71
|
+
return new TextEncoder().encode(serializeSSE(ev));
|
|
72
|
+
}
|