@copilotkit/aimock 1.42.1 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +65 -0
- package/README.md +108 -16
- package/dist/agui-handler.d.ts.map +1 -1
- package/dist/agui-mock.cjs +1 -1
- package/dist/agui-mock.cjs.map +1 -1
- package/dist/agui-mock.d.cts.map +1 -1
- package/dist/agui-mock.d.ts.map +1 -1
- package/dist/agui-mock.js +1 -1
- package/dist/agui-mock.js.map +1 -1
- package/dist/agui-recorder.cjs +33 -11
- package/dist/agui-recorder.cjs.map +1 -1
- package/dist/agui-recorder.d.cts +10 -1
- package/dist/agui-recorder.d.cts.map +1 -1
- package/dist/agui-recorder.d.ts +10 -1
- package/dist/agui-recorder.d.ts.map +1 -1
- package/dist/agui-recorder.js +33 -11
- package/dist/agui-recorder.js.map +1 -1
- package/dist/agui-types.d.cts +1 -0
- package/dist/agui-types.d.cts.map +1 -1
- package/dist/agui-types.d.ts +1 -0
- package/dist/agui-types.d.ts.map +1 -1
- package/dist/aimock-cli.cjs +38 -10
- package/dist/aimock-cli.cjs.map +1 -1
- package/dist/aimock-cli.d.cts +5 -0
- package/dist/aimock-cli.d.cts.map +1 -1
- package/dist/aimock-cli.d.ts +5 -0
- package/dist/aimock-cli.d.ts.map +1 -1
- package/dist/aimock-cli.js +38 -10
- package/dist/aimock-cli.js.map +1 -1
- package/dist/batches.cjs +458 -0
- package/dist/batches.cjs.map +1 -0
- package/dist/batches.d.cts +57 -0
- package/dist/batches.d.cts.map +1 -0
- package/dist/batches.d.ts +57 -0
- package/dist/batches.d.ts.map +1 -0
- package/dist/batches.js +453 -0
- package/dist/batches.js.map +1 -0
- package/dist/bedrock-converse.cjs +44 -12
- package/dist/bedrock-converse.cjs.map +1 -1
- package/dist/bedrock-converse.d.cts.map +1 -1
- package/dist/bedrock-converse.d.ts.map +1 -1
- package/dist/bedrock-converse.js +46 -14
- package/dist/bedrock-converse.js.map +1 -1
- package/dist/bedrock.cjs +10 -8
- package/dist/bedrock.cjs.map +1 -1
- package/dist/bedrock.d.cts.map +1 -1
- package/dist/bedrock.d.ts.map +1 -1
- package/dist/bedrock.js +12 -10
- package/dist/bedrock.js.map +1 -1
- package/dist/byteplus-video.cjs +3 -3
- package/dist/byteplus-video.cjs.map +1 -1
- package/dist/byteplus-video.js +4 -4
- package/dist/byteplus-video.js.map +1 -1
- package/dist/chaos.cjs +625 -72
- package/dist/chaos.cjs.map +1 -1
- package/dist/chaos.d.cts +164 -7
- package/dist/chaos.d.cts.map +1 -1
- package/dist/chaos.d.ts +164 -7
- package/dist/chaos.d.ts.map +1 -1
- package/dist/chaos.js +616 -74
- package/dist/chaos.js.map +1 -1
- package/dist/cli.cjs +131 -30
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +132 -31
- package/dist/cli.js.map +1 -1
- package/dist/cohere.cjs +37 -9
- package/dist/cohere.cjs.map +1 -1
- package/dist/cohere.d.cts.map +1 -1
- package/dist/cohere.d.ts.map +1 -1
- package/dist/cohere.js +39 -11
- package/dist/cohere.js.map +1 -1
- package/dist/config-loader.cjs +1 -1
- package/dist/config-loader.js +1 -1
- package/dist/constants.cjs +8 -0
- package/dist/constants.cjs.map +1 -1
- package/dist/constants.d.cts +6 -2
- package/dist/constants.d.ts +6 -2
- package/dist/constants.js +8 -1
- package/dist/constants.js.map +1 -1
- package/dist/convert.cjs +1 -1
- package/dist/convert.cjs.map +1 -1
- package/dist/convert.js +1 -1
- package/dist/convert.js.map +1 -1
- package/dist/echo-text.cjs +65 -0
- package/dist/echo-text.cjs.map +1 -0
- package/dist/echo-text.d.cts +22 -0
- package/dist/echo-text.d.cts.map +1 -0
- package/dist/echo-text.d.ts +22 -0
- package/dist/echo-text.d.ts.map +1 -0
- package/dist/echo-text.js +65 -0
- package/dist/echo-text.js.map +1 -0
- package/dist/elevenlabs-audio.cjs +3 -3
- package/dist/elevenlabs-audio.cjs.map +1 -1
- package/dist/elevenlabs-audio.js +4 -4
- package/dist/elevenlabs-audio.js.map +1 -1
- package/dist/elevenlabs-voice.cjs +1053 -0
- package/dist/elevenlabs-voice.cjs.map +1 -0
- package/dist/elevenlabs-voice.d.cts +72 -0
- package/dist/elevenlabs-voice.d.cts.map +1 -0
- package/dist/elevenlabs-voice.d.ts +72 -0
- package/dist/elevenlabs-voice.d.ts.map +1 -0
- package/dist/elevenlabs-voice.js +1048 -0
- package/dist/elevenlabs-voice.js.map +1 -0
- package/dist/embeddings.cjs +26 -5
- package/dist/embeddings.cjs.map +1 -1
- package/dist/embeddings.d.cts +1 -1
- package/dist/embeddings.d.cts.map +1 -1
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/embeddings.js +27 -6
- package/dist/embeddings.js.map +1 -1
- package/dist/fal-audio.cjs +3 -3
- package/dist/fal-audio.cjs.map +1 -1
- package/dist/fal-audio.js +4 -4
- package/dist/fal-audio.js.map +1 -1
- package/dist/fal.cjs +28 -4
- package/dist/fal.cjs.map +1 -1
- package/dist/fal.d.cts +16 -1
- package/dist/fal.d.cts.map +1 -1
- package/dist/fal.d.ts +16 -1
- package/dist/fal.d.ts.map +1 -1
- package/dist/fal.js +28 -5
- package/dist/fal.js.map +1 -1
- package/dist/files.cjs +1069 -0
- package/dist/files.cjs.map +1 -0
- package/dist/files.d.cts +68 -0
- package/dist/files.d.cts.map +1 -0
- package/dist/files.d.ts +68 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +1056 -0
- package/dist/files.js.map +1 -0
- package/dist/fine-tuning.cjs +1033 -0
- package/dist/fine-tuning.cjs.map +1 -0
- package/dist/fine-tuning.d.cts +96 -0
- package/dist/fine-tuning.d.cts.map +1 -0
- package/dist/fine-tuning.d.ts +96 -0
- package/dist/fine-tuning.d.ts.map +1 -0
- package/dist/fine-tuning.js +1026 -0
- package/dist/fine-tuning.js.map +1 -0
- package/dist/fixture-loader-services.cjs +116 -0
- package/dist/fixture-loader-services.cjs.map +1 -0
- package/dist/fixture-loader-services.d.cts +48 -0
- package/dist/fixture-loader-services.d.cts.map +1 -0
- package/dist/fixture-loader-services.d.ts +48 -0
- package/dist/fixture-loader-services.d.ts.map +1 -0
- package/dist/fixture-loader-services.js +114 -0
- package/dist/fixture-loader-services.js.map +1 -0
- package/dist/fixture-loader.cjs +0 -0
- package/dist/fixture-loader.cjs.map +1 -1
- package/dist/fixture-loader.d.cts +131 -5
- package/dist/fixture-loader.d.cts.map +1 -1
- package/dist/fixture-loader.d.ts +131 -5
- package/dist/fixture-loader.d.ts.map +1 -1
- package/dist/fixture-loader.js +0 -0
- package/dist/fixture-loader.js.map +1 -1
- package/dist/gemini-embeddings.cjs +44 -4
- package/dist/gemini-embeddings.cjs.map +1 -1
- package/dist/gemini-embeddings.js +46 -6
- package/dist/gemini-embeddings.js.map +1 -1
- package/dist/gemini-interactions.cjs +51 -7
- package/dist/gemini-interactions.cjs.map +1 -1
- package/dist/gemini-interactions.d.cts.map +1 -1
- package/dist/gemini-interactions.d.ts.map +1 -1
- package/dist/gemini-interactions.js +53 -9
- package/dist/gemini-interactions.js.map +1 -1
- package/dist/gemini.cjs +19 -8
- package/dist/gemini.cjs.map +1 -1
- package/dist/gemini.d.cts.map +1 -1
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +21 -10
- package/dist/gemini.js.map +1 -1
- package/dist/grok-video.cjs +14 -14
- package/dist/grok-video.cjs.map +1 -1
- package/dist/grok-video.js +15 -15
- package/dist/grok-video.js.map +1 -1
- package/dist/helpers.cjs +497 -10
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +38 -2
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts +38 -2
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +487 -12
- package/dist/helpers.js.map +1 -1
- package/dist/images.cjs +57 -125
- package/dist/images.cjs.map +1 -1
- package/dist/images.d.cts.map +1 -1
- package/dist/images.d.ts.map +1 -1
- package/dist/images.js +58 -126
- package/dist/images.js.map +1 -1
- package/dist/index.cjs +71 -9
- package/dist/index.d.cts +17 -7
- package/dist/index.d.ts +17 -7
- package/dist/index.js +21 -11
- package/dist/jest.cjs +24 -12
- package/dist/jest.cjs.map +1 -1
- package/dist/jest.d.cts +6 -3
- package/dist/jest.d.cts.map +1 -1
- package/dist/jest.d.ts +6 -3
- package/dist/jest.d.ts.map +1 -1
- package/dist/jest.js +25 -13
- package/dist/jest.js.map +1 -1
- package/dist/journal.cjs +61 -4
- package/dist/journal.cjs.map +1 -1
- package/dist/journal.d.cts +60 -3
- package/dist/journal.d.cts.map +1 -1
- package/dist/journal.d.ts +60 -3
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +61 -5
- package/dist/journal.js.map +1 -1
- package/dist/live-fixture.cjs +1192 -0
- package/dist/live-fixture.cjs.map +1 -0
- package/dist/live-fixture.js +1187 -0
- package/dist/live-fixture.js.map +1 -0
- package/dist/live-recorder.cjs +256 -0
- package/dist/live-recorder.cjs.map +1 -0
- package/dist/live-recorder.js +254 -0
- package/dist/live-recorder.js.map +1 -0
- package/dist/live-replay.cjs +286 -0
- package/dist/live-replay.cjs.map +1 -0
- package/dist/live-replay.js +285 -0
- package/dist/live-replay.js.map +1 -0
- package/dist/live-sanitize.cjs +196 -0
- package/dist/live-sanitize.cjs.map +1 -0
- package/dist/live-sanitize.js +196 -0
- package/dist/live-sanitize.js.map +1 -0
- package/dist/live-types.d.cts +71 -0
- package/dist/live-types.d.cts.map +1 -0
- package/dist/live-types.d.ts +71 -0
- package/dist/live-types.d.ts.map +1 -0
- package/dist/llmock.cjs +132 -49
- package/dist/llmock.cjs.map +1 -1
- package/dist/llmock.d.cts +51 -5
- package/dist/llmock.d.cts.map +1 -1
- package/dist/llmock.d.ts +51 -5
- package/dist/llmock.d.ts.map +1 -1
- package/dist/llmock.js +133 -50
- package/dist/llmock.js.map +1 -1
- package/dist/mcp-fakes-mount.cjs +216 -0
- package/dist/mcp-fakes-mount.cjs.map +1 -0
- package/dist/mcp-fakes-mount.js +210 -0
- package/dist/mcp-fakes-mount.js.map +1 -0
- package/dist/mcp-fakes.cjs +1619 -0
- package/dist/mcp-fakes.cjs.map +1 -0
- package/dist/mcp-fakes.d.cts +101 -0
- package/dist/mcp-fakes.d.cts.map +1 -0
- package/dist/mcp-fakes.d.ts +101 -0
- package/dist/mcp-fakes.d.ts.map +1 -0
- package/dist/mcp-fakes.js +1614 -0
- package/dist/mcp-fakes.js.map +1 -0
- package/dist/mcp-handler.cjs +331 -17
- package/dist/mcp-handler.cjs.map +1 -1
- package/dist/mcp-handler.js +330 -18
- package/dist/mcp-handler.js.map +1 -1
- package/dist/mcp-mock.cjs +217 -49
- package/dist/mcp-mock.cjs.map +1 -1
- package/dist/mcp-mock.d.cts +66 -1
- package/dist/mcp-mock.d.cts.map +1 -1
- package/dist/mcp-mock.d.ts +66 -1
- package/dist/mcp-mock.d.ts.map +1 -1
- package/dist/mcp-mock.js +218 -50
- package/dist/mcp-mock.js.map +1 -1
- package/dist/mcp-stub.cjs +5 -1
- package/dist/mcp-stub.d.cts +3 -1
- package/dist/mcp-stub.d.ts +3 -1
- package/dist/mcp-stub.js +3 -1
- package/dist/mcp-types.d.cts +6 -0
- package/dist/mcp-types.d.ts +6 -0
- package/dist/message-text.cjs +192 -0
- package/dist/message-text.cjs.map +1 -0
- package/dist/message-text.d.cts +31 -0
- package/dist/message-text.d.cts.map +1 -0
- package/dist/message-text.d.ts +31 -0
- package/dist/message-text.d.ts.map +1 -0
- package/dist/message-text.js +184 -0
- package/dist/message-text.js.map +1 -0
- package/dist/messages.cjs +49 -7
- package/dist/messages.cjs.map +1 -1
- package/dist/messages.d.cts.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +51 -9
- package/dist/messages.js.map +1 -1
- package/dist/metrics.cjs +278 -45
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.d.cts +17 -7
- package/dist/metrics.d.cts.map +1 -1
- package/dist/metrics.d.ts +17 -7
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +270 -29
- package/dist/metrics.js.map +1 -1
- package/dist/moderation.cjs +10 -1
- package/dist/moderation.cjs.map +1 -1
- package/dist/moderation.d.cts.map +1 -1
- package/dist/moderation.d.ts.map +1 -1
- package/dist/moderation.js +10 -1
- package/dist/moderation.js.map +1 -1
- package/dist/ollama.cjs +10 -6
- package/dist/ollama.cjs.map +1 -1
- package/dist/ollama.d.cts.map +1 -1
- package/dist/ollama.d.ts.map +1 -1
- package/dist/ollama.js +11 -7
- package/dist/ollama.js.map +1 -1
- package/dist/openapi.cjs +3405 -0
- package/dist/openapi.cjs.map +1 -0
- package/dist/openapi.d.cts +30 -0
- package/dist/openapi.d.cts.map +1 -0
- package/dist/openapi.d.ts +30 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +3404 -0
- package/dist/openapi.js.map +1 -0
- package/dist/openrouter-video.cjs +9 -9
- package/dist/openrouter-video.cjs.map +1 -1
- package/dist/openrouter-video.d.cts.map +1 -1
- package/dist/openrouter-video.d.ts.map +1 -1
- package/dist/openrouter-video.js +10 -10
- package/dist/openrouter-video.js.map +1 -1
- package/dist/recorder.cjs +54 -10
- package/dist/recorder.cjs.map +1 -1
- package/dist/recorder.d.cts.map +1 -1
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +52 -12
- package/dist/recorder.js.map +1 -1
- package/dist/responses.cjs +47 -7
- package/dist/responses.cjs.map +1 -1
- package/dist/responses.d.cts.map +1 -1
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +48 -9
- package/dist/responses.js.map +1 -1
- package/dist/route-registry.cjs +951 -0
- package/dist/route-registry.cjs.map +1 -0
- package/dist/route-registry.d.cts +33 -0
- package/dist/route-registry.d.cts.map +1 -0
- package/dist/route-registry.d.ts +33 -0
- package/dist/route-registry.d.ts.map +1 -0
- package/dist/route-registry.js +866 -0
- package/dist/route-registry.js.map +1 -0
- package/dist/router.cjs +21 -34
- package/dist/router.cjs.map +1 -1
- package/dist/router.d.cts +4 -1
- package/dist/router.d.cts.map +1 -1
- package/dist/router.d.ts +4 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +21 -35
- package/dist/router.js.map +1 -1
- package/dist/server.cjs +1252 -777
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +5 -1
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1185 -710
- package/dist/server.js.map +1 -1
- package/dist/speech.cjs +6 -4
- package/dist/speech.cjs.map +1 -1
- package/dist/speech.js +7 -5
- package/dist/speech.js.map +1 -1
- package/dist/suite.cjs +1 -1
- package/dist/suite.js +1 -1
- package/dist/transcription.cjs +6 -6
- package/dist/transcription.cjs.map +1 -1
- package/dist/transcription.d.cts.map +1 -1
- package/dist/transcription.d.ts.map +1 -1
- package/dist/transcription.js +7 -7
- package/dist/transcription.js.map +1 -1
- package/dist/types.cjs +18 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +320 -17
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +320 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-cli.cjs +983 -0
- package/dist/validate-cli.cjs.map +1 -0
- package/dist/validate-cli.js +982 -0
- package/dist/validate-cli.js.map +1 -0
- package/dist/vector-stores.cjs +1180 -0
- package/dist/vector-stores.cjs.map +1 -0
- package/dist/vector-stores.d.cts +106 -0
- package/dist/vector-stores.d.cts.map +1 -0
- package/dist/vector-stores.d.ts +106 -0
- package/dist/vector-stores.d.ts.map +1 -0
- package/dist/vector-stores.js +1164 -0
- package/dist/vector-stores.js.map +1 -0
- package/dist/vector-types.d.cts.map +1 -1
- package/dist/vector-types.d.ts.map +1 -1
- package/dist/veo-video.cjs +13 -13
- package/dist/veo-video.cjs.map +1 -1
- package/dist/veo-video.js +14 -14
- package/dist/veo-video.js.map +1 -1
- package/dist/video.cjs +23 -4
- package/dist/video.cjs.map +1 -1
- package/dist/video.d.cts +1 -1
- package/dist/video.d.cts.map +1 -1
- package/dist/video.d.ts +1 -1
- package/dist/video.d.ts.map +1 -1
- package/dist/video.js +24 -5
- package/dist/video.js.map +1 -1
- package/dist/vitest.cjs +23 -11
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +5 -2
- package/dist/vitest.d.cts.map +1 -1
- package/dist/vitest.d.ts +5 -2
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +25 -13
- package/dist/vitest.js.map +1 -1
- package/dist/watcher.cjs +2 -1
- package/dist/watcher.cjs.map +1 -1
- package/dist/watcher.js +2 -1
- package/dist/watcher.js.map +1 -1
- package/dist/ws-framing.cjs +150 -35
- package/dist/ws-framing.cjs.map +1 -1
- package/dist/ws-framing.d.cts +18 -3
- package/dist/ws-framing.d.cts.map +1 -1
- package/dist/ws-framing.d.ts +18 -3
- package/dist/ws-framing.d.ts.map +1 -1
- package/dist/ws-framing.js +151 -36
- package/dist/ws-framing.js.map +1 -1
- package/dist/ws-gemini-live.cjs +2 -2
- package/dist/ws-gemini-live.js +2 -2
- package/dist/ws-live.cjs +401 -0
- package/dist/ws-live.cjs.map +1 -0
- package/dist/ws-live.js +400 -0
- package/dist/ws-live.js.map +1 -0
- package/dist/ws-realtime.cjs +46 -2
- package/dist/ws-realtime.cjs.map +1 -1
- package/dist/ws-realtime.d.cts.map +1 -1
- package/dist/ws-realtime.d.ts.map +1 -1
- package/dist/ws-realtime.js +46 -2
- package/dist/ws-realtime.js.map +1 -1
- package/dist/ws-responses.cjs +16 -4
- package/dist/ws-responses.cjs.map +1 -1
- package/dist/ws-responses.js +16 -4
- package/dist/ws-responses.js.map +1 -1
- package/dist/ws-upstream.cjs +118 -0
- package/dist/ws-upstream.cjs.map +1 -0
- package/dist/ws-upstream.js +115 -0
- package/dist/ws-upstream.js.map +1 -0
- package/fixtures/openai-live-client.json +390 -0
- package/fixtures/openai-live-managed.json +709 -0
- package/package.json +4 -1
package/dist/files.cjs
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
const require_runtime = require('./_virtual/_rolldown/runtime.cjs');
|
|
2
|
+
const require_helpers = require('./helpers.cjs');
|
|
3
|
+
const require_chaos = require('./chaos.cjs');
|
|
4
|
+
const require_vector_stores = require('./vector-stores.cjs');
|
|
5
|
+
let node_crypto = require("node:crypto");
|
|
6
|
+
|
|
7
|
+
//#region src/files.ts
|
|
8
|
+
/**
|
|
9
|
+
* OpenAI Files API mock for aimock.
|
|
10
|
+
*
|
|
11
|
+
* Implements the subset of `POST /v1/files`, `GET /v1/files`,
|
|
12
|
+
* `GET /v1/files/{id}`, `GET /v1/files/{id}/content` and
|
|
13
|
+
* `DELETE /v1/files/{id}` that test harnesses need to exercise
|
|
14
|
+
* file-upload flows without touching the real API.
|
|
15
|
+
*
|
|
16
|
+
* Storage is in-memory and per-process (like the video job maps):
|
|
17
|
+
* - metadata lives in `fileStore`
|
|
18
|
+
* - raw bytes live in `fileContents` as `Buffer`s. Bytes are never
|
|
19
|
+
* round-tripped through a utf8 string, so binary uploads come back
|
|
20
|
+
* byte-for-byte identical.
|
|
21
|
+
* - `clearFileStore()` wipes both and is wired into the full reset path
|
|
22
|
+
* (`POST /__aimock/reset` + `LLMock.reset()`)
|
|
23
|
+
*
|
|
24
|
+
* The only memory bound here is per file: {@link FILES_MAX_BYTES} caps one
|
|
25
|
+
* upload. The file COUNT is deliberately unbounded, so total residency grows
|
|
26
|
+
* with the number of uploads until something calls `clearFileStore()` (or the
|
|
27
|
+
* process exits) — a suite that uploads in a loop without resetting will grow
|
|
28
|
+
* this map without limit. That is a real, documented bound, not an OOM defense.
|
|
29
|
+
*
|
|
30
|
+
* The job maps this module otherwise mirrors (fal-audio.ts,
|
|
31
|
+
* openrouter-video.ts, grok-video.ts, byteplus-video.ts) DO cap entry count and
|
|
32
|
+
* FIFO-evict the oldest, and that convention deliberately does NOT apply here.
|
|
33
|
+
* Those hold transient jobs that the real API also expires, so eviction
|
|
34
|
+
* reproduces real behaviour. A file is a durable object: the real API keeps it
|
|
35
|
+
* until the caller deletes it. Silently evicting one would make the mock answer
|
|
36
|
+
* `404 No such file` for a file the caller successfully uploaded and never
|
|
37
|
+
* deleted — an outcome the real API cannot produce, surfacing as an
|
|
38
|
+
* unexplainable flake in the suite under test. Unbounded growth is the honest
|
|
39
|
+
* failure mode; `POST /__aimock/reset` is the lever that reclaims it.
|
|
40
|
+
*
|
|
41
|
+
* Body handling accepts both shapes real SDKs send:
|
|
42
|
+
* - JSON `{ filename, purpose, content }` (test-friendly; `bytes` is an alias
|
|
43
|
+
* of `content`, and exactly one of the two must be present)
|
|
44
|
+
* - `multipart/form-data` with `purpose` + exactly one payload part named
|
|
45
|
+
* `file` (or its `content` alias), framed per RFC 2046 §5.1.1 so a payload
|
|
46
|
+
* containing the boundary token is stored whole rather than truncated at it
|
|
47
|
+
*
|
|
48
|
+
* Both parsers are strict: a malformed body is an OpenAI-shaped
|
|
49
|
+
* `400 invalid_request_error` naming the offending field or part. Nothing is
|
|
50
|
+
* `String()`-coerced and no missing field falls through to a fabricated
|
|
51
|
+
* default, because a mock that quietly stores `"[object Object]"` or a 0-byte
|
|
52
|
+
* file teaches the suite under test that a broken upload succeeded.
|
|
53
|
+
*
|
|
54
|
+
* Every branch journals with `service: "files"` so
|
|
55
|
+
* `GET /__aimock/journal?service=files` selects exactly this traffic, and with
|
|
56
|
+
* `source: "internal"` because — as with fine-tuning — no fixture or proxy
|
|
57
|
+
* ever serves this store. A successful upload journals a SYNTHETIC body
|
|
58
|
+
* describing it (`{ purpose, filename, bytes, content_type, sha256 }`) — never
|
|
59
|
+
* the uploaded octets — exactly as the transcription route journals its
|
|
60
|
+
* multipart audio; a `400` journals the error envelope it answered with, so
|
|
61
|
+
* the reason is readable from the journal; see {@link journalFiles}. Files also
|
|
62
|
+
* honors the inbound API-key boundary via the server dispatch (no bypass),
|
|
63
|
+
* and runs through the chaos gate so retry/backoff suites can inject
|
|
64
|
+
* 500s at the files surface too.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Purposes accepted on upload — `CreateFileRequest.purpose` in the official
|
|
68
|
+
* `openai/openai-openapi` spec (and the `FilePurpose` type in the SDK).
|
|
69
|
+
* `user_data` is what Responses-API file inputs use, so rejecting it broke the
|
|
70
|
+
* most current flow.
|
|
71
|
+
*/
|
|
72
|
+
const CREATE_PURPOSES = new Set([
|
|
73
|
+
"assistants",
|
|
74
|
+
"batch",
|
|
75
|
+
"fine-tune",
|
|
76
|
+
"vision",
|
|
77
|
+
"user_data",
|
|
78
|
+
"evals"
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* Exposed for tests and for callers that want to check a value themselves:
|
|
82
|
+
* the enum this mock enforces on upload.
|
|
83
|
+
*/
|
|
84
|
+
const FILE_CREATE_PURPOSES = [...CREATE_PURPOSES];
|
|
85
|
+
function invalidPurpose(purpose) {
|
|
86
|
+
return `Invalid purpose '${purpose}'. Expected one of: ${[...CREATE_PURPOSES].join(", ")}`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `Content-Transfer-Encoding` values that are identity transforms (RFC 2045
|
|
90
|
+
* §6.2): the part body already *is* the file's octets, so storing it verbatim
|
|
91
|
+
* is correct. Everything else (`base64`, `quoted-printable`, …) would have to
|
|
92
|
+
* be decoded before storage, and is rejected instead — see
|
|
93
|
+
* {@link unsupportedPartEncoding}.
|
|
94
|
+
*/
|
|
95
|
+
const IDENTITY_TRANSFER_ENCODINGS = new Set([
|
|
96
|
+
"7bit",
|
|
97
|
+
"8bit",
|
|
98
|
+
"binary",
|
|
99
|
+
"identity"
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* Reject, rather than silently mis-store, a file part that asks for a content
|
|
103
|
+
* encoding this mock does not apply.
|
|
104
|
+
*
|
|
105
|
+
* We deliberately do NOT implement a decoder. RFC 7578 §4.7 deprecates
|
|
106
|
+
* `Content-Transfer-Encoding` in `multipart/form-data` ("Senders SHOULD NOT
|
|
107
|
+
* generate any parts with a Content-Transfer-Encoding header field. Currently,
|
|
108
|
+
* no deployed implementations that send such bodies have been discovered"), and
|
|
109
|
+
* a capture of the clients that actually reach this endpoint — curl `-F`,
|
|
110
|
+
* python `requests`, python `httpx` (the OpenAI Python SDK's transport) and
|
|
111
|
+
* undici `FormData` (the OpenAI Node SDK's transport) — confirms none of them
|
|
112
|
+
* emits one. Storing an encoded body as-is is the harmful outcome: `bytes` and
|
|
113
|
+
* `GET /content` would report the *encoded* form and a hash comparison in a
|
|
114
|
+
* test would fail for a reason the caller cannot see. A loud 400 naming the
|
|
115
|
+
* header is the honest answer for a mock.
|
|
116
|
+
*
|
|
117
|
+
* `identity` counts as a no-op alongside the RFC 2045 §6.2 values: it is the
|
|
118
|
+
* registered `Content-Encoding` for "no transformation", and a client that
|
|
119
|
+
* spells it on `Content-Transfer-Encoding` means the same thing. Rejecting it
|
|
120
|
+
* named a problem that did not exist.
|
|
121
|
+
*
|
|
122
|
+
* Every part is checked, not only the payload part, and the message names the
|
|
123
|
+
* part it came from. While only the payload part was checked, a `base64`
|
|
124
|
+
* `purpose` part slid through to the enum check and 400'd with
|
|
125
|
+
* `Invalid purpose 'YXNzaXN0YW50cw=='`, pointing the caller at the wrong thing.
|
|
126
|
+
*/
|
|
127
|
+
function unsupportedPartEncoding(header, value, partName) {
|
|
128
|
+
return `Unsupported ${header}: '${value}' on the multipart '${partName}' part. aimock stores part bodies verbatim and does not decode them.${header === "Content-Transfer-Encoding" ? ` RFC 7578 section 4.7 deprecates ${header} in multipart/form-data.` : ""} Send the raw bytes instead.`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Content types for the handful of extensions test harnesses actually upload.
|
|
132
|
+
* Anything unrecognised falls back to `application/octet-stream` — the safe
|
|
133
|
+
* default for opaque bytes, and what stops a PNG being served (and re-decoded)
|
|
134
|
+
* as UTF-8 text.
|
|
135
|
+
*
|
|
136
|
+
* This is an allowlist of INERT types on purpose. `text/html` is deliberately
|
|
137
|
+
* absent: the mock serves uploaded bytes back from its own origin, so any
|
|
138
|
+
* active type here would let a stored upload script that origin in a browser.
|
|
139
|
+
* HTML uploads round-trip byte-for-byte, they are just served as opaque bytes.
|
|
140
|
+
*/
|
|
141
|
+
const CONTENT_TYPE_BY_EXT = {
|
|
142
|
+
json: "application/json",
|
|
143
|
+
jsonl: "application/jsonl",
|
|
144
|
+
txt: "text/plain; charset=utf-8",
|
|
145
|
+
md: "text/markdown; charset=utf-8",
|
|
146
|
+
csv: "text/csv; charset=utf-8",
|
|
147
|
+
pdf: "application/pdf",
|
|
148
|
+
png: "image/png",
|
|
149
|
+
jpg: "image/jpeg",
|
|
150
|
+
jpeg: "image/jpeg",
|
|
151
|
+
gif: "image/gif",
|
|
152
|
+
webp: "image/webp",
|
|
153
|
+
wav: "audio/wav",
|
|
154
|
+
mp3: "audio/mpeg",
|
|
155
|
+
zip: "application/zip"
|
|
156
|
+
};
|
|
157
|
+
function contentTypeForFilename(filename) {
|
|
158
|
+
const dot = filename.lastIndexOf(".");
|
|
159
|
+
if (dot === -1) return "application/octet-stream";
|
|
160
|
+
return CONTENT_TYPE_BY_EXT[filename.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* RFC 5987 §3.2.1 `ext-value` percent-encoding for the `filename*` parameter.
|
|
164
|
+
*
|
|
165
|
+
* `encodeURIComponent` leaves `!'()*-._~` unescaped, but `'`, `(`, `)` and `*`
|
|
166
|
+
* are NOT in RFC 5987's `attr-char` set, so they have to be escaped by hand.
|
|
167
|
+
*/
|
|
168
|
+
function encodeExtValue(value) {
|
|
169
|
+
return encodeURIComponent(value).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* `Content-Disposition` for the content route (RFC 6266 §4.1).
|
|
173
|
+
*
|
|
174
|
+
* Always `attachment`. {@link CONTENT_TYPE_BY_EXT} is an allowlist of INERT
|
|
175
|
+
* types precisely so a stored upload cannot execute against the mock's own
|
|
176
|
+
* origin, but `application/pdf` was still being served with no disposition at
|
|
177
|
+
* all, which in every mainstream browser means "render in the built-in PDF
|
|
178
|
+
* viewer" — an active surface on this origin, exactly what the allowlist's
|
|
179
|
+
* comment claims is excluded. `attachment` closes that without touching the
|
|
180
|
+
* allowlist, and it also stops a `.json`/`.txt`/`.csv` body being rendered
|
|
181
|
+
* inline if someone opens a content URL in a tab.
|
|
182
|
+
*
|
|
183
|
+
* What the REAL API sends here is UNVERIFIED: probing
|
|
184
|
+
* `api.openai.com/v1/files/{id}/content` needs a key, and neither
|
|
185
|
+
* `openai/openai-openapi` nor the SDK documents the response headers. So this
|
|
186
|
+
* is chosen for safety, not fidelity — and it is inert for SDK callers: the
|
|
187
|
+
* vendored SDK's `files.content()` (openai@4.104.0,
|
|
188
|
+
* `resources/files.js:82-88`) issues the GET with `Accept: application/binary`
|
|
189
|
+
* and `__binaryResponse: true`, handing back the raw `Response` without ever
|
|
190
|
+
* reading `Content-Disposition`.
|
|
191
|
+
*
|
|
192
|
+
* Both parameters are built from the STORED filename, which is attacker-shaped
|
|
193
|
+
* input: a multipart `filename="..."` can carry CR/LF and would otherwise
|
|
194
|
+
* inject response headers. The ASCII fallback replaces every non-printable and
|
|
195
|
+
* non-ASCII byte with `_` and backslash-escapes `"` and `\` per the
|
|
196
|
+
* quoted-string rule; `filename*` percent-encodes them. Neither can emit a bare
|
|
197
|
+
* CR or LF.
|
|
198
|
+
*/
|
|
199
|
+
function contentDispositionFor(filename) {
|
|
200
|
+
const base = `attachment; filename="${filename.replace(/[^\x20-\x7e]/g, "_").replace(/(["\\])/g, "\\$1")}"`;
|
|
201
|
+
if (/^[\x20-\x7e]*$/.test(filename)) return base;
|
|
202
|
+
return `${base}; filename*=UTF-8''${encodeExtValue(filename)}`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Per-file content cap. An upload whose decoded content is above this gets a
|
|
206
|
+
* `400` with the usual OpenAI-style error envelope, at every wire size a body
|
|
207
|
+
* can have — see {@link FILES_BODY_MAX_BYTES} for how that is kept true
|
|
208
|
+
* without buffering an unbounded body.
|
|
209
|
+
*/
|
|
210
|
+
const FILES_MAX_BYTES = 10 * 1024 * 1024;
|
|
211
|
+
/**
|
|
212
|
+
* Worst-case wire-to-content expansion of a JSON upload.
|
|
213
|
+
*
|
|
214
|
+
* `content` is a JSON string, so one byte of decoded payload can arrive as as
|
|
215
|
+
* many as six wire bytes: a `\uXXXX` escape is 6 bytes and the cheapest code
|
|
216
|
+
* point one can carry (U+0000–U+007F) is a single UTF-8 byte. Every other
|
|
217
|
+
* code point does better — U+0800–U+FFFF is 3 UTF-8 bytes per 6 wire bytes,
|
|
218
|
+
* a surrogate pair 4 per 12 — and a multipart part is 1:1 plus its envelope.
|
|
219
|
+
* So six is an upper bound on wire → content expansion for both parsers.
|
|
220
|
+
*/
|
|
221
|
+
const FILES_MAX_WIRE_EXPANSION = 6;
|
|
222
|
+
/** Slack for the JSON envelope, or for multipart boundaries and part headers. */
|
|
223
|
+
const FILES_ENVELOPE_HEADROOM_BYTES = 64 * 1024;
|
|
224
|
+
/**
|
|
225
|
+
* How much of a Files upload body the route will BUFFER — derived, not picked.
|
|
226
|
+
*
|
|
227
|
+
* `readBodyBuffer` enforces a limit by destroying the socket: no status line,
|
|
228
|
+
* no body, no CORS headers, just an `ECONNRESET`. That made the `400 File
|
|
229
|
+
* content exceeds ... byte cap` branches below unreachable when the two caps
|
|
230
|
+
* were equal (both the 10 MB `DEFAULT_MAX_BODY_BYTES`), and a fixed slice of
|
|
231
|
+
* headroom above the cap only moved the cliff: a `\u0001`-heavy JSON body or a
|
|
232
|
+
* multipart body far enough over the cap still died on the socket.
|
|
233
|
+
*
|
|
234
|
+
* So the bound is computed from the widest a within-cap payload can be on the
|
|
235
|
+
* wire ({@link FILES_MAX_WIRE_EXPANSION} × the cap, plus envelope headroom).
|
|
236
|
+
* Two consequences, and they are the contract:
|
|
237
|
+
*
|
|
238
|
+
* - No body that could decode to a payload within the cap is ever refused for
|
|
239
|
+
* its size — it fits here by construction, and is answered on content.
|
|
240
|
+
* - A body larger than this is not buffered. The route stops retaining bytes
|
|
241
|
+
* (peak memory stays at this bound), drains the rest, and answers a real
|
|
242
|
+
* `400` with CORS. It is not a socket drop, and it cannot be a false
|
|
243
|
+
* rejection either, by the point above.
|
|
244
|
+
*
|
|
245
|
+
* The global `readBodyBuffer` default is deliberately left alone — every other
|
|
246
|
+
* route depends on it.
|
|
247
|
+
*/
|
|
248
|
+
const FILES_BODY_MAX_BYTES = FILES_MAX_BYTES * FILES_MAX_WIRE_EXPANSION + FILES_ENVELOPE_HEADROOM_BYTES;
|
|
249
|
+
/**
|
|
250
|
+
* The one documented socket drop on this route: a DoS backstop.
|
|
251
|
+
*
|
|
252
|
+
* Draining an over-size body is what buys the caller a real `400`, but the
|
|
253
|
+
* draining itself is work an attacker can ask for without limit. Past this
|
|
254
|
+
* the request is simply not worth finishing and the socket is destroyed, the
|
|
255
|
+
* same way every other route treats its own limit. It is deliberately far
|
|
256
|
+
* above {@link FILES_BODY_MAX_BYTES}: a client that lands here is not sending
|
|
257
|
+
* a file that was ever going to be accepted.
|
|
258
|
+
*/
|
|
259
|
+
const FILES_BODY_DRAIN_MAX_BYTES = FILES_BODY_MAX_BYTES * 2;
|
|
260
|
+
/**
|
|
261
|
+
* Stands in for an upload body that went past {@link FILES_BODY_MAX_BYTES} and
|
|
262
|
+
* was therefore drained instead of buffered. The route hands this to
|
|
263
|
+
* {@link handleFilesCreate} in place of the bytes, so the over-size answer is
|
|
264
|
+
* the same `400` envelope — journalled, CORS-headed, chaos-gated — as every
|
|
265
|
+
* other rejection here, rather than a second error path in the server.
|
|
266
|
+
*/
|
|
267
|
+
const FILES_BODY_OVERSIZED = Symbol("files-body-oversized");
|
|
268
|
+
/** The `400` for a body too large to be a within-cap upload. */
|
|
269
|
+
function oversizedBody() {
|
|
270
|
+
return `Request body exceeds the ${FILES_BODY_MAX_BYTES} byte upload limit for this route (the ${FILES_MAX_BYTES} byte content cap plus worst-case wire encoding overhead)`;
|
|
271
|
+
}
|
|
272
|
+
const fileStore = /* @__PURE__ */ new Map();
|
|
273
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
274
|
+
function clearFileStore() {
|
|
275
|
+
fileStore.clear();
|
|
276
|
+
fileContents.clear();
|
|
277
|
+
}
|
|
278
|
+
/** The original upload name, without exposing mutable file metadata. */
|
|
279
|
+
function getStoredFileName(id) {
|
|
280
|
+
return fileStore.get(id)?.filename;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The stored bytes for one file, as the very `Buffer` this module holds — so a
|
|
284
|
+
* test can assert on what it RETAINS (`buffer.byteLength`), not just on what it
|
|
285
|
+
* serves back. `GET /v1/files/{id}/content` copies onto the wire, so it cannot
|
|
286
|
+
* tell an owned buffer from a `subarray` pinning a 60 MB request body.
|
|
287
|
+
*/
|
|
288
|
+
function getStoredFileBytes(id) {
|
|
289
|
+
return fileContents.get(id)?.bytes;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Register one file in the store and return its wire object. This is the one
|
|
293
|
+
* insert path: `POST /v1/files` uses it, and so do server-side producers of
|
|
294
|
+
* files (the Batches mock minting `output_file_id` / `error_file_id`), so a
|
|
295
|
+
* file minted internally is retrievable through `GET /v1/files/{id}` and
|
|
296
|
+
* `/content` exactly like an upload. `content` must be an OWNED buffer (see
|
|
297
|
+
* {@link ParsedUpload.content}) — it is stored as-is, and it must already be
|
|
298
|
+
* within {@link FILES_MAX_BYTES}: the cap the upload route enforces holds for
|
|
299
|
+
* every file in the store, so a producer that cannot fit under it fails its
|
|
300
|
+
* own operation instead of storing the file.
|
|
301
|
+
*/
|
|
302
|
+
function storeFile(file) {
|
|
303
|
+
if (file.content.length > FILES_MAX_BYTES) throw new RangeError(`File content exceeds ${FILES_MAX_BYTES} byte cap`);
|
|
304
|
+
const id = require_helpers.generateId("file");
|
|
305
|
+
const obj = {
|
|
306
|
+
id,
|
|
307
|
+
object: "file",
|
|
308
|
+
bytes: file.content.length,
|
|
309
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
310
|
+
filename: file.filename,
|
|
311
|
+
purpose: file.purpose,
|
|
312
|
+
status: "processed"
|
|
313
|
+
};
|
|
314
|
+
fileStore.set(id, obj);
|
|
315
|
+
fileContents.set(id, { bytes: file.content });
|
|
316
|
+
return obj;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Synthetic journal body for an upload — the SAME convention
|
|
320
|
+
* {@link handleTranscription} uses for `multipart/form-data` audio: the entry
|
|
321
|
+
* carries a small object describing the upload that was parsed out of the
|
|
322
|
+
* body, never the body itself. Raw file octets are NEVER journalled, in any
|
|
323
|
+
* encoding: they can be megabytes of binary, they would blow the journal's
|
|
324
|
+
* 64 KB body cap on anything real, and a base64 round-trip of them is not
|
|
325
|
+
* something a test wants to diff. `sha256` is the byte identity a caller
|
|
326
|
+
* asserts on instead.
|
|
327
|
+
*
|
|
328
|
+
* `content_type` is derived from the stored filename via
|
|
329
|
+
* {@link contentTypeForFilename} — the same decision `GET /v1/files/{id}/content`
|
|
330
|
+
* serves the bytes back with — rather than from the client-declared part
|
|
331
|
+
* header, which this mock deliberately ignores everywhere else.
|
|
332
|
+
*
|
|
333
|
+
* `model`/`messages` are the inert `ChatCompletionRequest` floor the journal's
|
|
334
|
+
* body type requires (the same `{ model: "", messages: [] }` the server uses
|
|
335
|
+
* for its own bodyless synthetic entries); `_endpointType` names the surface.
|
|
336
|
+
*/
|
|
337
|
+
function filesUploadBody(upload) {
|
|
338
|
+
return {
|
|
339
|
+
model: "",
|
|
340
|
+
messages: [],
|
|
341
|
+
_endpointType: "files",
|
|
342
|
+
purpose: upload.purpose,
|
|
343
|
+
filename: upload.filename,
|
|
344
|
+
bytes: upload.content.length,
|
|
345
|
+
content_type: contentTypeForFilename(upload.filename),
|
|
346
|
+
sha256: (0, node_crypto.createHash)("sha256").update(upload.content).digest("hex")
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Journal one files-route request.
|
|
351
|
+
*
|
|
352
|
+
* The rule for `body`: an entry carries the {@link filesUploadBody} descriptor
|
|
353
|
+
* exactly when a request had an upload payload that parsed — i.e. a successful
|
|
354
|
+
* `POST /v1/files` — and the {@link invalidRequest} envelope exactly when the
|
|
355
|
+
* request was rejected with a `400` (see {@link rejectFiles}), so the reason
|
|
356
|
+
* is readable from the journal without the wire response. Every other files
|
|
357
|
+
* entry (GET one, GET content, list, DELETE, a chaos-faulted request, a 404)
|
|
358
|
+
* has no payload to describe and stays `null`, matching the bodyless routes in
|
|
359
|
+
* {@link handleTranscription}. Raw uploaded bytes never appear.
|
|
360
|
+
*
|
|
361
|
+
* `service: "files"` is set on every branch so
|
|
362
|
+
* `GET /__aimock/journal?service=files` selects exactly this traffic, and
|
|
363
|
+
* `source: "internal"` because this store is aimock's own — the same tag every
|
|
364
|
+
* fine-tuning entry carries, success and error alike.
|
|
365
|
+
*
|
|
366
|
+
* Call it AFTER the response has been written: a `writeHead` that throws must
|
|
367
|
+
* not leave behind an entry for a response the client never received.
|
|
368
|
+
*/
|
|
369
|
+
function journalFiles(journal, method, path, headers, status, body = null) {
|
|
370
|
+
journal.add({
|
|
371
|
+
method,
|
|
372
|
+
path,
|
|
373
|
+
headers,
|
|
374
|
+
body,
|
|
375
|
+
service: "files",
|
|
376
|
+
response: {
|
|
377
|
+
status,
|
|
378
|
+
fixture: null,
|
|
379
|
+
source: "internal"
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/** Keep the entire warning bounded and on one physical line, including the URL. */
|
|
384
|
+
function filesRejectionWarning(message) {
|
|
385
|
+
const maxLength = 1024;
|
|
386
|
+
let safe = "";
|
|
387
|
+
for (const character of message) {
|
|
388
|
+
const code = character.charCodeAt(0);
|
|
389
|
+
const escaped = code < 32 || code >= 127 && code <= 159 || code === 8232 || code === 8233 ? `\\u${code.toString(16).padStart(4, "0")}` : character;
|
|
390
|
+
if (safe.length + escaped.length > maxLength - 3) return `${safe}...`;
|
|
391
|
+
safe += escaped;
|
|
392
|
+
}
|
|
393
|
+
return safe;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Answer a files-route `400`: log it at `warn` (the level the server uses for
|
|
397
|
+
* a request it refused), journal the {@link invalidRequest} envelope as the
|
|
398
|
+
* entry's body so the reason survives in the journal, and write that same
|
|
399
|
+
* envelope to the wire. One helper so the three can never disagree.
|
|
400
|
+
*/
|
|
401
|
+
function rejectFiles(res, journal, logger, method, path, headers, message, setCorsHeaders) {
|
|
402
|
+
const envelope = invalidRequest(message);
|
|
403
|
+
logger.warn(filesRejectionWarning(`Files mock: rejected ${method} ${path} with 400: ${message}`));
|
|
404
|
+
writeJson(res, 400, envelope, setCorsHeaders);
|
|
405
|
+
journalFiles(journal, method, path, headers, 400, envelope);
|
|
406
|
+
}
|
|
407
|
+
function writeJson(res, status, payload, setCorsHeaders) {
|
|
408
|
+
setCorsHeaders(res);
|
|
409
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
410
|
+
res.end(JSON.stringify(payload));
|
|
411
|
+
}
|
|
412
|
+
function invalidRequest(message) {
|
|
413
|
+
return { error: {
|
|
414
|
+
message,
|
|
415
|
+
type: "invalid_request_error"
|
|
416
|
+
} };
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Name a rejected value's JSON type for the 400 message. The point is that the
|
|
420
|
+
* caller sees *what* they sent instead of silently getting `String(value)`
|
|
421
|
+
* stored as the file's content.
|
|
422
|
+
*
|
|
423
|
+
* The listed cases are exhaustive for the one caller. `JSON.parse` only ever
|
|
424
|
+
* yields null, boolean, number, string, array or plain object — never
|
|
425
|
+
* `undefined`, `function`, `symbol` or `bigint` — and the caller has already
|
|
426
|
+
* excluded `string` (it is the accepted type) and `undefined` (that is the
|
|
427
|
+
* separate "required" 400). So everything reaching the last line is an object.
|
|
428
|
+
*/
|
|
429
|
+
function jsonTypeOf(value) {
|
|
430
|
+
if (value === null) return "null";
|
|
431
|
+
if (Array.isArray(value)) return "an array";
|
|
432
|
+
if (typeof value === "number") return "a number";
|
|
433
|
+
if (typeof value === "boolean") return "a boolean";
|
|
434
|
+
return "an object";
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* A high surrogate not followed by a low one, or a low surrogate not preceded
|
|
438
|
+
* by a high one — i.e. a JS string that is not well-formed UTF-16 and therefore
|
|
439
|
+
* has no UTF-8 encoding.
|
|
440
|
+
*
|
|
441
|
+
* `Buffer.from(s, "utf8")` does not fail on these: it substitutes U+FFFD (EF BF
|
|
442
|
+
* BD), silently and permanently. That breaks the one guarantee an upload mock
|
|
443
|
+
* owes its caller — `GET /v1/files/{id}/content` returns what was uploaded — and
|
|
444
|
+
* it breaks it invisibly, three bytes at a time, in a test whose hash
|
|
445
|
+
* comparison then fails for no visible reason. The byte-for-byte promise this
|
|
446
|
+
* module documents is made for the multipart path (which is scanned as latin1
|
|
447
|
+
* and never decoded); the JSON path carries *text*, so text that cannot be
|
|
448
|
+
* encoded is a 400, not a lossy store.
|
|
449
|
+
*/
|
|
450
|
+
const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
451
|
+
/**
|
|
452
|
+
* Longest `filename` this mock will store, in UTF-8 bytes.
|
|
453
|
+
*
|
|
454
|
+
* NOT the real API's limit: `openai/openai-openapi` gives `OpenAIFile.filename`
|
|
455
|
+
* as a bare `type: string` with no `maxLength`, and `CreateFileRequest` has no
|
|
456
|
+
* `filename` property at all. So the bound comes from the one thing here a
|
|
457
|
+
* filename can break: `GET /v1/files/{id}/content` echoes the stored name into
|
|
458
|
+
* `Content-Disposition` twice — quoted ASCII (where `"` and `\` double) and
|
|
459
|
+
* percent-encoded per RFC 5987 (where a byte can triple) — so ~5 bytes of
|
|
460
|
+
* header per byte of name. 1024 bytes of name is ~5 KiB of header, well inside
|
|
461
|
+
* Node's 16 KiB default `--max-http-header-size`.
|
|
462
|
+
*
|
|
463
|
+
* Unbounded, a 20 KB filename uploaded `200` and then made the content route
|
|
464
|
+
* unreadable by undici: `UND_ERR_HEADERS_OVERFLOW`, no status, no envelope, no
|
|
465
|
+
* CORS — the OpenAI SDK could not fetch a file it had just uploaded.
|
|
466
|
+
*/
|
|
467
|
+
const FILES_MAX_FILENAME_BYTES = 1024;
|
|
468
|
+
/**
|
|
469
|
+
* The filename rules, in one place because both parsers must answer the same.
|
|
470
|
+
*
|
|
471
|
+
* They used to disagree: a name carrying NUL, CR or LF was a `400` as a
|
|
472
|
+
* multipart parameter and a `200` as a JSON field, stored and echoed back
|
|
473
|
+
* verbatim. Returns an error message, or `undefined` if the name is fine.
|
|
474
|
+
*/
|
|
475
|
+
function filenameError(filename) {
|
|
476
|
+
if (CONTROL_CHARS.test(filename)) return "Invalid parameter: 'filename' must not contain control characters";
|
|
477
|
+
if (LONE_SURROGATE_RE.test(filename)) return "Invalid parameter: 'filename' must not contain an unpaired UTF-16 surrogate";
|
|
478
|
+
const byteLength = Buffer.byteLength(filename, "utf8");
|
|
479
|
+
if (byteLength > FILES_MAX_FILENAME_BYTES) return `Invalid parameter: 'filename' is ${byteLength} UTF-8 bytes, above the ${FILES_MAX_FILENAME_BYTES} byte cap (a longer name makes GET /v1/files/{id}/content unfetchable: its Content-Disposition header would exceed the HTTP header limit)`;
|
|
480
|
+
}
|
|
481
|
+
/** The deterministic name given to an upload that arrived without one. */
|
|
482
|
+
function synthesizedFilename(content) {
|
|
483
|
+
return `upload-${(0, node_crypto.createHash)("sha256").update(content).digest("hex").slice(0, 24)}.bin`;
|
|
484
|
+
}
|
|
485
|
+
function parseJsonUpload(raw) {
|
|
486
|
+
let text;
|
|
487
|
+
try {
|
|
488
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
489
|
+
} catch {
|
|
490
|
+
return { error: "Invalid request: body is not valid UTF-8" };
|
|
491
|
+
}
|
|
492
|
+
let body;
|
|
493
|
+
try {
|
|
494
|
+
body = JSON.parse(text);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
return { error: `Malformed JSON: ${err instanceof Error ? err.message : "unknown"}` };
|
|
497
|
+
}
|
|
498
|
+
if (!require_helpers.isJsonObject(body)) return { error: "Request body must be a JSON object" };
|
|
499
|
+
const rawFilename = body["filename"];
|
|
500
|
+
const purpose = body["purpose"];
|
|
501
|
+
if (typeof rawFilename !== "string") return { error: "Invalid parameter: 'filename' must be a string" };
|
|
502
|
+
const filenameProblem = filenameError(rawFilename);
|
|
503
|
+
if (filenameProblem !== void 0) return { error: filenameProblem };
|
|
504
|
+
const filename = rawFilename.trim() === "" ? void 0 : rawFilename;
|
|
505
|
+
if (typeof purpose !== "string" || purpose.length === 0) return { error: "Invalid parameter: 'purpose' must be a non-empty string" };
|
|
506
|
+
if (!CREATE_PURPOSES.has(purpose)) return { error: invalidPurpose(purpose) };
|
|
507
|
+
const hasContent = body["content"] !== void 0;
|
|
508
|
+
const hasBytesAlias = body["bytes"] !== void 0;
|
|
509
|
+
if (hasContent && hasBytesAlias) return { error: "Invalid parameter: 'content' and its alias 'bytes' are mutually exclusive; send exactly one" };
|
|
510
|
+
const field = hasContent ? "content" : "bytes";
|
|
511
|
+
const content = hasContent ? body["content"] : body["bytes"];
|
|
512
|
+
if (content === void 0) return { error: "Invalid parameter: 'content' is required (or its alias 'bytes')" };
|
|
513
|
+
if (typeof content !== "string") return { error: `Invalid parameter: '${field}' must be a string, got ${jsonTypeOf(content)}` };
|
|
514
|
+
if (LONE_SURROGATE_RE.test(content)) return { error: `Invalid parameter: '${field}' contains an unpaired UTF-16 surrogate and is not valid text. aimock would have to store it as U+FFFD, so the bytes served back by GET /v1/files/{id}/content would not be the bytes you sent. Send binary as a multipart/form-data upload, which is stored verbatim.` };
|
|
515
|
+
const bytes = Buffer.from(content, "utf8");
|
|
516
|
+
if (bytes.length > FILES_MAX_BYTES) return { error: `File content exceeds ${FILES_MAX_BYTES} byte cap` };
|
|
517
|
+
return {
|
|
518
|
+
filename: filename ?? synthesizedFilename(bytes),
|
|
519
|
+
purpose,
|
|
520
|
+
content: bytes
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const HEADER_TERMINATOR = Buffer.from("\r\n\r\n");
|
|
524
|
+
/** RFC 9110 `token` characters — the legal set for a header field name. */
|
|
525
|
+
const TOKEN_CHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
526
|
+
/** C0 controls plus DEL — never legitimate in a filename we echo back. */
|
|
527
|
+
const CONTROL_CHARS = /[-]/;
|
|
528
|
+
/**
|
|
529
|
+
* Is this request body `multipart/form-data`?
|
|
530
|
+
*
|
|
531
|
+
* Compares the media type's *essence* (RFC 9110 §8.3.1: everything before the
|
|
532
|
+
* first `;`), not a substring of the whole header. A substring test routed
|
|
533
|
+
* `application/json; note="multipart/form-data"` — and anything else that
|
|
534
|
+
* merely mentions the token in a parameter — into the multipart parser, which
|
|
535
|
+
* then 400'd about a missing `boundary` directive on a perfectly good JSON
|
|
536
|
+
* body.
|
|
537
|
+
*/
|
|
538
|
+
function isMultipartFormData(contentType) {
|
|
539
|
+
if (contentType === void 0) return false;
|
|
540
|
+
const semi = contentType.indexOf(";");
|
|
541
|
+
return (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase() === "multipart/form-data";
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Locate the next *boundary delimiter line* at or after `from`.
|
|
545
|
+
*
|
|
546
|
+
* RFC 2046 §5.1.1 defines the delimiter as `CRLF "--" boundary` followed by
|
|
547
|
+
* either transport-padding + CRLF (another part follows) or `--` (the close
|
|
548
|
+
* delimiter). Both halves matter:
|
|
549
|
+
*
|
|
550
|
+
* - The **leading CRLF is part of the delimiter**, not of the preceding body.
|
|
551
|
+
* The previous implementation split on a bare `--boundary`, so a payload
|
|
552
|
+
* whose bytes happened to contain that sequence was truncated at it and
|
|
553
|
+
* stored short — with a 200, silently, which is the worst possible answer
|
|
554
|
+
* from a module whose contract is byte-for-byte storage.
|
|
555
|
+
* - An occurrence **not followed by** CRLF or `--` is not a delimiter line at
|
|
556
|
+
* all, so it is ordinary body content and the scan continues past it. That
|
|
557
|
+
* is what keeps a payload containing `CRLF--boundary` plus arbitrary bytes
|
|
558
|
+
* byte-exact. (undici's `Response.formData()` rejects such a body outright;
|
|
559
|
+
* see the note on {@link parseMultipartUpload}.)
|
|
560
|
+
*/
|
|
561
|
+
function nextDelimiter(buf, delimiter, from) {
|
|
562
|
+
let at = buf.indexOf(delimiter, from);
|
|
563
|
+
while (at !== -1) {
|
|
564
|
+
const tail = delimiterTail(buf, at + delimiter.length);
|
|
565
|
+
if (tail) return {
|
|
566
|
+
at,
|
|
567
|
+
...tail
|
|
568
|
+
};
|
|
569
|
+
at = buf.indexOf(delimiter, at + 1);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Classify what follows a `CRLF "--" boundary` match: transport-padding + CRLF
|
|
574
|
+
* (another part follows), `--` (the close delimiter), or neither — in which
|
|
575
|
+
* case the match is not a delimiter line at all and the caller keeps scanning.
|
|
576
|
+
* Split out of {@link nextDelimiter} so {@link parseMultipartUpload} can apply
|
|
577
|
+
* the same rule to an opening delimiter whose leading CRLF is not present.
|
|
578
|
+
*/
|
|
579
|
+
function delimiterTail(buf, after) {
|
|
580
|
+
if (buf[after] === 45 && buf[after + 1] === 45) return {
|
|
581
|
+
next: after + 2,
|
|
582
|
+
closing: true
|
|
583
|
+
};
|
|
584
|
+
let j = after;
|
|
585
|
+
while (buf[j] === 32 || buf[j] === 9) j += 1;
|
|
586
|
+
if (buf[j] === 13 && buf[j + 1] === 10) return {
|
|
587
|
+
next: j + 2,
|
|
588
|
+
closing: false
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function parseHeaderParams(value) {
|
|
592
|
+
const params = /* @__PURE__ */ new Map();
|
|
593
|
+
const semi = value.indexOf(";");
|
|
594
|
+
const head = (semi === -1 ? value : value.slice(0, semi)).trim();
|
|
595
|
+
let i = semi === -1 ? value.length : semi;
|
|
596
|
+
while (i < value.length) {
|
|
597
|
+
i += 1;
|
|
598
|
+
while (value[i] === " " || value[i] === " ") i += 1;
|
|
599
|
+
if (i >= value.length) break;
|
|
600
|
+
const eq = value.indexOf("=", i);
|
|
601
|
+
if (eq === -1) return MALFORMED_HEADER;
|
|
602
|
+
const attr = value.slice(i, eq).trim().toLowerCase();
|
|
603
|
+
if (!TOKEN_CHAR.test(attr)) return MALFORMED_HEADER;
|
|
604
|
+
if (params.has(attr)) return {
|
|
605
|
+
error: "duplicate",
|
|
606
|
+
param: attr
|
|
607
|
+
};
|
|
608
|
+
i = eq + 1;
|
|
609
|
+
if (value[i] === "\"") {
|
|
610
|
+
i += 1;
|
|
611
|
+
let out = "";
|
|
612
|
+
for (;;) {
|
|
613
|
+
if (i >= value.length) return MALFORMED_HEADER;
|
|
614
|
+
const ch = value[i];
|
|
615
|
+
if (ch === "\\" && i + 1 < value.length) {
|
|
616
|
+
out += value[i + 1];
|
|
617
|
+
i += 2;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (ch === "\"") {
|
|
621
|
+
i += 1;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
out += ch;
|
|
625
|
+
i += 1;
|
|
626
|
+
}
|
|
627
|
+
params.set(attr, out);
|
|
628
|
+
while (value[i] === " " || value[i] === " ") i += 1;
|
|
629
|
+
if (i < value.length && value[i] !== ";") return MALFORMED_HEADER;
|
|
630
|
+
} else {
|
|
631
|
+
const end = value.indexOf(";", i);
|
|
632
|
+
params.set(attr, (end === -1 ? value.slice(i) : value.slice(i, end)).trim());
|
|
633
|
+
i = end === -1 ? value.length : end;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
value: head,
|
|
638
|
+
params
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const MALFORMED_HEADER = { error: "malformed" };
|
|
642
|
+
/** Render a {@link parseHeaderParams} failure as a 400 message naming the header. */
|
|
643
|
+
function headerParamError(err, header, where) {
|
|
644
|
+
return err.error === "duplicate" ? `Invalid request: duplicate '${err.param}' parameter on the ${header} header in ${where}` : `Invalid request: malformed ${header} header in ${where}`;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Decode an RFC 5987 `ext-value` (`charset'language'pct-encoded`), the form a
|
|
648
|
+
* `filename*` parameter takes. RFC 6266 §4.3 says a recipient that understands
|
|
649
|
+
* it SHOULD prefer it over the plain `filename`, so {@link parsePart} does.
|
|
650
|
+
* A client that sends only `filename*=` therefore keeps the name it sent
|
|
651
|
+
* instead of receiving a synthesized one.
|
|
652
|
+
*/
|
|
653
|
+
function decodeExtValue(raw) {
|
|
654
|
+
const first = raw.indexOf("'");
|
|
655
|
+
const second = raw.indexOf("'", first + 1);
|
|
656
|
+
if (first === -1 || second === -1) return void 0;
|
|
657
|
+
const charset = raw.slice(0, first).toLowerCase();
|
|
658
|
+
if (charset !== "utf-8" && charset !== "iso-8859-1") return void 0;
|
|
659
|
+
const octets = [];
|
|
660
|
+
const pct = raw.slice(second + 1);
|
|
661
|
+
for (let i = 0; i < pct.length; i += 1) if (pct[i] === "%") {
|
|
662
|
+
const hex = pct.slice(i + 1, i + 3);
|
|
663
|
+
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return void 0;
|
|
664
|
+
octets.push(parseInt(hex, 16));
|
|
665
|
+
i += 2;
|
|
666
|
+
} else {
|
|
667
|
+
const code = pct.charCodeAt(i);
|
|
668
|
+
if (code > 127) return void 0;
|
|
669
|
+
octets.push(code);
|
|
670
|
+
}
|
|
671
|
+
const bytes = Buffer.from(octets);
|
|
672
|
+
if (charset === "iso-8859-1") return bytes.toString("latin1");
|
|
673
|
+
try {
|
|
674
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
675
|
+
} catch {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** Turn one part's raw octets into headers + body, or name why it is malformed. */
|
|
680
|
+
function parsePart(partBytes, index) {
|
|
681
|
+
const where = `multipart part #${index + 1}`;
|
|
682
|
+
let headerText;
|
|
683
|
+
let bodyStart;
|
|
684
|
+
if (partBytes[0] === 13 && partBytes[1] === 10) {
|
|
685
|
+
headerText = "";
|
|
686
|
+
bodyStart = 2;
|
|
687
|
+
} else {
|
|
688
|
+
const end = partBytes.indexOf(HEADER_TERMINATOR);
|
|
689
|
+
if (end === -1) return { error: `Invalid request: malformed ${where} — its headers are not terminated by a blank line` };
|
|
690
|
+
headerText = partBytes.subarray(0, end).toString("utf8");
|
|
691
|
+
bodyStart = end + 4;
|
|
692
|
+
}
|
|
693
|
+
const headers = /* @__PURE__ */ new Map();
|
|
694
|
+
if (headerText !== "") for (const line of headerText.split("\r\n")) {
|
|
695
|
+
const colon = line.indexOf(":");
|
|
696
|
+
const fieldName = colon === -1 ? "" : line.slice(0, colon);
|
|
697
|
+
if (!TOKEN_CHAR.test(fieldName)) return { error: `Invalid request: malformed header line in ${where}` };
|
|
698
|
+
const field = fieldName.toLowerCase();
|
|
699
|
+
if (headers.has(field)) return { error: `Invalid request: duplicate '${fieldName.trim()}' header in ${where}` };
|
|
700
|
+
headers.set(field, line.slice(colon + 1).trim());
|
|
701
|
+
}
|
|
702
|
+
const disposition = headers.get("content-disposition");
|
|
703
|
+
if (disposition === void 0) return { error: `Invalid request: ${where} has no Content-Disposition header` };
|
|
704
|
+
const parsed = parseHeaderParams(disposition);
|
|
705
|
+
if ("error" in parsed) return { error: headerParamError(parsed, "Content-Disposition", where) };
|
|
706
|
+
if (parsed.value.toLowerCase() !== "form-data") return { error: `Invalid request: ${where} has Content-Disposition type '${parsed.value}'; RFC 7578 section 4.2 requires 'form-data'` };
|
|
707
|
+
const name = parsed.params.get("name");
|
|
708
|
+
if (name === void 0) return { error: `Invalid request: ${where} has no 'name' parameter on its Content-Disposition header` };
|
|
709
|
+
const star = parsed.params.get("filename*");
|
|
710
|
+
const filename = star !== void 0 ? decodeExtValue(star) : parsed.params.get("filename");
|
|
711
|
+
if (star !== void 0 && filename === void 0) return { error: `Invalid request: malformed 'filename*' parameter in ${where}` };
|
|
712
|
+
return {
|
|
713
|
+
name,
|
|
714
|
+
filename,
|
|
715
|
+
headers,
|
|
716
|
+
body: partBytes.subarray(bodyStart)
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Read a part's content encoding header. An empty value reads as absent: the
|
|
721
|
+
* old regex used `\s*` after the colon, which matches a CRLF, so an empty
|
|
722
|
+
* `Content-Transfer-Encoding:` captured the *next* header line and 400'd
|
|
723
|
+
* naming `content-type: image/png` as the unsupported encoding.
|
|
724
|
+
*/
|
|
725
|
+
function partEncoding(part, header) {
|
|
726
|
+
const value = part.headers.get(header)?.trim().toLowerCase();
|
|
727
|
+
return value === void 0 || value === "" ? void 0 : value;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Parse a `multipart/form-data` upload body.
|
|
731
|
+
*
|
|
732
|
+
* ## Why this is not `Response.formData()`
|
|
733
|
+
*
|
|
734
|
+
* Node's built-in (undici) `Response.formData()` is a spec-compliant multipart
|
|
735
|
+
* parser and was evaluated first, per the "use the platform, do not hand-roll
|
|
736
|
+
* a codec" rule. It fails three of this module's requirements:
|
|
737
|
+
*
|
|
738
|
+
* 1. **Byte fidelity.** It throws `expected CRLF` on any body whose payload
|
|
739
|
+
* contains the boundary token, including the RFC-legal bare `--boundary`
|
|
740
|
+
* with no preceding CRLF. Those bytes are storable and this module promises
|
|
741
|
+
* to store them.
|
|
742
|
+
* 2. **Filename-less payload parts.** A part with no `filename` parameter comes
|
|
743
|
+
* back as a *string*, decoded as UTF-8, so binary octets are destroyed
|
|
744
|
+
* (`00 ff fe ...` becomes `00 efbfbd efbfbd ...`). Such a part is legal
|
|
745
|
+
* input here and already round-tripped byte-exact.
|
|
746
|
+
* 3. **Part headers.** `File` exposes only `name`, `type` and `size`. There is
|
|
747
|
+
* no way to see a part's `Content-Transfer-Encoding`, so the "reject an
|
|
748
|
+
* encoding we do not apply" contract above could not be implemented at all
|
|
749
|
+
* and a `base64` part would be stored in its encoded form — the exact bug
|
|
750
|
+
* that contract exists to prevent.
|
|
751
|
+
*
|
|
752
|
+
* So the framing stays local, but it is written to RFC 2046 §5.1.1 and
|
|
753
|
+
* RFC 7578 rather than to a `split()`: see {@link nextDelimiter} for the
|
|
754
|
+
* delimiter rule and {@link parsePart} for the header rule.
|
|
755
|
+
*
|
|
756
|
+
* Bytes never round-trip through a string. Part bodies are `subarray`s of the
|
|
757
|
+
* request buffer, so what is parsed is literally what arrived on the wire — but
|
|
758
|
+
* the payload part is COPIED out of that view before it is returned, because a
|
|
759
|
+
* `subarray` retains its whole backing buffer (see the copy below). The scan
|
|
760
|
+
* itself never copies the body either, so peak residency during a parse is one
|
|
761
|
+
* request body, not two.
|
|
762
|
+
*/
|
|
763
|
+
function parseMultipartUpload(rawBytes, contentType) {
|
|
764
|
+
const parsedType = parseHeaderParams(contentType ?? "");
|
|
765
|
+
if ("error" in parsedType) return { error: headerParamError(parsedType, "Content-Type", "the request") };
|
|
766
|
+
const boundary = parsedType.params.get("boundary");
|
|
767
|
+
if (!boundary) return { error: "Invalid request: multipart/form-data Content-Type is missing a 'boundary' directive" };
|
|
768
|
+
const buf = rawBytes;
|
|
769
|
+
const delimiter = Buffer.from(`\r\n--${boundary}`, "latin1");
|
|
770
|
+
const dashBoundary = Buffer.from(`--${boundary}`, "latin1");
|
|
771
|
+
const opening = buf.subarray(0, dashBoundary.length).equals(dashBoundary) ? delimiterTail(buf, dashBoundary.length) : void 0;
|
|
772
|
+
let delim = opening ? {
|
|
773
|
+
at: 0,
|
|
774
|
+
...opening
|
|
775
|
+
} : nextDelimiter(buf, delimiter, 0);
|
|
776
|
+
if (!delim) return { error: "Invalid request: multipart body contains no boundary delimiter" };
|
|
777
|
+
const parts = [];
|
|
778
|
+
while (!delim.closing) {
|
|
779
|
+
const start = delim.next;
|
|
780
|
+
const following = nextDelimiter(buf, delimiter, start);
|
|
781
|
+
if (!following) return { error: "Invalid request: multipart body ended before its closing boundary delimiter" };
|
|
782
|
+
const part = parsePart(buf.subarray(start, following.at), parts.length);
|
|
783
|
+
if ("error" in part) return part;
|
|
784
|
+
parts.push(part);
|
|
785
|
+
delim = following;
|
|
786
|
+
}
|
|
787
|
+
for (const part of parts) {
|
|
788
|
+
const transfer = partEncoding(part, "content-transfer-encoding");
|
|
789
|
+
if (transfer !== void 0 && !IDENTITY_TRANSFER_ENCODINGS.has(transfer)) return { error: unsupportedPartEncoding("Content-Transfer-Encoding", transfer, part.name) };
|
|
790
|
+
const encoding = partEncoding(part, "content-encoding");
|
|
791
|
+
if (encoding !== void 0 && encoding !== "identity") return { error: unsupportedPartEncoding("Content-Encoding", encoding, part.name) };
|
|
792
|
+
}
|
|
793
|
+
const purposeParts = parts.filter((p) => p.name === "purpose");
|
|
794
|
+
if (purposeParts.length > 1) return { error: "Invalid request: multipart body has more than one 'purpose' part; send exactly one" };
|
|
795
|
+
const payloadParts = parts.filter((p) => p.name === "file" || p.name === "content");
|
|
796
|
+
if (payloadParts.length > 1) {
|
|
797
|
+
const [first, second] = payloadParts;
|
|
798
|
+
return { error: `Invalid request: multipart body has more than one payload part ('${first.name}' and '${second.name}'); send exactly one 'file' or 'content' part` };
|
|
799
|
+
}
|
|
800
|
+
const purpose = purposeParts[0]?.body.toString("utf8");
|
|
801
|
+
if (!purpose) return { error: "Invalid parameter: multipart 'purpose' field is required" };
|
|
802
|
+
if (!CREATE_PURPOSES.has(purpose)) return { error: invalidPurpose(purpose) };
|
|
803
|
+
const payload = payloadParts[0];
|
|
804
|
+
if (payload === void 0) return { error: "Invalid request: multipart body must include a 'file' part (or its 'content' alias)" };
|
|
805
|
+
if (payload.body.length > FILES_MAX_BYTES) return { error: `File content exceeds ${FILES_MAX_BYTES} byte cap` };
|
|
806
|
+
const content = Buffer.alloc(payload.body.length);
|
|
807
|
+
payload.body.copy(content);
|
|
808
|
+
let filename = payload.filename;
|
|
809
|
+
if (filename !== void 0) {
|
|
810
|
+
const problem = filenameError(filename);
|
|
811
|
+
if (problem !== void 0) return { error: problem };
|
|
812
|
+
}
|
|
813
|
+
if (filename !== void 0 && filename.trim() === "") filename = void 0;
|
|
814
|
+
if (filename === void 0) filename = synthesizedFilename(content);
|
|
815
|
+
return {
|
|
816
|
+
filename,
|
|
817
|
+
purpose,
|
|
818
|
+
content
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
async function handleFilesCreate(req, res, raw, journal, defaults, setCorsHeaders) {
|
|
822
|
+
const path = req.url ?? "/v1/files";
|
|
823
|
+
const method = req.method ?? "POST";
|
|
824
|
+
setCorsHeaders(res);
|
|
825
|
+
if (await require_chaos.applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
|
|
826
|
+
method,
|
|
827
|
+
path,
|
|
828
|
+
headers: require_helpers.flattenHeaders(req.headers),
|
|
829
|
+
body: null,
|
|
830
|
+
service: "files"
|
|
831
|
+
}, "internal", defaults.registry, defaults.logger)) return;
|
|
832
|
+
if (raw === FILES_BODY_OVERSIZED) {
|
|
833
|
+
rejectFiles(res, journal, defaults.logger, method, path, require_helpers.flattenHeaders(req.headers), oversizedBody(), setCorsHeaders);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const contentType = Array.isArray(req.headers["content-type"]) ? req.headers["content-type"][0] : req.headers["content-type"];
|
|
837
|
+
const parsed = isMultipartFormData(contentType) ? parseMultipartUpload(raw, contentType) : parseJsonUpload(raw);
|
|
838
|
+
if ("error" in parsed) {
|
|
839
|
+
rejectFiles(res, journal, defaults.logger, method, path, require_helpers.flattenHeaders(req.headers), parsed.error, setCorsHeaders);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const obj = storeFile(parsed);
|
|
843
|
+
const { id, bytes } = obj;
|
|
844
|
+
defaults.logger.debug(`Files mock: stored ${id} (${parsed.filename}, ${bytes} bytes)`);
|
|
845
|
+
writeJson(res, 200, obj, setCorsHeaders);
|
|
846
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 200, filesUploadBody(parsed));
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* `GET /v1/files` paging bounds, taken from the `openai/openai-openapi` spec
|
|
850
|
+
* (`operationId: listFiles`): "Limit can range between 1 and 10,000, and the
|
|
851
|
+
* default is 10,000." Files is the odd one out among the list surfaces — most
|
|
852
|
+
* default to 20 — so the default is spelled out here rather than shared.
|
|
853
|
+
*/
|
|
854
|
+
const FILES_LIST_MAX_LIMIT = 1e4;
|
|
855
|
+
const FILES_LIST_DEFAULT_LIMIT = FILES_LIST_MAX_LIMIT;
|
|
856
|
+
/**
|
|
857
|
+
* The single non-empty value of a list query parameter, or the 400 it earns.
|
|
858
|
+
*
|
|
859
|
+
* ONE rule for all four of `limit`, `order`, `after` and `purpose`, because the
|
|
860
|
+
* three ways of reading them used to disagree on the same URL:
|
|
861
|
+
*
|
|
862
|
+
* - REPEATED is a 400. `URLSearchParams.get` answers with the FIRST of a
|
|
863
|
+
* repeated parameter and launders the rest, so `?limit=1&limit=abc` paged
|
|
864
|
+
* at 1 and never looked at `abc` — a caller that built its URL twice over
|
|
865
|
+
* got a silent success for a value it never meant. `listFiles` declares
|
|
866
|
+
* each of the four as a single scalar and gives no combining rule for a
|
|
867
|
+
* repeat, so a repeat is a caller error. Matches the multipart body, where
|
|
868
|
+
* two `purpose` parts are already a 400 rather than a last-one-wins.
|
|
869
|
+
* - PRESENT-BUT-EMPTY is a 400. `?order=` was already one (no member of the
|
|
870
|
+
* enum is ""), but `?after=` meant "no cursor" and `?purpose=` meant "no
|
|
871
|
+
* filter" and handed back the WHOLE store — the exact opposite of the
|
|
872
|
+
* narrowing the caller asked for. An empty string is not an object id and
|
|
873
|
+
* not a purpose any stored file can carry, and `?x=` is what building a
|
|
874
|
+
* URL from a partly-undefined params object emits, which is a bug worth
|
|
875
|
+
* showing. Omitting the parameter is how you say "no filter".
|
|
876
|
+
* - ABSENT is `null`, and each caller applies its own default.
|
|
877
|
+
*
|
|
878
|
+
* Both 400s are OURS, not the vendor's: the spec states neither. They are the
|
|
879
|
+
* mock's standing bias towards failing loudly over answering a question the
|
|
880
|
+
* caller did not ask.
|
|
881
|
+
*/
|
|
882
|
+
function singleParam(params, name) {
|
|
883
|
+
const all = params.getAll(name);
|
|
884
|
+
if (all.length > 1) return {
|
|
885
|
+
ok: false,
|
|
886
|
+
error: `Invalid parameter: '${name}' was given ${all.length} times; it takes a single value`
|
|
887
|
+
};
|
|
888
|
+
if (all.length === 0) return {
|
|
889
|
+
ok: true,
|
|
890
|
+
value: null
|
|
891
|
+
};
|
|
892
|
+
if (all[0] === "") return {
|
|
893
|
+
ok: false,
|
|
894
|
+
error: `Invalid parameter: '${name}' was given an empty value; omit it instead`
|
|
895
|
+
};
|
|
896
|
+
return {
|
|
897
|
+
ok: true,
|
|
898
|
+
value: all[0]
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Parse the `limit` / `order` / `after` / `purpose` parameters off a list URL.
|
|
903
|
+
*
|
|
904
|
+
* Shapes come from `operationId: listFiles` in `openai/openai-openapi` and from
|
|
905
|
+
* `FileListParams extends CursorPageParams` in the vendored SDK (openai
|
|
906
|
+
* 4.104.0 — `resources/files.d.ts` gives `order?: 'asc' | 'desc'` and
|
|
907
|
+
* `purpose?: string`; `pagination.d.ts` gives `after?: string` and
|
|
908
|
+
* `limit?: number`). `order` defaults to `desc`, so the newest file comes back
|
|
909
|
+
* first.
|
|
910
|
+
*
|
|
911
|
+
* All four are read here, through {@link singleParam}, so there is exactly one
|
|
912
|
+
* place that decides what a repeated or empty list parameter means. `purpose`
|
|
913
|
+
* used to be read separately in `src/server.ts` and passed in already parsed,
|
|
914
|
+
* which is how it ended up with neither rule.
|
|
915
|
+
*/
|
|
916
|
+
function parseListQuery(rawUrl) {
|
|
917
|
+
const queryStart = rawUrl.indexOf("?");
|
|
918
|
+
const params = new URLSearchParams(queryStart === -1 ? "" : rawUrl.slice(queryStart + 1));
|
|
919
|
+
const rawOrder = singleParam(params, "order");
|
|
920
|
+
if (!rawOrder.ok) return { error: rawOrder.error };
|
|
921
|
+
const orderRaw = rawOrder.value;
|
|
922
|
+
if (orderRaw !== null && orderRaw !== "asc" && orderRaw !== "desc") return { error: "Invalid parameter: 'order' must be one of: asc, desc" };
|
|
923
|
+
const rawLimit = singleParam(params, "limit");
|
|
924
|
+
if (!rawLimit.ok) return { error: rawLimit.error };
|
|
925
|
+
let limit = FILES_LIST_DEFAULT_LIMIT;
|
|
926
|
+
if (rawLimit.value !== null) {
|
|
927
|
+
const parsed = require_helpers.parseStrictIntegerText(rawLimit.value);
|
|
928
|
+
if (parsed === null || parsed < 1 || parsed > FILES_LIST_MAX_LIMIT) return { error: `Invalid parameter: 'limit' must be an integer between 1 and ${FILES_LIST_MAX_LIMIT}` };
|
|
929
|
+
limit = parsed;
|
|
930
|
+
}
|
|
931
|
+
const rawAfter = singleParam(params, "after");
|
|
932
|
+
if (!rawAfter.ok) return { error: rawAfter.error };
|
|
933
|
+
const rawPurpose = singleParam(params, "purpose");
|
|
934
|
+
if (!rawPurpose.ok) return { error: rawPurpose.error };
|
|
935
|
+
return {
|
|
936
|
+
limit,
|
|
937
|
+
order: orderRaw ?? "desc",
|
|
938
|
+
after: rawAfter.value ?? void 0,
|
|
939
|
+
purpose: rawPurpose.value ?? void 0
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
async function handleFilesList(req, res, journal, defaults, setCorsHeaders) {
|
|
943
|
+
const path = req.url ?? "/v1/files";
|
|
944
|
+
const method = req.method ?? "GET";
|
|
945
|
+
setCorsHeaders(res);
|
|
946
|
+
if (await require_chaos.applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
|
|
947
|
+
method,
|
|
948
|
+
path,
|
|
949
|
+
headers: require_helpers.flattenHeaders(req.headers),
|
|
950
|
+
body: null,
|
|
951
|
+
service: "files"
|
|
952
|
+
}, "internal", defaults.registry, defaults.logger)) return;
|
|
953
|
+
const query = parseListQuery(path);
|
|
954
|
+
if ("error" in query) {
|
|
955
|
+
rejectFiles(res, journal, defaults.logger, method, path, require_helpers.flattenHeaders(req.headers), query.error, setCorsHeaders);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
let data = [...fileStore.values()];
|
|
959
|
+
if (query.purpose !== void 0) data = data.filter((f) => f.purpose === query.purpose);
|
|
960
|
+
data.sort((a, b) => a.created_at - b.created_at);
|
|
961
|
+
if (query.order === "desc") data.reverse();
|
|
962
|
+
let start = 0;
|
|
963
|
+
if (query.after !== void 0) {
|
|
964
|
+
const idx = data.findIndex((f) => f.id === query.after);
|
|
965
|
+
if (idx === -1) {
|
|
966
|
+
rejectFiles(res, journal, defaults.logger, method, path, require_helpers.flattenHeaders(req.headers), `Invalid parameter: 'after' cursor '${query.after}' does not match any file in this listing`, setCorsHeaders);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
start = idx + 1;
|
|
970
|
+
}
|
|
971
|
+
const page = data.slice(start, start + query.limit);
|
|
972
|
+
writeJson(res, 200, {
|
|
973
|
+
object: "list",
|
|
974
|
+
data: page,
|
|
975
|
+
first_id: page[0]?.id ?? null,
|
|
976
|
+
last_id: page[page.length - 1]?.id ?? null,
|
|
977
|
+
has_more: start + page.length < data.length
|
|
978
|
+
}, setCorsHeaders);
|
|
979
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 200);
|
|
980
|
+
}
|
|
981
|
+
async function handleFilesRetrieve(req, res, fileId, journal, defaults, setCorsHeaders) {
|
|
982
|
+
const path = req.url ?? `/v1/files/${fileId}`;
|
|
983
|
+
const method = req.method ?? "GET";
|
|
984
|
+
setCorsHeaders(res);
|
|
985
|
+
if (await require_chaos.applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
|
|
986
|
+
method,
|
|
987
|
+
path,
|
|
988
|
+
headers: require_helpers.flattenHeaders(req.headers),
|
|
989
|
+
body: null,
|
|
990
|
+
service: "files"
|
|
991
|
+
}, "internal", defaults.registry, defaults.logger)) return;
|
|
992
|
+
const found = fileStore.get(fileId);
|
|
993
|
+
if (!found) {
|
|
994
|
+
writeJson(res, 404, invalidRequest(`No such file: ${fileId}`), setCorsHeaders);
|
|
995
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 404);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
writeJson(res, 200, found, setCorsHeaders);
|
|
999
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 200);
|
|
1000
|
+
}
|
|
1001
|
+
async function handleFilesContent(req, res, fileId, journal, defaults, setCorsHeaders) {
|
|
1002
|
+
const path = req.url ?? `/v1/files/${fileId}/content`;
|
|
1003
|
+
const method = req.method ?? "GET";
|
|
1004
|
+
setCorsHeaders(res);
|
|
1005
|
+
if (await require_chaos.applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
|
|
1006
|
+
method,
|
|
1007
|
+
path,
|
|
1008
|
+
headers: require_helpers.flattenHeaders(req.headers),
|
|
1009
|
+
body: null,
|
|
1010
|
+
service: "files"
|
|
1011
|
+
}, "internal", defaults.registry, defaults.logger)) return;
|
|
1012
|
+
const found = fileStore.get(fileId);
|
|
1013
|
+
const content = fileContents.get(fileId);
|
|
1014
|
+
if (!found || content === void 0) {
|
|
1015
|
+
writeJson(res, 404, invalidRequest(`No such file: ${fileId}`), setCorsHeaders);
|
|
1016
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 404);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
res.writeHead(200, {
|
|
1020
|
+
"Content-Type": contentTypeForFilename(found.filename),
|
|
1021
|
+
"Content-Length": String(content.bytes.length),
|
|
1022
|
+
"X-Content-Type-Options": "nosniff",
|
|
1023
|
+
"Content-Disposition": contentDispositionFor(found.filename)
|
|
1024
|
+
});
|
|
1025
|
+
res.end(content.bytes);
|
|
1026
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 200);
|
|
1027
|
+
}
|
|
1028
|
+
async function handleFilesDelete(req, res, fileId, journal, defaults, setCorsHeaders) {
|
|
1029
|
+
const path = req.url ?? `/v1/files/${fileId}`;
|
|
1030
|
+
const method = req.method ?? "DELETE";
|
|
1031
|
+
setCorsHeaders(res);
|
|
1032
|
+
if (await require_chaos.applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
|
|
1033
|
+
method,
|
|
1034
|
+
path,
|
|
1035
|
+
headers: require_helpers.flattenHeaders(req.headers),
|
|
1036
|
+
body: null,
|
|
1037
|
+
service: "files"
|
|
1038
|
+
}, "internal", defaults.registry, defaults.logger)) return;
|
|
1039
|
+
if (!fileStore.get(fileId)) {
|
|
1040
|
+
writeJson(res, 404, invalidRequest(`No such file: ${fileId}`), setCorsHeaders);
|
|
1041
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 404);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
fileStore.delete(fileId);
|
|
1045
|
+
fileContents.delete(fileId);
|
|
1046
|
+
require_vector_stores.removeVectorStoreFile(fileId);
|
|
1047
|
+
writeJson(res, 200, {
|
|
1048
|
+
id: fileId,
|
|
1049
|
+
object: "file",
|
|
1050
|
+
deleted: true
|
|
1051
|
+
}, setCorsHeaders);
|
|
1052
|
+
journalFiles(journal, method, path, require_helpers.flattenHeaders(req.headers), 200);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
//#endregion
|
|
1056
|
+
exports.FILES_BODY_DRAIN_MAX_BYTES = FILES_BODY_DRAIN_MAX_BYTES;
|
|
1057
|
+
exports.FILES_BODY_MAX_BYTES = FILES_BODY_MAX_BYTES;
|
|
1058
|
+
exports.FILES_BODY_OVERSIZED = FILES_BODY_OVERSIZED;
|
|
1059
|
+
exports.FILES_MAX_BYTES = FILES_MAX_BYTES;
|
|
1060
|
+
exports.clearFileStore = clearFileStore;
|
|
1061
|
+
exports.getStoredFileBytes = getStoredFileBytes;
|
|
1062
|
+
exports.getStoredFileName = getStoredFileName;
|
|
1063
|
+
exports.handleFilesContent = handleFilesContent;
|
|
1064
|
+
exports.handleFilesCreate = handleFilesCreate;
|
|
1065
|
+
exports.handleFilesDelete = handleFilesDelete;
|
|
1066
|
+
exports.handleFilesList = handleFilesList;
|
|
1067
|
+
exports.handleFilesRetrieve = handleFilesRetrieve;
|
|
1068
|
+
exports.storeFile = storeFile;
|
|
1069
|
+
//# sourceMappingURL=files.cjs.map
|