offrouter-core 0.2.1 → 0.2.2

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.
Files changed (139) hide show
  1. package/dist/audit.d.ts +137 -0
  2. package/dist/audit.d.ts.map +1 -0
  3. package/dist/audit.js +320 -0
  4. package/dist/audit.js.map +1 -0
  5. package/dist/auth/credential-chain.d.ts +84 -0
  6. package/dist/auth/credential-chain.d.ts.map +1 -0
  7. package/dist/auth/credential-chain.js +150 -0
  8. package/dist/auth/credential-chain.js.map +1 -0
  9. package/dist/auth/index.d.ts +10 -0
  10. package/dist/auth/index.d.ts.map +1 -0
  11. package/dist/auth/index.js +7 -0
  12. package/dist/auth/index.js.map +1 -0
  13. package/dist/auth/keychain.d.ts +60 -0
  14. package/dist/auth/keychain.d.ts.map +1 -0
  15. package/dist/auth/keychain.js +104 -0
  16. package/dist/auth/keychain.js.map +1 -0
  17. package/dist/auth/oauth-pkce.d.ts +138 -0
  18. package/dist/auth/oauth-pkce.d.ts.map +1 -0
  19. package/dist/auth/oauth-pkce.js +375 -0
  20. package/dist/auth/oauth-pkce.js.map +1 -0
  21. package/dist/auth/oauth-server.d.ts +36 -0
  22. package/dist/auth/oauth-server.d.ts.map +1 -0
  23. package/dist/auth/oauth-server.js +210 -0
  24. package/dist/auth/oauth-server.js.map +1 -0
  25. package/dist/config.d.ts +76 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +355 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/delegation.d.ts +123 -0
  30. package/dist/delegation.d.ts.map +1 -0
  31. package/dist/delegation.js +455 -0
  32. package/dist/delegation.js.map +1 -0
  33. package/dist/index.d.ts +41 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +26 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/policy.d.ts +45 -0
  38. package/dist/policy.d.ts.map +1 -0
  39. package/dist/policy.js +318 -0
  40. package/dist/policy.js.map +1 -0
  41. package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
  42. package/dist/protocol.d.ts.map +1 -0
  43. package/dist/protocol.js +6 -0
  44. package/dist/protocol.js.map +1 -0
  45. package/dist/providers/adapter.d.ts +137 -0
  46. package/dist/providers/adapter.d.ts.map +1 -0
  47. package/dist/providers/adapter.js +163 -0
  48. package/dist/providers/adapter.js.map +1 -0
  49. package/dist/providers/catalog-data.d.ts +128 -0
  50. package/dist/providers/catalog-data.d.ts.map +1 -0
  51. package/dist/providers/catalog-data.js +397 -0
  52. package/dist/providers/catalog-data.js.map +1 -0
  53. package/dist/providers/catalog.d.ts +63 -0
  54. package/dist/providers/catalog.d.ts.map +1 -0
  55. package/dist/providers/catalog.js +394 -0
  56. package/dist/providers/catalog.js.map +1 -0
  57. package/dist/providers/fake.d.ts +46 -0
  58. package/dist/providers/fake.d.ts.map +1 -0
  59. package/dist/providers/fake.js +234 -0
  60. package/dist/providers/fake.js.map +1 -0
  61. package/dist/providers/openai-compatible.d.ts +65 -0
  62. package/dist/providers/openai-compatible.d.ts.map +1 -0
  63. package/dist/providers/openai-compatible.js +434 -0
  64. package/dist/providers/openai-compatible.js.map +1 -0
  65. package/dist/proxy/responses-mapper.d.ts +366 -0
  66. package/dist/proxy/responses-mapper.d.ts.map +1 -0
  67. package/dist/proxy/responses-mapper.js +517 -0
  68. package/dist/proxy/responses-mapper.js.map +1 -0
  69. package/dist/proxy/responses-server.d.ts +29 -0
  70. package/dist/proxy/responses-server.d.ts.map +1 -0
  71. package/dist/proxy/responses-server.js +360 -0
  72. package/dist/proxy/responses-server.js.map +1 -0
  73. package/dist/router.d.ts +18 -0
  74. package/dist/router.d.ts.map +1 -0
  75. package/dist/router.js +296 -0
  76. package/dist/router.js.map +1 -0
  77. package/dist/schemas.d.ts +560 -0
  78. package/dist/schemas.d.ts.map +1 -0
  79. package/{src/schemas.ts → dist/schemas.js} +83 -103
  80. package/dist/schemas.js.map +1 -0
  81. package/dist/secrets.d.ts +112 -0
  82. package/dist/secrets.d.ts.map +1 -0
  83. package/dist/secrets.js +326 -0
  84. package/dist/secrets.js.map +1 -0
  85. package/dist/types.d.ts +117 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +6 -0
  88. package/dist/types.js.map +1 -0
  89. package/dist/usage-file.d.ts +59 -0
  90. package/dist/usage-file.d.ts.map +1 -0
  91. package/dist/usage-file.js +316 -0
  92. package/dist/usage-file.js.map +1 -0
  93. package/dist/usage.d.ts +235 -0
  94. package/dist/usage.d.ts.map +1 -0
  95. package/dist/usage.js +517 -0
  96. package/dist/usage.js.map +1 -0
  97. package/package.json +7 -2
  98. package/src/audit.test.ts +0 -302
  99. package/src/audit.ts +0 -553
  100. package/src/auth/credential-chain.test.ts +0 -326
  101. package/src/auth/credential-chain.ts +0 -235
  102. package/src/auth/index.ts +0 -45
  103. package/src/auth/keychain.test.ts +0 -265
  104. package/src/auth/keychain.ts +0 -168
  105. package/src/auth/oauth-pkce.test.ts +0 -329
  106. package/src/auth/oauth-pkce.ts +0 -571
  107. package/src/auth/oauth-server.test.ts +0 -83
  108. package/src/auth/oauth-server.ts +0 -296
  109. package/src/config.test.ts +0 -479
  110. package/src/config.ts +0 -505
  111. package/src/delegation.test.ts +0 -368
  112. package/src/delegation.ts +0 -605
  113. package/src/index.ts +0 -280
  114. package/src/policy.test.ts +0 -634
  115. package/src/policy.ts +0 -420
  116. package/src/providers/adapter.test.ts +0 -293
  117. package/src/providers/adapter.ts +0 -328
  118. package/src/providers/catalog-data.test.ts +0 -258
  119. package/src/providers/catalog-data.ts +0 -498
  120. package/src/providers/catalog.test.ts +0 -84
  121. package/src/providers/catalog.ts +0 -483
  122. package/src/providers/fake.ts +0 -312
  123. package/src/providers/openai-compatible.test.ts +0 -366
  124. package/src/providers/openai-compatible.ts +0 -554
  125. package/src/proxy/responses-mapper.test.ts +0 -290
  126. package/src/proxy/responses-mapper.ts +0 -736
  127. package/src/proxy/responses-server.test.ts +0 -322
  128. package/src/proxy/responses-server.ts +0 -469
  129. package/src/router.test.ts +0 -699
  130. package/src/router.ts +0 -352
  131. package/src/schemas.test.ts +0 -291
  132. package/src/secrets.test.ts +0 -271
  133. package/src/secrets.ts +0 -461
  134. package/src/types.ts +0 -173
  135. package/src/usage-file.test.ts +0 -243
  136. package/src/usage-file.ts +0 -435
  137. package/src/usage.test.ts +0 -335
  138. package/src/usage.ts +0 -859
  139. package/tsconfig.json +0 -9
@@ -1,736 +0,0 @@
1
- /**
2
- * OpenAI Responses <-> OffRouter translation for the local proxy.
3
- *
4
- * Inbound: turn an OpenAI Responses request body into a RouteRequest that
5
- * carries only a redacted prompt preview and digest (never raw prompt text)
6
- * plus a DelegationRequest that carries the full prompt after policy allows.
7
- *
8
- * Outbound: turn OffRouter delegation/provider events into OpenAI Responses
9
- * streaming SSE chunks (response.created, response.output_text.delta,
10
- * response.completed) and map provider/policy errors to HTTP statuses and
11
- * OpenAI-style error JSON.
12
- *
13
- * Keeps HTTP and provider specifics out of the core router.
14
- */
15
- import { createHash, randomBytes } from "node:crypto";
16
- import {
17
- DelegationRuntime,
18
- type DelegationRuntimeOptions,
19
- } from "../delegation.js";
20
- import { ROUTE_PROTOCOL_VERSION } from "../protocol.js";
21
- import { z } from "zod";
22
- import type { PolicyConfig } from "../policy.js";
23
- import type { ProviderAdapter } from "../providers/adapter.js";
24
- import type { ProviderErrorCode } from "../providers/adapter.js";
25
- import type {
26
- HarnessRef,
27
- ProviderCandidate,
28
- RouteDecision,
29
- RouteRequest,
30
- WorkspaceRef,
31
- } from "../types.js";
32
- import type {
33
- DelegationMode,
34
- DelegationOutput,
35
- DelegationRequest,
36
- } from "../delegation.js";
37
-
38
- /** Max chars kept for the routing-only prompt preview. */
39
- export const RESPONSES_PROMPT_PREVIEW_CHARS = 120;
40
-
41
- // ---------------------------------------------------------------------------
42
- // Inbound parsing
43
- // ---------------------------------------------------------------------------
44
-
45
- export interface ParsedResponsesInput {
46
- /** Full prompt text for delegation only (never used for routing). */
47
- prompt: string;
48
- /** Requested model id; informational, routing overrides it. */
49
- model?: string;
50
- maxOutputTokens?: number;
51
- temperature?: number;
52
- stream?: boolean;
53
- }
54
-
55
- // ---------------------------------------------------------------------------
56
- // Inbound request schemas (Zod, strict at the top level)
57
- // ---------------------------------------------------------------------------
58
-
59
- /**
60
- * Thrown when an inbound request body fails schema validation. The proxy maps
61
- * this to a 400 OpenAI-style error response.
62
- */
63
- export class InvalidProxyRequestError extends Error {
64
- readonly issues: readonly z.ZodIssue[];
65
- constructor(issues: readonly z.ZodIssue[]) {
66
- super("Invalid request body.");
67
- this.name = "InvalidProxyRequestError";
68
- this.issues = issues;
69
- }
70
- }
71
-
72
- // Nested input item content parts use the default (strip) mode so OpenAI part
73
- // extras such as "annotations" do not break prompt extraction; only the
74
- // top-level request object is parsed strictly.
75
- const ResponsesContentPartSchema = z.object({
76
- type: z.string().optional(),
77
- text: z.string().optional(),
78
- input_text: z.string().optional(),
79
- output_text: z.string().optional(),
80
- });
81
-
82
- const ResponsesInputItemSchema = z.object({
83
- role: z.string().optional(),
84
- content: z
85
- .union([
86
- z.string(),
87
- z.array(z.union([z.string(), ResponsesContentPartSchema])),
88
- ])
89
- .optional(),
90
- });
91
-
92
- /**
93
- * Strict Zod schema for an OpenAI Responses inbound request body. Unknown
94
- * top-level keys are rejected so malformed requests surface as a 400 rather
95
- * than silently ignoring fields.
96
- */
97
- export const ResponsesRequestSchema = z.object({
98
- model: z.string().optional(),
99
- instructions: z.string().optional(),
100
- input: z
101
- .union([
102
- z.string(),
103
- z.array(z.union([z.string(), ResponsesInputItemSchema])),
104
- ])
105
- .optional(),
106
- stream: z.boolean().optional(),
107
- max_output_tokens: z.number().nonnegative().optional(),
108
- temperature: z.number().optional(),
109
- });
110
- // Unknown top-level keys (tools, store, metadata, ...) are stripped, not rejected,
111
- // so real OpenAI-compatible clients interoperate. Structurally invalid values still 400.
112
-
113
- /**
114
- * Extract prompt text and tuning options from an OpenAI Responses request body.
115
- * Validates the body and throws {@link InvalidProxyRequestError} on any
116
- * schema violation. Accepts both string and structured `input` shapes.
117
- */
118
- export function extractPromptFromResponsesInput(
119
- body: unknown,
120
- ): ParsedResponsesInput {
121
- const result = ResponsesRequestSchema.safeParse(body);
122
- if (!result.success) {
123
- throw new InvalidProxyRequestError(result.error.issues);
124
- }
125
- const data = result.data;
126
- return {
127
- prompt: joinPromptText(data.instructions, data.input),
128
- model: data.model,
129
- maxOutputTokens: data.max_output_tokens,
130
- temperature: data.temperature,
131
- stream: data.stream,
132
- };
133
- }
134
-
135
- function joinPromptText(instructions: unknown, input: unknown): string {
136
- const parts: string[] = [];
137
- if (typeof instructions === "string" && instructions.length > 0) {
138
- parts.push(instructions);
139
- }
140
- if (typeof input === "string") {
141
- if (input.length > 0) parts.push(input);
142
- } else if (Array.isArray(input)) {
143
- for (const item of input) {
144
- const text = textFromInputItem(item);
145
- if (text) parts.push(text);
146
- }
147
- }
148
- return parts.join("\n\n").trim();
149
- }
150
-
151
- function textFromInputItem(item: unknown): string | undefined {
152
- if (typeof item === "string") {
153
- return item.length > 0 ? item : undefined;
154
- }
155
- if (!item || typeof item !== "object") return undefined;
156
- const obj = item as Record<string, unknown>;
157
- const content = obj["content"];
158
- if (typeof content === "string") {
159
- return content.length > 0 ? content : undefined;
160
- }
161
- if (Array.isArray(content)) {
162
- const out: string[] = [];
163
- for (const part of content) {
164
- if (typeof part === "string") {
165
- if (part.length > 0) out.push(part);
166
- continue;
167
- }
168
- if (part && typeof part === "object") {
169
- const p = part as Record<string, unknown>;
170
- const text = p["text"] ?? p["input_text"] ?? p["output_text"];
171
- if (typeof text === "string" && text.length > 0) out.push(text);
172
- }
173
- }
174
- return out.length > 0 ? out.join("") : undefined;
175
- }
176
- return undefined;
177
- }
178
-
179
- // ---------------------------------------------------------------------------
180
- // Digest + preview (routing never sees raw prompt text)
181
- // ---------------------------------------------------------------------------
182
-
183
- /** Stable sha256 digest of the full prompt text. */
184
- export function promptDigest(text: string): string {
185
- return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
186
- }
187
-
188
- /** Short single-line preview; collapses whitespace and truncates. */
189
- export function promptPreview(
190
- text: string,
191
- max: number = RESPONSES_PROMPT_PREVIEW_CHARS,
192
- ): string {
193
- const clean = text.replace(/\s+/g, " ").trim();
194
- if (clean.length <= max) return clean;
195
- // ASCII ellipsis (three dots) keeps the preview ASCII-only; reserve three
196
- // characters for the marker so the result never exceeds `max`.
197
- return `${clean.slice(0, Math.max(0, max - 3))}...`;
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // Build RouteRequest (digest/preview only) and DelegationRequest (full prompt)
202
- // ---------------------------------------------------------------------------
203
-
204
- export interface BuildRouteInput {
205
- prompt: string;
206
- harness: HarnessRef;
207
- workspace: WorkspaceRef;
208
- requestId?: string;
209
- kind?: RouteRequest["task"]["kind"];
210
- risk?: RouteRequest["task"]["risk"];
211
- }
212
-
213
- /**
214
- * Build a RouteRequest from the full prompt. The raw prompt is reduced to a
215
- * redacted preview and digest; the full text never appears on the route.
216
- */
217
- export function buildRouteRequest(input: BuildRouteInput): RouteRequest {
218
- return {
219
- protocolVersion: ROUTE_PROTOCOL_VERSION,
220
- requestId: input.requestId ?? `req_proxy_${randomBytes(8).toString("hex")}`,
221
- harness: input.harness,
222
- task: {
223
- promptPreview: promptPreview(input.prompt),
224
- promptDigest: promptDigest(input.prompt),
225
- kind: input.kind ?? "other",
226
- risk: input.risk ?? "low",
227
- requiresStreaming: true,
228
- },
229
- workspace: input.workspace,
230
- constraints: {
231
- subscriptionFirst: true,
232
- allowApiKeyFallback: true,
233
- },
234
- };
235
- }
236
-
237
- export interface BuildDelegationInput {
238
- prompt: string;
239
- route: RouteRequest;
240
- taskId?: string;
241
- maxOutputTokens?: number;
242
- temperature?: number;
243
- }
244
-
245
- /**
246
- * Build a DelegationRequest carrying the full prompt. Routing has already
247
- * cleared the selected provider; the full text reaches the adapter only here.
248
- */
249
- export function buildDelegationRequest(
250
- input: BuildDelegationInput,
251
- ): DelegationRequest {
252
- return {
253
- taskId: input.taskId ?? `task_proxy_${randomBytes(8).toString("hex")}`,
254
- mode: "stream" as DelegationMode,
255
- output: "text" as DelegationOutput,
256
- prompt: input.prompt,
257
- route: input.route,
258
- maxOutputTokens: input.maxOutputTokens,
259
- temperature: input.temperature,
260
- };
261
- }
262
-
263
- // ---------------------------------------------------------------------------
264
- // Outbound SSE mapping
265
- // ---------------------------------------------------------------------------
266
-
267
- export interface ResponsesOutputTextPart {
268
- type: "output_text";
269
- text: string;
270
- annotations: unknown[];
271
- }
272
-
273
- export interface ResponsesMessageItem {
274
- id: string;
275
- type: "message";
276
- status: "completed";
277
- role: "assistant";
278
- content: ResponsesOutputTextPart[];
279
- }
280
-
281
- export interface ResponsesObject {
282
- id: string;
283
- object: "response";
284
- created_at: number;
285
- status: "in_progress" | "completed" | "failed";
286
- model: string;
287
- output: ResponsesMessageItem[];
288
- output_text?: string;
289
- usage?: { input_tokens: number; output_tokens: number; total_tokens: number };
290
- }
291
-
292
- export interface ResponsesUsageInput {
293
- inputTokens?: number;
294
- outputTokens?: number;
295
- totalTokens?: number;
296
- }
297
-
298
- function newPrefixId(prefix: string): string {
299
- return `${prefix}_${randomBytes(12).toString("hex")}`;
300
- }
301
-
302
- /**
303
- * Stateful builder that accumulates output text and produces the three
304
- * OpenAI Responses SSE event payloads the proxy emits.
305
- */
306
- export class ResponsesSseBuilder {
307
- readonly responseId: string;
308
- readonly messageId: string;
309
- readonly createdAtMs: number;
310
- private readonly now: () => Date;
311
- private model: string;
312
- private text = "";
313
- private usage: ResponsesUsageInput | undefined;
314
-
315
- constructor(model: string, now: () => Date = () => new Date()) {
316
- this.model = model;
317
- this.now = now;
318
- this.responseId = newPrefixId("resp");
319
- this.messageId = newPrefixId("msg");
320
- this.createdAtMs = Math.floor(now().getTime() / 1000);
321
- }
322
-
323
- setModel(model: string): void {
324
- this.model = model;
325
- }
326
-
327
- addText(delta: string): void {
328
- this.text += delta;
329
- }
330
-
331
- setUsage(usage: ResponsesUsageInput): void {
332
- this.usage = usage;
333
- }
334
-
335
- get fullText(): string {
336
- return this.text;
337
- }
338
-
339
- createdEvent(): {
340
- type: "response.created";
341
- response: ResponsesObject;
342
- } {
343
- return {
344
- type: "response.created",
345
- response: this.baseObject("in_progress", []),
346
- };
347
- }
348
-
349
- deltaEvent(delta: string): {
350
- type: "response.output_text.delta";
351
- item_id: string;
352
- output_index: number;
353
- content_index: number;
354
- delta: string;
355
- } {
356
- return {
357
- type: "response.output_text.delta",
358
- item_id: this.messageId,
359
- output_index: 0,
360
- content_index: 0,
361
- delta,
362
- };
363
- }
364
-
365
- completedEvent(): {
366
- type: "response.completed";
367
- response: ResponsesObject;
368
- } {
369
- const item: ResponsesMessageItem = {
370
- id: this.messageId,
371
- type: "message",
372
- status: "completed",
373
- role: "assistant",
374
- content: [{ type: "output_text", text: this.text, annotations: [] }],
375
- };
376
- return {
377
- type: "response.completed",
378
- response: this.baseObject("completed", [item], this.text),
379
- };
380
- }
381
-
382
- /**
383
- * Final non-streaming Responses object: merges created + completed into a
384
- * single JSON body (status "completed" with output_text).
385
- */
386
- responseObject(): ResponsesObject {
387
- return this.completedEvent().response;
388
- }
389
-
390
- private baseObject(
391
- status: ResponsesObject["status"],
392
- output: ResponsesMessageItem[],
393
- outputText?: string,
394
- ): ResponsesObject {
395
- const obj: ResponsesObject = {
396
- id: this.responseId,
397
- object: "response",
398
- created_at: this.createdAtMs,
399
- status,
400
- model: this.model,
401
- output,
402
- };
403
- if (outputText !== undefined) {
404
- obj.output_text = outputText;
405
- }
406
- if (this.usage) {
407
- obj.usage = {
408
- input_tokens: this.usage.inputTokens ?? 0,
409
- output_tokens: this.usage.outputTokens ?? 0,
410
- total_tokens: this.usage.totalTokens ?? 0,
411
- };
412
- }
413
- return obj;
414
- }
415
- }
416
-
417
- /** Wrap any JSON-serializable payload as an SSE `data:` line. */
418
- export function responsesSseData(payload: unknown): string {
419
- return `data: ${JSON.stringify(payload)}\n\n`;
420
- }
421
-
422
- // ---------------------------------------------------------------------------
423
- // Chat Completions alias (input + SSE)
424
- // ---------------------------------------------------------------------------
425
-
426
- const ChatMessageContentPartSchema = z.object({
427
- type: z.string().optional(),
428
- text: z.string().optional(),
429
- });
430
-
431
- const ChatMessageSchema = z.object({
432
- role: z.string().optional(),
433
- content: z
434
- .union([
435
- z.string(),
436
- z.array(z.union([z.string(), ChatMessageContentPartSchema])),
437
- ])
438
- .optional(),
439
- });
440
-
441
- /**
442
- * Strict Zod schema for an OpenAI Chat Completions inbound request body.
443
- * Unknown top-level keys are rejected.
444
- */
445
- export const ChatCompletionsRequestSchema = z.object({
446
- model: z.string().optional(),
447
- messages: z.array(ChatMessageSchema).optional(),
448
- stream: z.boolean().optional(),
449
- max_tokens: z.number().nonnegative().optional(),
450
- temperature: z.number().optional(),
451
- });
452
- // Unknown top-level keys stripped (OpenAI-compat interop); invalid values still 400.
453
-
454
- /**
455
- * Extract prompt text and options from an OpenAI Chat Completions body.
456
- * Validates the body strictly and throws {@link InvalidProxyRequestError} on any
457
- * schema violation. Used by the optional /v1/chat/completions alias.
458
- */
459
- export function extractPromptFromChatCompletions(
460
- body: unknown,
461
- ): ParsedResponsesInput {
462
- const result = ChatCompletionsRequestSchema.safeParse(body);
463
- if (!result.success) {
464
- throw new InvalidProxyRequestError(result.error.issues);
465
- }
466
- const data = result.data;
467
- const messages = data.messages ?? [];
468
- const parts: string[] = [];
469
- for (const message of messages) {
470
- const content = message.content;
471
- if (typeof content === "string") {
472
- if (content.length > 0) parts.push(content);
473
- } else if (Array.isArray(content)) {
474
- for (const part of content) {
475
- if (typeof part === "string") {
476
- if (part.length > 0) parts.push(part);
477
- } else if (typeof part.text === "string" && part.text.length > 0) {
478
- parts.push(part.text);
479
- }
480
- }
481
- }
482
- }
483
- return {
484
- prompt: parts.join("\n").trim(),
485
- model: data.model,
486
- maxOutputTokens: data.max_tokens,
487
- temperature: data.temperature,
488
- stream: data.stream,
489
- };
490
- }
491
-
492
- export interface ChatCompletionDeltaEvent {
493
- id: string;
494
- object: "chat.completion.chunk";
495
- created: number;
496
- model: string;
497
- choices: Array<{
498
- index: number;
499
- delta: { role?: "assistant"; content?: string };
500
- finish_reason: string | null;
501
- }>;
502
- }
503
-
504
- export interface ChatUsageInput {
505
- promptTokens?: number;
506
- completionTokens?: number;
507
- totalTokens?: number;
508
- }
509
-
510
- export interface ChatCompletionObject {
511
- id: string;
512
- object: "chat.completion";
513
- created: number;
514
- model: string;
515
- choices: Array<{
516
- index: number;
517
- message: { role: "assistant"; content: string };
518
- finish_reason: string;
519
- }>;
520
- usage?: {
521
- prompt_tokens: number;
522
- completion_tokens: number;
523
- total_tokens: number;
524
- };
525
- }
526
-
527
- /** Stateful builder for Chat Completions streaming chunks. */
528
- export class ChatCompletionsSseBuilder {
529
- readonly completionId: string;
530
- readonly createdAtMs: number;
531
- private model: string;
532
- private text = "";
533
- private usage: ChatUsageInput | undefined;
534
-
535
- constructor(model: string, now: () => Date = () => new Date()) {
536
- this.model = model;
537
- this.completionId = newPrefixId("chatcmpl");
538
- this.createdAtMs = Math.floor(now().getTime() / 1000);
539
- }
540
-
541
- setModel(model: string): void {
542
- this.model = model;
543
- }
544
-
545
- addText(delta: string): void {
546
- this.text += delta;
547
- }
548
-
549
- get fullText(): string {
550
- return this.text;
551
- }
552
-
553
- setUsage(usage: ChatUsageInput): void {
554
- this.usage = usage;
555
- }
556
-
557
- firstEvent(): ChatCompletionDeltaEvent {
558
- return this.chunk({ delta: { role: "assistant" }, finish_reason: null });
559
- }
560
-
561
- deltaEvent(content: string): ChatCompletionDeltaEvent {
562
- return this.chunk({ delta: { content }, finish_reason: null });
563
- }
564
-
565
- doneEvent(): ChatCompletionDeltaEvent {
566
- return this.chunk({ delta: {}, finish_reason: "stop" });
567
- }
568
-
569
- /**
570
- * Final non-streaming Chat Completions object: a single JSON body with the
571
- * merged assistant message and (optional) usage.
572
- */
573
- completionObject(): ChatCompletionObject {
574
- const obj: ChatCompletionObject = {
575
- id: this.completionId,
576
- object: "chat.completion",
577
- created: this.createdAtMs,
578
- model: this.model,
579
- choices: [
580
- {
581
- index: 0,
582
- message: { role: "assistant", content: this.text },
583
- finish_reason: "stop",
584
- },
585
- ],
586
- };
587
- if (this.usage) {
588
- obj.usage = {
589
- prompt_tokens: this.usage.promptTokens ?? 0,
590
- completion_tokens: this.usage.completionTokens ?? 0,
591
- total_tokens: this.usage.totalTokens ?? 0,
592
- };
593
- }
594
- return obj;
595
- }
596
-
597
- private chunk(input: {
598
- delta: { role?: "assistant"; content?: string };
599
- finish_reason: string | null;
600
- }): ChatCompletionDeltaEvent {
601
- return {
602
- id: this.completionId,
603
- object: "chat.completion.chunk",
604
- created: this.createdAtMs,
605
- model: this.model,
606
- choices: [
607
- { index: 0, delta: input.delta, finish_reason: input.finish_reason },
608
- ],
609
- };
610
- }
611
- }
612
-
613
- /** Terminal SSE marker for Chat Completions streams. */
614
- export const CHAT_COMPLETIONS_DONE = "data: [DONE]\n\n";
615
-
616
- // ---------------------------------------------------------------------------
617
- // Error mapping
618
- // ---------------------------------------------------------------------------
619
-
620
- /** Map a canonical provider error code to an HTTP status. */
621
- export function providerErrorCodeToHttpStatus(code: ProviderErrorCode): number {
622
- switch (code) {
623
- case "auth_failed":
624
- return 401;
625
- case "rate_limited":
626
- case "quota_exhausted":
627
- return 429;
628
- case "billing":
629
- return 402;
630
- case "model_not_found":
631
- return 404;
632
- case "context_window":
633
- return 413;
634
- case "overload":
635
- return 503;
636
- case "timeout":
637
- return 504;
638
- case "network":
639
- return 502;
640
- case "invalid_request":
641
- case "safety_refusal":
642
- return 400;
643
- case "stream_interrupted":
644
- case "unknown":
645
- default:
646
- return 500;
647
- }
648
- }
649
-
650
- /**
651
- * Map a routing decision at the gate to an HTTP status.
652
- * needsConfiguration -> 503; otherwise blocked -> 403.
653
- */
654
- export function policyDecisionToHttpStatus(decision: RouteDecision): number {
655
- if (decision.needsConfiguration) return 503;
656
- return 403;
657
- }
658
-
659
- function openAiErrorType(status: number): string {
660
- switch (status) {
661
- case 401:
662
- return "authentication_error";
663
- case 403:
664
- return "permission_denied";
665
- case 402:
666
- return "billing_error";
667
- case 429:
668
- return "rate_limit_error";
669
- case 404:
670
- return "not_found";
671
- case 400:
672
- return "invalid_request_error";
673
- case 503:
674
- return "service_unavailable";
675
- default:
676
- return "server_error";
677
- }
678
- }
679
-
680
- export interface OpenAiErrorPayload {
681
- error: {
682
- message: string;
683
- type: string;
684
- code?: string;
685
- };
686
- }
687
-
688
- /** Build an OpenAI-style error JSON payload. */
689
- export function openAiErrorPayload(
690
- status: number,
691
- message: string,
692
- code?: string,
693
- ): OpenAiErrorPayload {
694
- const error: OpenAiErrorPayload["error"] = {
695
- message,
696
- type: openAiErrorType(status),
697
- };
698
- if (code !== undefined) {
699
- error.code = code;
700
- }
701
- return { error };
702
- }
703
-
704
- // ---------------------------------------------------------------------------
705
- // Proxy context (same shape as the MCP context)
706
- // ---------------------------------------------------------------------------
707
-
708
- export interface ResponsesProxyContext {
709
- policy: PolicyConfig;
710
- candidates: ProviderCandidate[];
711
- adapters: Record<string, ProviderAdapter>;
712
- runtime: DelegationRuntime;
713
- }
714
-
715
- export function createResponsesProxyContext(options: {
716
- policy: PolicyConfig;
717
- candidates: ProviderCandidate[];
718
- adapters: Record<string, ProviderAdapter>;
719
- delegation?: Pick<
720
- DelegationRuntimeOptions,
721
- "audit" | "now" | "includePromptPreview"
722
- >;
723
- }): ResponsesProxyContext {
724
- const runtime = new DelegationRuntime({
725
- adapters: options.adapters,
726
- candidates: options.candidates,
727
- policy: options.policy,
728
- ...options.delegation,
729
- });
730
- return {
731
- policy: options.policy,
732
- candidates: [...options.candidates],
733
- adapters: { ...options.adapters },
734
- runtime,
735
- };
736
- }