haechi 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +46 -11
- package/README.md +46 -11
- package/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +165 -9
- package/docs/current/configuration.md +165 -9
- package/docs/current/operations-runbook.ko.md +155 -0
- package/docs/current/operations-runbook.md +241 -0
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +5 -3
- package/docs/current/risk-register-release-gate.md +13 -3
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +4 -2
- package/docs/current/threat-model.md +4 -2
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +20 -3
- package/package.json +7 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +57 -10
- package/packages/cli/runtime.mjs +402 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +975 -12
- package/packages/metrics/index.mjs +181 -0
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +525 -40
- package/packages/stream-filter/index.mjs +69 -7
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// WS4-A telemetry seam (reliability-hardening-track §WS4 "Telemetry").
|
|
2
|
+
//
|
|
3
|
+
// A minimal, zero-dependency in-memory metrics collector rendering the
|
|
4
|
+
// Prometheus text exposition format. It is an INJECTABLE collaborator
|
|
5
|
+
// (providers.metrics in createRuntime), mirroring auditSink/rateLimiter; an
|
|
6
|
+
// operator who wants a real metrics backend injects their own object exposing
|
|
7
|
+
// the same { increment, observe, render } contract.
|
|
8
|
+
//
|
|
9
|
+
// HARD INVARIANT (the no-plaintext-in-audit invariant, extended to telemetry):
|
|
10
|
+
// every metric name AND every label value is a BOUNDED ENUM — a route id, a
|
|
11
|
+
// policy mode, or a decision class. It is NEVER an identity id/subject, a token,
|
|
12
|
+
// a detected value, or any other unbounded/PII-bearing string. This module does
|
|
13
|
+
// not — and structurally cannot — accept a payload value: callers pass only
|
|
14
|
+
// pre-classified enum labels, and label values are coerced + length-capped here
|
|
15
|
+
// as defence in depth so an accidental high-cardinality value cannot explode
|
|
16
|
+
// the series set or leak content.
|
|
17
|
+
|
|
18
|
+
// Metric catalogue: name -> { type, help }. Counters and one histogram. Keeping
|
|
19
|
+
// the catalogue explicit (rather than letting callers invent metric names)
|
|
20
|
+
// bounds the metric-name dimension to this fixed set.
|
|
21
|
+
const COUNTERS = {
|
|
22
|
+
haechi_requests_total: "Proxy requests by route, mode, and decision class.",
|
|
23
|
+
haechi_blocks_total: "Requests blocked by a policy decision.",
|
|
24
|
+
haechi_auth_denied_total: "Requests denied at authentication.",
|
|
25
|
+
haechi_rate_limited_total: "Requests rejected by the rate limiter.",
|
|
26
|
+
haechi_upstream_timeout_total: "Upstream requests that timed out.",
|
|
27
|
+
haechi_upstream_error_total: "Upstream requests that failed (non-timeout).",
|
|
28
|
+
haechi_response_unprotected_total: "Responses forwarded without protection (size/encoding/parse).",
|
|
29
|
+
haechi_internal_error_total: "Unexpected internal proxy errors.",
|
|
30
|
+
haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503)."
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const HISTOGRAMS = {
|
|
34
|
+
haechi_request_duration_seconds: "End-to-end proxy request handling duration in seconds."
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Default request-duration histogram buckets (seconds). Bounded, fixed set.
|
|
38
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
39
|
+
|
|
40
|
+
// Defence-in-depth label hygiene: a label value must be a short, bounded token.
|
|
41
|
+
// We coerce to string, trim, cap length, and collapse anything outside a safe
|
|
42
|
+
// charset to "_". This guarantees that even a caller mistake cannot place a raw
|
|
43
|
+
// payload value or a long identity string into a series label.
|
|
44
|
+
const MAX_LABEL_LENGTH = 64;
|
|
45
|
+
|
|
46
|
+
function safeLabelValue(value) {
|
|
47
|
+
if (value === undefined || value === null) {
|
|
48
|
+
return "none";
|
|
49
|
+
}
|
|
50
|
+
const text = String(value).slice(0, MAX_LABEL_LENGTH);
|
|
51
|
+
// Allow a conservative identifier charset only (route ids, modes, decisions
|
|
52
|
+
// are all of this shape). Everything else becomes "_".
|
|
53
|
+
return text.replace(/[^A-Za-z0-9_.:/-]/g, "_") || "none";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function seriesKey(name, labels) {
|
|
57
|
+
const parts = Object.keys(labels)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((labelName) => `${labelName}="${escapeLabel(labels[labelName])}"`);
|
|
60
|
+
return parts.length > 0 ? `${name}{${parts.join(",")}}` : name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escapeLabel(value) {
|
|
64
|
+
return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createMetrics({ buckets = DEFAULT_BUCKETS } = {}) {
|
|
68
|
+
// counterSeries: metricName -> Map(seriesKey -> { labels, value })
|
|
69
|
+
const counterSeries = new Map();
|
|
70
|
+
// histogramSeries: metricName -> Map(seriesKey -> { labels, bucketCounts, sum, count })
|
|
71
|
+
const histogramSeries = new Map();
|
|
72
|
+
const sortedBuckets = [...buckets].sort((a, b) => a - b);
|
|
73
|
+
|
|
74
|
+
function normalizeLabels(labels = {}) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
77
|
+
// Label NAMES are caller-fixed identifiers; coerce defensively anyway.
|
|
78
|
+
const labelName = String(key).replace(/[^A-Za-z0-9_]/g, "_");
|
|
79
|
+
out[labelName] = safeLabelValue(value);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
// Increment a known counter by `amount` (default 1), labelled by a bounded
|
|
86
|
+
// enum set. An unknown metric name is ignored (fail-soft for telemetry — a
|
|
87
|
+
// metric typo must never break a request path).
|
|
88
|
+
increment(name, labels = {}, amount = 1) {
|
|
89
|
+
if (!(name in COUNTERS)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const safe = normalizeLabels(labels);
|
|
93
|
+
const key = seriesKey(name, safe);
|
|
94
|
+
let series = counterSeries.get(name);
|
|
95
|
+
if (!series) {
|
|
96
|
+
series = new Map();
|
|
97
|
+
counterSeries.set(name, series);
|
|
98
|
+
}
|
|
99
|
+
const existing = series.get(key);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.value += amount;
|
|
102
|
+
} else {
|
|
103
|
+
series.set(key, { labels: safe, value: amount });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// Observe a value into a known histogram (request-duration seconds).
|
|
108
|
+
observe(name, value, labels = {}) {
|
|
109
|
+
if (!(name in HISTOGRAMS) || typeof value !== "number" || !Number.isFinite(value)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const safe = normalizeLabels(labels);
|
|
113
|
+
const key = seriesKey(name, safe);
|
|
114
|
+
let series = histogramSeries.get(name);
|
|
115
|
+
if (!series) {
|
|
116
|
+
series = new Map();
|
|
117
|
+
histogramSeries.set(name, series);
|
|
118
|
+
}
|
|
119
|
+
let entry = series.get(key);
|
|
120
|
+
if (!entry) {
|
|
121
|
+
entry = { labels: safe, bucketCounts: new Array(sortedBuckets.length).fill(0), sum: 0, count: 0 };
|
|
122
|
+
series.set(key, entry);
|
|
123
|
+
}
|
|
124
|
+
entry.sum += value;
|
|
125
|
+
entry.count += 1;
|
|
126
|
+
for (let i = 0; i < sortedBuckets.length; i += 1) {
|
|
127
|
+
if (value <= sortedBuckets[i]) {
|
|
128
|
+
entry.bucketCounts[i] += 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// Render the full Prometheus text exposition. Every declared counter and
|
|
134
|
+
// histogram emits its HELP/TYPE header even with no observations, so the
|
|
135
|
+
// surface is stable for scrapers.
|
|
136
|
+
render() {
|
|
137
|
+
const lines = [];
|
|
138
|
+
|
|
139
|
+
for (const [name, help] of Object.entries(COUNTERS)) {
|
|
140
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
141
|
+
lines.push(`# TYPE ${name} counter`);
|
|
142
|
+
const series = counterSeries.get(name);
|
|
143
|
+
if (series) {
|
|
144
|
+
for (const { labels, value } of series.values()) {
|
|
145
|
+
lines.push(`${seriesKey(name, labels)} ${value}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const [name, help] of Object.entries(HISTOGRAMS)) {
|
|
151
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
152
|
+
lines.push(`# TYPE ${name} histogram`);
|
|
153
|
+
const series = histogramSeries.get(name);
|
|
154
|
+
if (series) {
|
|
155
|
+
for (const entry of series.values()) {
|
|
156
|
+
// bucketCounts[i] already holds the cumulative count of observations
|
|
157
|
+
// with value <= sortedBuckets[i] (observe() increments every bucket
|
|
158
|
+
// the value falls under), which is exactly the Prometheus le="..."
|
|
159
|
+
// cumulative bucket semantics — emit it directly.
|
|
160
|
+
for (let i = 0; i < sortedBuckets.length; i += 1) {
|
|
161
|
+
const labels = { ...entry.labels, le: String(sortedBuckets[i]) };
|
|
162
|
+
lines.push(`${seriesKey(`${name}_bucket`, labels)} ${entry.bucketCounts[i]}`);
|
|
163
|
+
}
|
|
164
|
+
const infLabels = { ...entry.labels, le: "+Inf" };
|
|
165
|
+
lines.push(`${seriesKey(`${name}_bucket`, infLabels)} ${entry.count}`);
|
|
166
|
+
lines.push(`${seriesKey(`${name}_sum`, entry.labels)} ${entry.sum}`);
|
|
167
|
+
lines.push(`${seriesKey(`${name}_count`, entry.labels)} ${entry.count}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return `${lines.join("\n")}\n`;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Exported for tests / operators who want to assert the bounded metric surface.
|
|
178
|
+
export const METRIC_NAMES = Object.freeze({
|
|
179
|
+
counters: Object.keys(COUNTERS),
|
|
180
|
+
histograms: Object.keys(HISTOGRAMS)
|
|
181
|
+
});
|
|
@@ -12,7 +12,11 @@ const PROFILES = {
|
|
|
12
12
|
email: "redact",
|
|
13
13
|
card: "block",
|
|
14
14
|
api_key: "block",
|
|
15
|
-
secret: "block"
|
|
15
|
+
secret: "block",
|
|
16
|
+
// A Japan My Number leak is as sensitive as a national ID and is a
|
|
17
|
+
// checksummed true-positive — block it in every profile so a non-JP
|
|
18
|
+
// deployment that happens to process JP data is still covered.
|
|
19
|
+
jp_mynumber: "block"
|
|
16
20
|
}
|
|
17
21
|
},
|
|
18
22
|
transfer: {
|
|
@@ -31,7 +35,17 @@ const PROFILES = {
|
|
|
31
35
|
card: "block",
|
|
32
36
|
api_key: "block",
|
|
33
37
|
secret: "block",
|
|
34
|
-
kr_rrn: "block"
|
|
38
|
+
kr_rrn: "block",
|
|
39
|
+
// EU national IDs — France NIR, Spain DNI/NIE, UK National Insurance
|
|
40
|
+
// Number, Italy codice fiscale, Germany tax ID, Netherlands BSN — are
|
|
41
|
+
// GDPR special-category-adjacent identifiers; block them.
|
|
42
|
+
fr_nir: "block",
|
|
43
|
+
es_dni: "block",
|
|
44
|
+
uk_nino: "block",
|
|
45
|
+
it_codice_fiscale: "block",
|
|
46
|
+
de_steuer_id: "block",
|
|
47
|
+
nl_bsn: "block",
|
|
48
|
+
jp_mynumber: "block"
|
|
35
49
|
}
|
|
36
50
|
},
|
|
37
51
|
transfer: {
|
|
@@ -39,6 +53,37 @@ const PROFILES = {
|
|
|
39
53
|
note: "Treat model/tool transfer as processor/subprocessor transfer and document SCC/TIA evidence outside Haechi."
|
|
40
54
|
}
|
|
41
55
|
},
|
|
56
|
+
"asia-pdpa": {
|
|
57
|
+
id: "asia-pdpa",
|
|
58
|
+
region: "ASIA",
|
|
59
|
+
regulations: ["Singapore PDPA", "India DPDP Act"],
|
|
60
|
+
policy: {
|
|
61
|
+
actions: {
|
|
62
|
+
// Asia national IDs — Singapore NRIC/FIN and India Aadhaar — are sensitive
|
|
63
|
+
// identifiers under the Singapore PDPA / India DPDP Act; block them. The
|
|
64
|
+
// other checksummed national IDs are also blocked so a mixed-region payload
|
|
65
|
+
// is covered, matching the cross-profile convention.
|
|
66
|
+
sg_nric: "block",
|
|
67
|
+
in_aadhaar: "block",
|
|
68
|
+
jp_mynumber: "block",
|
|
69
|
+
kr_rrn: "block",
|
|
70
|
+
fr_nir: "block",
|
|
71
|
+
es_dni: "block",
|
|
72
|
+
it_codice_fiscale: "block",
|
|
73
|
+
de_steuer_id: "block",
|
|
74
|
+
nl_bsn: "block",
|
|
75
|
+
phone: "mask",
|
|
76
|
+
email: "redact",
|
|
77
|
+
card: "block",
|
|
78
|
+
api_key: "block",
|
|
79
|
+
secret: "block"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
transfer: {
|
|
83
|
+
requiresAssessment: true,
|
|
84
|
+
note: "Document the PDPA/DPDP handling basis, purpose limitation, and cross-border transfer notice before production use."
|
|
85
|
+
}
|
|
86
|
+
},
|
|
42
87
|
"us-general": {
|
|
43
88
|
id: "us-general",
|
|
44
89
|
region: "US",
|
|
@@ -49,13 +94,37 @@ const PROFILES = {
|
|
|
49
94
|
phone: "mask",
|
|
50
95
|
card: "block",
|
|
51
96
|
api_key: "block",
|
|
52
|
-
secret: "block"
|
|
97
|
+
secret: "block",
|
|
98
|
+
jp_mynumber: "block"
|
|
53
99
|
}
|
|
54
100
|
},
|
|
55
101
|
transfer: {
|
|
56
102
|
requiresAssessment: false,
|
|
57
103
|
note: "Classify sector rules separately before using protected health, payment, or children's data."
|
|
58
104
|
}
|
|
105
|
+
},
|
|
106
|
+
"jp-appi": {
|
|
107
|
+
id: "jp-appi",
|
|
108
|
+
region: "JP",
|
|
109
|
+
regulations: ["APPI"],
|
|
110
|
+
policy: {
|
|
111
|
+
actions: {
|
|
112
|
+
// My Number (個人番号) is a special-care personal-information identifier
|
|
113
|
+
// under the My Number Act; block it. The EU/KR IDs are also blocked so a
|
|
114
|
+
// mixed-region payload is covered, matching the cross-profile convention.
|
|
115
|
+
jp_mynumber: "block",
|
|
116
|
+
phone: "mask",
|
|
117
|
+
email: "redact",
|
|
118
|
+
card: "block",
|
|
119
|
+
api_key: "block",
|
|
120
|
+
secret: "block",
|
|
121
|
+
kr_rrn: "block"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
transfer: {
|
|
125
|
+
requiresAssessment: true,
|
|
126
|
+
note: "Document the My Number Act handling basis, purpose limitation, and cross-border transfer notice before production use."
|
|
127
|
+
}
|
|
59
128
|
}
|
|
60
129
|
};
|
|
61
130
|
|
|
@@ -8,6 +8,28 @@ const SSE_RESPONSES = { format: "sse", deltaPath: null };
|
|
|
8
8
|
const SSE_LLAMA_LEGACY = { format: "sse", deltaPath: ["content"] };
|
|
9
9
|
const NDJSON_OLLAMA_CHAT = { format: "ndjson", deltaPath: ["message", "content"] };
|
|
10
10
|
const NDJSON_OLLAMA_GENERATE = { format: "ndjson", deltaPath: ["response"] };
|
|
11
|
+
// Anthropic Messages API streams event-typed SSE frames; the incremental text
|
|
12
|
+
// channel is `delta.text` inside a `content_block_delta` frame. Other frame
|
|
13
|
+
// types (message_start, ping, etc.) don't carry deltaPath, so they get
|
|
14
|
+
// within-frame protection but no cross-frame buffering. The stream-filter
|
|
15
|
+
// preserves each frame's `event:` line on re-serialize. `flushOnType` lists the
|
|
16
|
+
// frame types that END a delta sequence: before one of them the held cross-frame
|
|
17
|
+
// buffer tail is flushed as a valid `content_block_delta`, so the residual lands
|
|
18
|
+
// IN ORDER (before content_block_stop/message_stop) rather than after the stream
|
|
19
|
+
// terminates. `ping` is intentionally absent — a match split across a keepalive
|
|
20
|
+
// must still be caught by the sliding buffer. Legacy /v1/complete streams a
|
|
21
|
+
// `completion` delta (no block framing, so no flushOnType needed).
|
|
22
|
+
const SSE_ANTHROPIC_MESSAGES = {
|
|
23
|
+
format: "sse",
|
|
24
|
+
deltaPath: ["delta", "text"],
|
|
25
|
+
flushOnType: { path: ["type"], values: ["content_block_stop", "message_delta", "message_stop"] }
|
|
26
|
+
};
|
|
27
|
+
const SSE_ANTHROPIC_COMPLETE = { format: "sse", deltaPath: ["completion"] };
|
|
28
|
+
// Google Gemini streams :streamGenerateContent as DATA-ONLY SSE (no `event:`
|
|
29
|
+
// lines, like OpenAI). Each `data:` frame is a FULL GenerateContentResponse;
|
|
30
|
+
// the incremental text channel is just deeper. Because frames are data-only,
|
|
31
|
+
// the held cross-frame tail can flush at end-of-stream — no flushOnType needed.
|
|
32
|
+
const SSE_GEMINI = { format: "sse", deltaPath: ["candidates", 0, "content", "parts", 0, "text"] };
|
|
11
33
|
|
|
12
34
|
const ADAPTERS = {
|
|
13
35
|
"openai-compatible": {
|
|
@@ -50,6 +72,47 @@ const ADAPTERS = {
|
|
|
50
72
|
route("/api/embed", "embed"),
|
|
51
73
|
route("/api/embeddings", "embeddings")
|
|
52
74
|
]
|
|
75
|
+
},
|
|
76
|
+
"anthropic": {
|
|
77
|
+
id: "anthropic",
|
|
78
|
+
protocol: "anthropic",
|
|
79
|
+
routes: [
|
|
80
|
+
// Anthropic Messages API. PII can sit in the top-level `system` string/blocks
|
|
81
|
+
// or any `messages[].content` string or content-block text/input — the core
|
|
82
|
+
// tree walk (collectStringEntries) covers every string leaf, so no custom
|
|
83
|
+
// extraction is needed. Streams via content_block_delta `delta.text`.
|
|
84
|
+
route("/v1/messages", "messages", { streaming: SSE_ANTHROPIC_MESSAGES }),
|
|
85
|
+
// count_tokens is a utility, but it carries prompt content, so protect it.
|
|
86
|
+
route("/v1/messages/count_tokens", "count-tokens", { protectRequest: true }),
|
|
87
|
+
// Legacy text completions: `prompt` is a top-level string; streams a `completion` delta.
|
|
88
|
+
route("/v1/complete", "complete", { streaming: SSE_ANTHROPIC_COMPLETE })
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
"gemini": {
|
|
92
|
+
id: "gemini",
|
|
93
|
+
protocol: "gemini",
|
|
94
|
+
routes: [
|
|
95
|
+
// Google Gemini API. Endpoints are MODEL-IN-PATH with a `:method` suffix:
|
|
96
|
+
// POST /v1beta/models/{model}:generateContent (and /v1, and arbitrary
|
|
97
|
+
// model names like gemini-2.0-flash). The route key is therefore the
|
|
98
|
+
// `:method` SUFFIX, not a fixed path — declared via `methodSuffix`, which
|
|
99
|
+
// matchRoute checks only AFTER exact-path matches (so existing adapters
|
|
100
|
+
// are unaffected). PII can sit in systemInstruction.parts[].text and any
|
|
101
|
+
// contents[].parts[].text; the core tree walk (collectStringEntries)
|
|
102
|
+
// covers every string leaf, so no custom extraction is needed.
|
|
103
|
+
suffixRoute("generateContent", "generate-content"),
|
|
104
|
+
// Streaming variant: data-only SSE, full GenerateContentResponse per frame;
|
|
105
|
+
// delta text lives at candidates[0].content.parts[0].text. The :stream*
|
|
106
|
+
// endpoint ALWAYS streams (the intent is in the path, not a body flag), so
|
|
107
|
+
// mark it streamingDefault — there is no `stream:false` body field for
|
|
108
|
+
// Gemini, so isStreamingRequest always classifies it as streaming.
|
|
109
|
+
suffixRoute("streamGenerateContent", "stream-generate-content", { streamingDefault: true, streaming: SSE_GEMINI }),
|
|
110
|
+
// countTokens carries prompt content (contents/systemInstruction), so protect it.
|
|
111
|
+
suffixRoute("countTokens", "count-tokens", { protectRequest: true }),
|
|
112
|
+
// Embedding endpoints: request carries text to embed; protect it.
|
|
113
|
+
suffixRoute("embedContent", "embed", { protectRequest: true }),
|
|
114
|
+
suffixRoute("batchEmbedContents", "batch-embed", { protectRequest: true })
|
|
115
|
+
]
|
|
53
116
|
}
|
|
54
117
|
};
|
|
55
118
|
|
|
@@ -121,10 +184,45 @@ function route(path, operation, options = {}) {
|
|
|
121
184
|
};
|
|
122
185
|
}
|
|
123
186
|
|
|
187
|
+
// A SUFFIX route matches by a `:method` suffix instead of an exact pathname —
|
|
188
|
+
// for model-in-path APIs (Gemini) where the path embeds an arbitrary model name
|
|
189
|
+
// and a version prefix (e.g. /v1beta/models/gemini-2.0-flash:generateContent).
|
|
190
|
+
// `path` stays null (there is no fixed path); `methodSuffix` carries the bare
|
|
191
|
+
// method name and matchRoute matches when the pathname ends with `:${suffix}`.
|
|
192
|
+
// matchRoute tries exact-path routes FIRST, so this never changes existing
|
|
193
|
+
// exact-match behavior for openai/anthropic/ollama/llama-cpp.
|
|
194
|
+
function suffixRoute(methodSuffix, operation, options = {}) {
|
|
195
|
+
return {
|
|
196
|
+
id: operation,
|
|
197
|
+
path: null,
|
|
198
|
+
methodSuffix,
|
|
199
|
+
operation,
|
|
200
|
+
protectRequest: options.protectRequest ?? true,
|
|
201
|
+
protectResponse: options.protectResponse ?? true,
|
|
202
|
+
streamingDefault: options.streamingDefault ?? false,
|
|
203
|
+
streaming: options.streaming ?? null
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
124
207
|
function pathFromRequestUrl(url) {
|
|
125
208
|
return new URL(url, "http://haechi.local").pathname;
|
|
126
209
|
}
|
|
127
210
|
|
|
128
211
|
function matchRoute(routes, pathname) {
|
|
129
|
-
|
|
212
|
+
// 1) EXACT pathname match (unchanged) — the only matcher for openai/anthropic/
|
|
213
|
+
// ollama/llama-cpp, so their classification is byte-for-byte identical.
|
|
214
|
+
const exact = routes.find((candidate) => candidate.path === pathname);
|
|
215
|
+
if (exact) {
|
|
216
|
+
return exact;
|
|
217
|
+
}
|
|
218
|
+
// 2) ADDITIVE: `:method`-SUFFIX match for model-in-path APIs (Gemini). A path
|
|
219
|
+
// like /v1beta/models/gemini-2.0-flash:generateContent matches the route
|
|
220
|
+
// whose methodSuffix the pathname ends with (`...:generateContent`). The
|
|
221
|
+
// `:` guard prevents a bare substring (e.g. a model literally named
|
|
222
|
+
// "generateContent") from matching without the method delimiter.
|
|
223
|
+
return routes.find(
|
|
224
|
+
(candidate) =>
|
|
225
|
+
typeof candidate.methodSuffix === "string" &&
|
|
226
|
+
pathname.endsWith(`:${candidate.methodSuffix}`)
|
|
227
|
+
);
|
|
130
228
|
}
|