tina4-nodejs 3.13.133 → 3.13.134
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/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3181 -3051
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3090 -2952
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3100 -2965
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- package/types/orm/src/seederTypes.d.ts +16 -0
|
@@ -185,39 +185,53 @@ export class Ai {
|
|
|
185
185
|
if (!Array.isArray(content) || content.length === 0) {
|
|
186
186
|
throw new AiConfigError("AI message content must be a string or a non-empty list of parts");
|
|
187
187
|
}
|
|
188
|
-
for (const part of content)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
} else if (partType === "tool_result") {
|
|
209
|
-
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
210
|
-
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
211
|
-
}
|
|
212
|
-
if (typeof record.content !== "string") {
|
|
213
|
-
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
214
|
-
}
|
|
215
|
-
} else {
|
|
216
|
-
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
217
|
-
}
|
|
188
|
+
for (const part of content) this.validateContentPart(part);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private static validateContentPart(part: unknown): void {
|
|
192
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
193
|
+
throw new AiConfigError("AI content part must be an object with type and text/source");
|
|
194
|
+
}
|
|
195
|
+
const record = part as Record<string, unknown>;
|
|
196
|
+
switch (record.type) {
|
|
197
|
+
case "text":
|
|
198
|
+
this.validateTextPart(record);
|
|
199
|
+
return;
|
|
200
|
+
case "image":
|
|
201
|
+
this.validateImagePart(record);
|
|
202
|
+
return;
|
|
203
|
+
case "tool_result":
|
|
204
|
+
this.validateToolResultPart(record);
|
|
205
|
+
return;
|
|
206
|
+
default:
|
|
207
|
+
throw new AiConfigError(`AI content part has unknown type '${String(record.type)}'`);
|
|
218
208
|
}
|
|
219
209
|
}
|
|
220
210
|
|
|
211
|
+
private static validateTextPart(record: Record<string, unknown>): void {
|
|
212
|
+
if (typeof record.text !== "string") throw new AiConfigError("AI text content part requires a string 'text' field");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private static validateImagePart(record: Record<string, unknown>): void {
|
|
216
|
+
const source = record.source;
|
|
217
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
218
|
+
throw new AiConfigError("AI image content part requires a non-empty string 'source' field");
|
|
219
|
+
}
|
|
220
|
+
if (!source.startsWith("data:") && !source.startsWith("https://")) {
|
|
221
|
+
throw new AiConfigError("AI image source must be a data: URI or an https:// URL");
|
|
222
|
+
}
|
|
223
|
+
if (source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(source)) {
|
|
224
|
+
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private static validateToolResultPart(record: Record<string, unknown>): void {
|
|
229
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
230
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
231
|
+
}
|
|
232
|
+
if (typeof record.content !== "string") throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
233
|
+
}
|
|
234
|
+
|
|
221
235
|
/**
|
|
222
236
|
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
223
237
|
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
@@ -566,27 +580,11 @@ export class Ai {
|
|
|
566
580
|
throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
|
|
567
581
|
}
|
|
568
582
|
const response = opened.response;
|
|
569
|
-
const chunks = this.responseChunks(response);
|
|
570
|
-
const events = parseSseStream(parseLineStream(chunks));
|
|
571
|
-
const aggregator = new AggregateState(config.provider);
|
|
572
583
|
let done = false;
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
yield emitted;
|
|
578
|
-
if (emitted.type === "done" || emitted.type === "error") { done = true; break; }
|
|
579
|
-
}
|
|
580
|
-
if (done) break;
|
|
581
|
-
}
|
|
582
|
-
} catch (error) {
|
|
583
|
-
if (yielded) {
|
|
584
|
-
yielded = true;
|
|
585
|
-
yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
|
|
586
|
-
opened.cleanup(); opened = null;
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
throw error;
|
|
584
|
+
for await (const emitted of this.readStream(response, config.provider)) {
|
|
585
|
+
yielded = true;
|
|
586
|
+
yield emitted;
|
|
587
|
+
if (emitted.type === "done" || emitted.type === "error") { done = true; break; }
|
|
590
588
|
}
|
|
591
589
|
opened.cleanup(); opened = null;
|
|
592
590
|
if (done) return;
|
|
@@ -611,6 +609,24 @@ export class Ai {
|
|
|
611
609
|
}
|
|
612
610
|
}
|
|
613
611
|
|
|
612
|
+
private static async *readStream(response: IncomingMessage, provider: Config["provider"]): AsyncGenerator<AiEvent> {
|
|
613
|
+
const events = parseSseStream(parseLineStream(this.responseChunks(response)));
|
|
614
|
+
const aggregator = new AggregateState(provider);
|
|
615
|
+
let yielded = false;
|
|
616
|
+
try {
|
|
617
|
+
for await (const sseEvent of events) {
|
|
618
|
+
for (const emitted of aggregator.consume(sseEvent)) {
|
|
619
|
+
yielded = true;
|
|
620
|
+
yield emitted;
|
|
621
|
+
if (emitted.type === "done" || emitted.type === "error") return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (!yielded) throw error;
|
|
626
|
+
yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
614
630
|
private static streamError(error: unknown): AiError {
|
|
615
631
|
if (error instanceof AiError) return error;
|
|
616
632
|
if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
|
|
@@ -663,132 +679,154 @@ class AggregateState {
|
|
|
663
679
|
if (!Array.isArray(choices) || choices.length === 0) return;
|
|
664
680
|
const choice = choices[0];
|
|
665
681
|
const delta = (choice.delta ?? {}) as Record<string, unknown>;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
|
|
671
|
-
if (Array.isArray(toolCalls)) {
|
|
672
|
-
for (const call of toolCalls) {
|
|
673
|
-
const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
|
|
674
|
-
const idFromCall = typeof call.id === "string" ? call.id : "";
|
|
675
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
676
|
-
const nameFromCall = typeof fn.name === "string" ? fn.name : "";
|
|
677
|
-
const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
678
|
-
const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
|
|
679
|
-
if (idFromCall) existing.id = idFromCall;
|
|
680
|
-
if (nameFromCall) existing.name = nameFromCall;
|
|
681
|
-
existing.args += argsFragment;
|
|
682
|
-
this.toolBuffers.set(index, existing);
|
|
683
|
-
if (existing.name && existing.args) {
|
|
684
|
-
try {
|
|
685
|
-
const parsed = JSON.parse(existing.args) as unknown;
|
|
686
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
687
|
-
this.toolBuffers.delete(index);
|
|
688
|
-
yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed as Record<string, unknown> };
|
|
689
|
-
}
|
|
690
|
-
} catch {
|
|
691
|
-
/* args not complete yet — keep buffering */
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
|
|
697
|
-
this.lastFinishReason = choice.finish_reason;
|
|
698
|
-
}
|
|
699
|
-
const usage = payload.usage as Record<string, unknown> | undefined;
|
|
700
|
-
if (usage && typeof usage === "object") {
|
|
701
|
-
const promptTokens = Number(usage.prompt_tokens ?? 0);
|
|
702
|
-
const completionTokens = Number(usage.completion_tokens ?? 0);
|
|
703
|
-
const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
|
|
704
|
-
if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) {
|
|
705
|
-
this.lastUsage = { promptTokens, completionTokens, totalTokens };
|
|
706
|
-
}
|
|
707
|
-
}
|
|
682
|
+
yield* this.consumeOpenAiContent(delta);
|
|
683
|
+
yield* this.consumeOpenAiTools(delta);
|
|
684
|
+
this.updateOpenAiFinishReason(choice);
|
|
685
|
+
this.updateOpenAiUsage(payload);
|
|
708
686
|
}
|
|
709
687
|
|
|
710
688
|
private *consumeAnthropic(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
689
|
+
switch (payload.type) {
|
|
690
|
+
case "content_block_start":
|
|
691
|
+
this.consumeAnthropicBlockStart(payload);
|
|
692
|
+
return;
|
|
693
|
+
case "content_block_delta":
|
|
694
|
+
yield* this.consumeAnthropicBlockDelta(payload);
|
|
695
|
+
return;
|
|
696
|
+
case "content_block_stop":
|
|
697
|
+
yield* this.consumeAnthropicBlockStop(payload);
|
|
698
|
+
return;
|
|
699
|
+
case "message_delta":
|
|
700
|
+
this.consumeAnthropicMessageDelta(payload);
|
|
701
|
+
return;
|
|
702
|
+
case "message_stop":
|
|
703
|
+
yield* this.consumeAnthropicMessageStop();
|
|
704
|
+
return;
|
|
705
|
+
case "message_start":
|
|
706
|
+
this.consumeAnthropicMessageStart(payload);
|
|
707
|
+
return;
|
|
708
|
+
case "error":
|
|
709
|
+
this.consumeAnthropicError(payload);
|
|
727
710
|
return;
|
|
728
|
-
}
|
|
729
|
-
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
730
|
-
const existing = this.toolBuffers.get(index);
|
|
731
|
-
if (existing) existing.args += delta.partial_json;
|
|
732
|
-
}
|
|
733
|
-
return;
|
|
734
711
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private *consumeOpenAiContent(delta: Record<string, unknown>): Iterable<AiEvent> {
|
|
715
|
+
const content = delta.content;
|
|
716
|
+
if (typeof content === "string" && content.length > 0) yield { type: "text_delta", text: content };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private *consumeOpenAiTools(delta: Record<string, unknown>): Iterable<AiEvent> {
|
|
720
|
+
const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
|
|
721
|
+
if (!Array.isArray(toolCalls)) return;
|
|
722
|
+
for (const call of toolCalls) yield* this.consumeOpenAiTool(call);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private *consumeOpenAiTool(call: Record<string, unknown>): Iterable<AiEvent> {
|
|
726
|
+
const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
|
|
727
|
+
const idFromCall = typeof call.id === "string" ? call.id : "";
|
|
728
|
+
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
729
|
+
const nameFromCall = typeof fn.name === "string" ? fn.name : "";
|
|
730
|
+
const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
731
|
+
const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
|
|
732
|
+
if (idFromCall) existing.id = idFromCall;
|
|
733
|
+
if (nameFromCall) existing.name = nameFromCall;
|
|
734
|
+
existing.args += argsFragment;
|
|
735
|
+
this.toolBuffers.set(index, existing);
|
|
736
|
+
if (!existing.name || !existing.args) return;
|
|
737
|
+
try {
|
|
738
|
+
const parsed = JSON.parse(existing.args) as unknown;
|
|
739
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
739
740
|
this.toolBuffers.delete(index);
|
|
740
|
-
|
|
741
|
-
const parsed = existing.args ? JSON.parse(existing.args) : {};
|
|
742
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
743
|
-
yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed as Record<string, unknown> };
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
throw new Error();
|
|
747
|
-
} catch {
|
|
748
|
-
throw new AiParseError("AI provider returned malformed tool-call JSON");
|
|
749
|
-
}
|
|
741
|
+
yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed as Record<string, unknown> };
|
|
750
742
|
}
|
|
751
|
-
|
|
743
|
+
} catch {
|
|
744
|
+
/* args not complete yet — keep buffering */
|
|
752
745
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private updateOpenAiFinishReason(choice: Record<string, unknown>): void {
|
|
749
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) this.lastFinishReason = choice.finish_reason;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private updateOpenAiUsage(payload: Record<string, unknown>): void {
|
|
753
|
+
const usage = payload.usage as Record<string, unknown> | undefined;
|
|
754
|
+
if (!usage || typeof usage !== "object") return;
|
|
755
|
+
const promptTokens = Number(usage.prompt_tokens ?? 0);
|
|
756
|
+
const completionTokens = Number(usage.completion_tokens ?? 0);
|
|
757
|
+
const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
|
|
758
|
+
if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) this.lastUsage = { promptTokens, completionTokens, totalTokens };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private consumeAnthropicBlockStart(payload: Record<string, unknown>): void {
|
|
762
|
+
const block = (payload.content_block ?? {}) as Record<string, unknown>;
|
|
763
|
+
if (block.type !== "tool_use") return;
|
|
764
|
+
const index = String(payload.index ?? this.toolBuffers.size);
|
|
765
|
+
const id = typeof block.id === "string" ? block.id : `call_${index}`;
|
|
766
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
767
|
+
this.toolBuffers.set(index, { id, name, args: "" });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private *consumeAnthropicBlockDelta(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
771
|
+
const index = String(payload.index ?? 0);
|
|
772
|
+
const delta = (payload.delta ?? {}) as Record<string, unknown>;
|
|
773
|
+
if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) {
|
|
774
|
+
yield { type: "text_delta", text: delta.text };
|
|
764
775
|
return;
|
|
765
776
|
}
|
|
766
|
-
if (type === "
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
yield {
|
|
770
|
-
type: "done",
|
|
771
|
-
finishReason: this.lastFinishReason ?? "end_turn",
|
|
772
|
-
...(this.lastUsage ? { usage: this.lastUsage } : {}),
|
|
773
|
-
};
|
|
774
|
-
return;
|
|
777
|
+
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
778
|
+
const existing = this.toolBuffers.get(index);
|
|
779
|
+
if (existing) existing.args += delta.partial_json;
|
|
775
780
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private *consumeAnthropicBlockStop(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
784
|
+
const index = String(payload.index ?? 0);
|
|
785
|
+
const existing = this.toolBuffers.get(index);
|
|
786
|
+
if (!existing || !existing.name) return;
|
|
787
|
+
this.toolBuffers.delete(index);
|
|
788
|
+
try {
|
|
789
|
+
const parsed = existing.args ? JSON.parse(existing.args) : {};
|
|
790
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
791
|
+
yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed as Record<string, unknown> };
|
|
792
|
+
return;
|
|
783
793
|
}
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const err = (payload.error ?? {}) as Record<string, unknown>;
|
|
788
|
-
throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
|
|
794
|
+
throw new Error();
|
|
795
|
+
} catch {
|
|
796
|
+
throw new AiParseError("AI provider returned malformed tool-call JSON");
|
|
789
797
|
}
|
|
790
798
|
}
|
|
791
799
|
|
|
800
|
+
private consumeAnthropicMessageDelta(payload: Record<string, unknown>): void {
|
|
801
|
+
const delta = (payload.delta ?? {}) as Record<string, unknown>;
|
|
802
|
+
if (typeof delta.stop_reason === "string" && delta.stop_reason.length > 0) this.lastFinishReason = delta.stop_reason;
|
|
803
|
+
const usage = (payload.usage ?? {}) as Record<string, unknown>;
|
|
804
|
+
if (usage.output_tokens === undefined && usage.input_tokens === undefined) return;
|
|
805
|
+
const promptTokens = Number(usage.input_tokens ?? this.lastUsage?.promptTokens ?? 0);
|
|
806
|
+
const completionTokens = Number(usage.output_tokens ?? this.lastUsage?.completionTokens ?? 0);
|
|
807
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private *consumeAnthropicMessageStop(): Iterable<AiEvent> {
|
|
811
|
+
if (this.doneEmitted) return;
|
|
812
|
+
this.doneEmitted = true;
|
|
813
|
+
yield { type: "done", finishReason: this.lastFinishReason ?? "end_turn", ...(this.lastUsage ? { usage: this.lastUsage } : {}) };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private consumeAnthropicMessageStart(payload: Record<string, unknown>): void {
|
|
817
|
+
const message = (payload.message ?? {}) as Record<string, unknown>;
|
|
818
|
+
const usage = (message.usage ?? {}) as Record<string, unknown>;
|
|
819
|
+
if (usage.input_tokens === undefined && usage.output_tokens === undefined) return;
|
|
820
|
+
const promptTokens = Number(usage.input_tokens ?? 0);
|
|
821
|
+
const completionTokens = Number(usage.output_tokens ?? 0);
|
|
822
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private consumeAnthropicError(payload: Record<string, unknown>): void {
|
|
826
|
+
const err = (payload.error ?? {}) as Record<string, unknown>;
|
|
827
|
+
throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
|
|
828
|
+
}
|
|
829
|
+
|
|
792
830
|
private *flushRemainingToolCalls(): Iterable<AiEvent> {
|
|
793
831
|
for (const [index, buffered] of this.toolBuffers) {
|
|
794
832
|
if (buffered.name && buffered.args) {
|
|
@@ -206,6 +206,69 @@ function etagMatchesInm(ifNoneMatch: string, etag: string): boolean {
|
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
function maybeCompressResponse(body: Buffer, rawReq: IncomingMessage, rawRes: ServerResponse): Buffer {
|
|
210
|
+
const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
|
|
211
|
+
const contentTypeHeader = rawRes.getHeader("content-type");
|
|
212
|
+
const contentType = typeof contentTypeHeader === "string" ? contentTypeHeader : "";
|
|
213
|
+
const alreadyEncoded = !!rawRes.getHeader("content-encoding");
|
|
214
|
+
if (alreadyEncoded || body.length <= 1024 || !acceptEncoding.includes("gzip") || !isCompressibleContentType(contentType)) {
|
|
215
|
+
return body;
|
|
216
|
+
}
|
|
217
|
+
const compressed = gzipSync(body, { level: 6 });
|
|
218
|
+
rawRes.setHeader("Content-Encoding", "gzip");
|
|
219
|
+
rawRes.setHeader("Vary", "Accept-Encoding");
|
|
220
|
+
return compressed;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function responseIsNotModified(rawReq: IncomingMessage, rawRes: ServerResponse, etag: string): boolean {
|
|
224
|
+
const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
|
|
225
|
+
if (ifNoneMatch) return etagMatchesInm(ifNoneMatch, etag);
|
|
226
|
+
const lastModifiedHeader = rawRes.getHeader("last-modified");
|
|
227
|
+
if (typeof lastModifiedHeader !== "string") return false;
|
|
228
|
+
const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");
|
|
229
|
+
if (!ifModifiedSince) return false;
|
|
230
|
+
const modified = Date.parse(lastModifiedHeader);
|
|
231
|
+
const since = Date.parse(ifModifiedSince);
|
|
232
|
+
return !Number.isNaN(modified) && !Number.isNaN(since) && modified <= since;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function clearNotModifiedHeaders(rawRes: ServerResponse): void {
|
|
236
|
+
rawRes.statusCode = 304;
|
|
237
|
+
rawRes.removeHeader("Content-Type");
|
|
238
|
+
rawRes.removeHeader("Content-Encoding");
|
|
239
|
+
rawRes.removeHeader("Vary");
|
|
240
|
+
rawRes.removeHeader("Content-Length");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function applyResponseValidator(rawReq: IncomingMessage, rawRes: ServerResponse, body: Buffer): boolean {
|
|
244
|
+
if ((rawRes.statusCode || 200) !== 200 || body.length === 0) return false;
|
|
245
|
+
let etag = rawRes.getHeader("etag");
|
|
246
|
+
if (!etag) {
|
|
247
|
+
etag = `"${createHash("md5").update(body).digest("hex").slice(0, 16)}"`;
|
|
248
|
+
rawRes.setHeader("ETag", etag);
|
|
249
|
+
}
|
|
250
|
+
if (!responseIsNotModified(rawReq, rawRes, String(etag))) return false;
|
|
251
|
+
clearNotModifiedHeaders(rawRes);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function endBufferedResponse(
|
|
256
|
+
rawReq: IncomingMessage,
|
|
257
|
+
rawRes: ServerResponse,
|
|
258
|
+
body: Buffer,
|
|
259
|
+
realCb: ((...args: any[]) => void) | undefined,
|
|
260
|
+
origEnd: (...args: any[]) => any,
|
|
261
|
+
): any {
|
|
262
|
+
const finalBody = maybeCompressResponse(body, rawReq, rawRes);
|
|
263
|
+
if (applyResponseValidator(rawReq, rawRes, finalBody)) {
|
|
264
|
+
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
265
|
+
}
|
|
266
|
+
if (!rawRes.headersSent && (rawRes.statusCode || 200) !== 304) {
|
|
267
|
+
rawRes.setHeader("Content-Length", finalBody.length);
|
|
268
|
+
}
|
|
269
|
+
return typeof realCb === "function" ? origEnd(finalBody, realCb) : origEnd(finalBody);
|
|
270
|
+
}
|
|
271
|
+
|
|
209
272
|
/**
|
|
210
273
|
* Gzip-compress + attach an ETag, and answer a matching conditional GET with
|
|
211
274
|
* a 304 that PRESERVES whichever validators the 200 would have carried
|
|
@@ -276,74 +339,9 @@ export function compressionEtagIntercept(rawReq: IncomingMessage, rawRes: Server
|
|
|
276
339
|
|
|
277
340
|
const buf = toBuffer(chunk, typeof encodingOrCb === "string" ? encodingOrCb : undefined);
|
|
278
341
|
if (buf) chunks.push(buf);
|
|
279
|
-
let body = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
280
342
|
const realCb = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
// Compression: body > 1024 bytes AND Accept-Encoding offers gzip AND the
|
|
284
|
-
// content type is compressible. Applies to ANY response (matches every
|
|
285
|
-
// route, not status-gated), same as the Python master.
|
|
286
|
-
//
|
|
287
|
-
// REAL BUG (found 2026-08-13, tina4cssServed.test.ts): a static-file
|
|
288
|
-
// response (static.ts) already gzips itself and sets Content-Encoding
|
|
289
|
-
// before calling res.raw.end() — but that end() is THIS intercepted one,
|
|
290
|
-
// so without the guard below it gzipped an already-gzipped body a second
|
|
291
|
-
// time. The client's one layer of automatic decompression then handed
|
|
292
|
-
// back a still-gzipped blob instead of the real bytes. Skip compression
|
|
293
|
-
// here whenever an earlier stage already set Content-Encoding.
|
|
294
|
-
const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
|
|
295
|
-
const contentTypeHeader = rawRes.getHeader("content-type");
|
|
296
|
-
const contentType = typeof contentTypeHeader === "string" ? contentTypeHeader : "";
|
|
297
|
-
const alreadyEncoded = !!rawRes.getHeader("content-encoding");
|
|
298
|
-
if (!alreadyEncoded && body.length > 1024 && acceptEncoding.includes("gzip") && isCompressibleContentType(contentType)) {
|
|
299
|
-
body = gzipSync(body, { level: 6 });
|
|
300
|
-
rawRes.setHeader("Content-Encoding", "gzip");
|
|
301
|
-
rawRes.setHeader("Vary", "Accept-Encoding");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (statusCode === 200 && body.length > 0) {
|
|
305
|
-
// ETag: a strong md5 hash (first 16 hex chars) over the FINAL
|
|
306
|
-
// (post-compression) body, UNLESS a validator is already set — a
|
|
307
|
-
// static-file response (static.ts) pins its own weak size+mtime ETag
|
|
308
|
-
// before this ever runs (CE-DEC-02), so this never overwrites it with
|
|
309
|
-
// a content hash.
|
|
310
|
-
let etag = rawRes.getHeader("etag");
|
|
311
|
-
if (!etag) {
|
|
312
|
-
etag = `"${createHash("md5").update(body).digest("hex").slice(0, 16)}"`;
|
|
313
|
-
rawRes.setHeader("ETag", etag);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Conditional GET -> 304, preserving whichever validators are set.
|
|
317
|
-
// If-None-Match takes precedence over If-Modified-Since (RFC 9110 S13.1.3).
|
|
318
|
-
const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
|
|
319
|
-
const lastModifiedHeader = rawRes.getHeader("last-modified");
|
|
320
|
-
const lastModified = typeof lastModifiedHeader === "string" ? lastModifiedHeader : "";
|
|
321
|
-
let notModified = false;
|
|
322
|
-
if (ifNoneMatch) {
|
|
323
|
-
notModified = etagMatchesInm(ifNoneMatch, String(etag));
|
|
324
|
-
} else if (lastModified) {
|
|
325
|
-
const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");
|
|
326
|
-
if (ifModifiedSince) {
|
|
327
|
-
const modified = Date.parse(lastModified);
|
|
328
|
-
const since = Date.parse(ifModifiedSince);
|
|
329
|
-
notModified = !Number.isNaN(modified) && !Number.isNaN(since) && modified <= since;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (notModified) {
|
|
334
|
-
rawRes.statusCode = 304;
|
|
335
|
-
rawRes.removeHeader("Content-Type");
|
|
336
|
-
rawRes.removeHeader("Content-Encoding");
|
|
337
|
-
rawRes.removeHeader("Vary");
|
|
338
|
-
rawRes.removeHeader("Content-Length");
|
|
339
|
-
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (!rawRes.headersSent && statusCode !== 304) {
|
|
344
|
-
rawRes.setHeader("Content-Length", body.length);
|
|
345
|
-
}
|
|
346
|
-
return typeof realCb === "function" ? origEnd(body, realCb) : origEnd(body);
|
|
343
|
+
const body = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
344
|
+
return endBufferedResponse(rawReq, rawRes, body, realCb, origEnd);
|
|
347
345
|
}) as typeof rawRes.end;
|
|
348
346
|
}
|
|
349
347
|
|