babelgate 1.1.1 → 1.2.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.md +116 -28
- package/dist/app.js +5 -5
- package/dist/cli.js +84 -43
- package/dist/config/env-config.js +61 -10
- package/dist/config/gateway.schema.js +50 -0
- package/dist/config/yaml-config.js +164 -0
- package/dist/index.js +6 -4
- package/dist/middleware/detect-source.js +20 -0
- package/dist/middleware/error-handler.js +8 -0
- package/dist/routes/proxy.js +3 -0
- package/dist/routing/detect-source.js +33 -0
- package/dist/routing/fallback.js +37 -0
- package/dist/routing/plan-route.js +47 -0
- package/dist/routing/registry.js +47 -0
- package/dist/routing/route-log.js +8 -0
- package/dist/routing/rules.js +35 -0
- package/dist/routing/table.js +32 -0
- package/dist/services/attempt-targets.js +91 -0
- package/dist/services/chat-stream.service.js +131 -0
- package/dist/services/chat.service.js +91 -0
- package/dist/services/models.service.js +160 -0
- package/dist/services/proxy.service.js +39 -231
- package/dist/transformers/adapters/gemini.adapter.js +7 -3
- package/dist/transformers/adapters/gemini.tools.js +13 -3
- package/dist/transformers/adapters/index.js +1 -2
- package/dist/transformers/adapters/ollama.adapter.js +6 -3
- package/dist/utils/errors.js +6 -0
- package/dist/utils/model-map.js +7 -8
- package/package.json +2 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.proxyChatStream = proxyChatStream;
|
|
4
|
+
const transformers_1 = require("../transformers");
|
|
5
|
+
const registry_1 = require("../routing/registry");
|
|
6
|
+
const route_log_1 = require("../routing/route-log");
|
|
7
|
+
const errors_1 = require("../utils/errors");
|
|
8
|
+
const http_client_1 = require("./http-client");
|
|
9
|
+
const stream_pipeline_1 = require("./stream-pipeline");
|
|
10
|
+
const attempt_targets_1 = require("./attempt-targets");
|
|
11
|
+
/**
|
|
12
|
+
* Streaming chat over a RoutePlan (spec §3.2): per target — encode the
|
|
13
|
+
* canonical copy, postStream to its stream endpoint, pipe through
|
|
14
|
+
* runStreamPipeline. The postStream call resolves on response headers,
|
|
15
|
+
* so the attempt is what the §3.4 fallback decision hangs off: once 2xx
|
|
16
|
+
* headers arrive the target is committed and mid-stream failures end in
|
|
17
|
+
* an in-stream error, never a fallback. A Gemini target uses the
|
|
18
|
+
* route's streamRequestPath (:streamGenerateContent?alt=sse); the
|
|
19
|
+
* others stream on the chat path.
|
|
20
|
+
*/
|
|
21
|
+
async function proxyChatStream(input) {
|
|
22
|
+
const { source, sourceBody, canonical, requestedModel, plan, config, requestId, opts, } = input;
|
|
23
|
+
const encoder = transformers_1.adapters[source].createStreamEncoder({
|
|
24
|
+
sourceRequest: sourceBody,
|
|
25
|
+
query: opts?.query ?? {},
|
|
26
|
+
requestedModel,
|
|
27
|
+
});
|
|
28
|
+
const hops = [];
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
result = await (0, attempt_targets_1.attemptTargets)(plan, config.registry, async (entry, target) => {
|
|
32
|
+
const decoder = transformers_1.adapters[entry.type].createStreamDecoder(target.model);
|
|
33
|
+
const targetBody = transformers_1.adapters[entry.type].fromCanonicalRequest((0, attempt_targets_1.canonicalForTarget)(canonical, entry, target));
|
|
34
|
+
const targetRoute = transformers_1.PROVIDER_ROUTES[entry.type].find((r) => r.kind === "chat");
|
|
35
|
+
const url = `${entry.baseUrl}${targetRoute.streamRequestPath?.(target.model) ??
|
|
36
|
+
targetRoute.requestPath(target.model)}`;
|
|
37
|
+
// Client disconnect aborts the upstream request; upstreamAc lets
|
|
38
|
+
// the pipeline's end (terminal event, failure) tear it down too —
|
|
39
|
+
// a bare body cancel doesn't reach the request on every transport.
|
|
40
|
+
const upstreamAc = new AbortController();
|
|
41
|
+
const upstream = await (0, http_client_1.postStream)({
|
|
42
|
+
url,
|
|
43
|
+
headers: (0, registry_1.authHeaders)(entry),
|
|
44
|
+
body: targetBody,
|
|
45
|
+
timeoutMs: entry.timeoutMs ?? config.upstreamTimeoutMs,
|
|
46
|
+
retries: config.upstreamRetries,
|
|
47
|
+
requestId,
|
|
48
|
+
signal: opts?.signal
|
|
49
|
+
? AbortSignal.any([opts.signal, upstreamAc.signal])
|
|
50
|
+
: upstreamAc.signal,
|
|
51
|
+
}).catch((err) => {
|
|
52
|
+
throw err instanceof errors_1.UpstreamError
|
|
53
|
+
? (0, attempt_targets_1.toClientFacing)(err, entry.type)
|
|
54
|
+
: err;
|
|
55
|
+
});
|
|
56
|
+
return { upstream, upstreamAc, decoder };
|
|
57
|
+
}, { requestId, signal: opts?.signal, hops });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
(0, route_log_1.logRoute)({
|
|
61
|
+
requestId,
|
|
62
|
+
sourceApi: source,
|
|
63
|
+
requestedModel,
|
|
64
|
+
reason: plan.reason,
|
|
65
|
+
hops,
|
|
66
|
+
stream: true,
|
|
67
|
+
});
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
const { upstream, upstreamAc, decoder } = result.value;
|
|
71
|
+
// Watch the event stream for the route log line — the pipeline
|
|
72
|
+
// yields only encoded bytes, so the decoder is wrapped. Chunk contents
|
|
73
|
+
// are never logged (§3.2); only usage and the finish reason are.
|
|
74
|
+
const observed = [];
|
|
75
|
+
const recordingDecoder = {
|
|
76
|
+
feed(chunk) {
|
|
77
|
+
const events = decoder.feed(chunk);
|
|
78
|
+
observed.push(...events);
|
|
79
|
+
return events;
|
|
80
|
+
},
|
|
81
|
+
end() {
|
|
82
|
+
const events = decoder.end();
|
|
83
|
+
observed.push(...events);
|
|
84
|
+
return events;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const signal = opts?.signal ?? new AbortController().signal;
|
|
88
|
+
const chunks = (async function* () {
|
|
89
|
+
try {
|
|
90
|
+
yield* (0, stream_pipeline_1.runStreamPipeline)({
|
|
91
|
+
upstream: upstream.body,
|
|
92
|
+
decoder: recordingDecoder,
|
|
93
|
+
encoder,
|
|
94
|
+
idleMs: result.entry.streamIdleMs ?? config.upstreamStreamIdleMs,
|
|
95
|
+
signal,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
// Whatever ended the pipe — terminal event, in-stream error,
|
|
100
|
+
// client disconnect — the upstream request is done with, and the
|
|
101
|
+
// route log line goes out exactly once (spec §3.4).
|
|
102
|
+
upstreamAc.abort();
|
|
103
|
+
const last = observed.at(-1);
|
|
104
|
+
const usageEvent = observed.find((e) => e.type === "usage");
|
|
105
|
+
(0, route_log_1.logRoute)({
|
|
106
|
+
requestId,
|
|
107
|
+
sourceApi: source,
|
|
108
|
+
requestedModel,
|
|
109
|
+
reason: plan.reason,
|
|
110
|
+
hops: result.hops,
|
|
111
|
+
winner: result.target.provider,
|
|
112
|
+
stream: true,
|
|
113
|
+
inputTokens: usageEvent?.type === "usage"
|
|
114
|
+
? usageEvent.usage.inputTokens
|
|
115
|
+
: undefined,
|
|
116
|
+
outputTokens: usageEvent?.type === "usage"
|
|
117
|
+
? usageEvent.usage.outputTokens
|
|
118
|
+
: undefined,
|
|
119
|
+
finishReason: last?.type === "message_end" ? last.finishReason : undefined,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
return {
|
|
124
|
+
stream: { contentType: encoder.contentType, chunks },
|
|
125
|
+
headers: {
|
|
126
|
+
"x-babelgate-provider": result.target.provider,
|
|
127
|
+
"x-babelgate-model": result.target.model,
|
|
128
|
+
"x-babelgate-attempts": String(result.hops.length),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.proxyChat = proxyChat;
|
|
4
|
+
const transformers_1 = require("../transformers");
|
|
5
|
+
const registry_1 = require("../routing/registry");
|
|
6
|
+
const route_log_1 = require("../routing/route-log");
|
|
7
|
+
const canonical_schema_1 = require("../types/canonical.schema");
|
|
8
|
+
const errors_1 = require("../utils/errors");
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
const http_client_1 = require("./http-client");
|
|
11
|
+
const attempt_targets_1 = require("./attempt-targets");
|
|
12
|
+
/**
|
|
13
|
+
* Non-streaming chat over a RoutePlan: per target — encode with the
|
|
14
|
+
* registry provider's adapter, postJson with its URL, auth and
|
|
15
|
+
* timeouts; decode the reply → model := requestedModel → encode in the
|
|
16
|
+
* client's format (moved from proxy.service, spec §3.1). The route log
|
|
17
|
+
* line and the x-babelgate-* reply headers come from spec §3.4.
|
|
18
|
+
*/
|
|
19
|
+
async function proxyChat(input) {
|
|
20
|
+
const { source, canonical, requestedModel, plan, config, requestId } = input;
|
|
21
|
+
const hops = [];
|
|
22
|
+
let result;
|
|
23
|
+
try {
|
|
24
|
+
result = await (0, attempt_targets_1.attemptTargets)(plan, config.registry, async (entry, target) => {
|
|
25
|
+
const targetBody = transformers_1.adapters[entry.type].fromCanonicalRequest((0, attempt_targets_1.canonicalForTarget)(canonical, entry, target));
|
|
26
|
+
const targetRoute = transformers_1.PROVIDER_ROUTES[entry.type].find((r) => r.kind === "chat");
|
|
27
|
+
const url = `${entry.baseUrl}${targetRoute.requestPath(target.model)}`;
|
|
28
|
+
const upstream = await (0, http_client_1.postJson)({
|
|
29
|
+
url,
|
|
30
|
+
headers: (0, registry_1.authHeaders)(entry),
|
|
31
|
+
body: targetBody,
|
|
32
|
+
timeoutMs: entry.timeoutMs ?? config.upstreamTimeoutMs,
|
|
33
|
+
retries: config.upstreamRetries,
|
|
34
|
+
requestId,
|
|
35
|
+
}).catch((err) => {
|
|
36
|
+
throw err instanceof errors_1.UpstreamError
|
|
37
|
+
? (0, attempt_targets_1.toClientFacing)(err, entry.type)
|
|
38
|
+
: err;
|
|
39
|
+
});
|
|
40
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
41
|
+
const err = new errors_1.UpstreamError(`Upstream ${entry.type} returned ${upstream.status}`, upstream.status, upstream.body);
|
|
42
|
+
err.retryAfterSeconds = upstream.retryAfterSeconds;
|
|
43
|
+
throw (0, attempt_targets_1.toClientFacing)(err, entry.type);
|
|
44
|
+
}
|
|
45
|
+
return upstream;
|
|
46
|
+
}, { requestId, signal: input.opts?.signal, hops });
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
(0, route_log_1.logRoute)({
|
|
50
|
+
requestId,
|
|
51
|
+
sourceApi: source,
|
|
52
|
+
requestedModel,
|
|
53
|
+
reason: plan.reason,
|
|
54
|
+
hops,
|
|
55
|
+
stream: false,
|
|
56
|
+
});
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
// Target → Canonical → Source. §3.5: replies carry the model name the
|
|
60
|
+
// client asked for, not the routed name sent upstream.
|
|
61
|
+
const canonicalResp = transformers_1.adapters[result.entry.type].toCanonicalResponse(result.value.body, result.target.model);
|
|
62
|
+
canonicalResp.model = requestedModel;
|
|
63
|
+
// Adapter bug detection (non-production only): canonical response
|
|
64
|
+
// should always be schema-valid
|
|
65
|
+
if (process.env.NODE_ENV !== "production") {
|
|
66
|
+
const respParsed = canonical_schema_1.canonicalResponseSchema.safeParse(canonicalResp);
|
|
67
|
+
if (!respParsed.success) {
|
|
68
|
+
logger_1.logger.error({ requestId, issues: respParsed.error.issues }, "Canonical response failed schema validation — adapter bug");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
(0, route_log_1.logRoute)({
|
|
72
|
+
requestId,
|
|
73
|
+
sourceApi: source,
|
|
74
|
+
requestedModel,
|
|
75
|
+
reason: plan.reason,
|
|
76
|
+
hops: result.hops,
|
|
77
|
+
winner: result.target.provider,
|
|
78
|
+
stream: false,
|
|
79
|
+
inputTokens: canonicalResp.usage?.inputTokens,
|
|
80
|
+
outputTokens: canonicalResp.usage?.outputTokens,
|
|
81
|
+
finishReason: canonicalResp.finishReason,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
body: transformers_1.adapters[source].fromCanonicalResponse(canonicalResp),
|
|
85
|
+
headers: {
|
|
86
|
+
"x-babelgate-provider": result.target.provider,
|
|
87
|
+
"x-babelgate-model": result.target.model,
|
|
88
|
+
"x-babelgate-attempts": String(result.hops.length),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createModelListCache = createModelListCache;
|
|
4
|
+
exports.resetModelListCache = resetModelListCache;
|
|
5
|
+
exports.proxyModelList = proxyModelList;
|
|
6
|
+
const transformers_1 = require("../transformers");
|
|
7
|
+
const registry_1 = require("../routing/registry");
|
|
8
|
+
const fallback_1 = require("../routing/fallback");
|
|
9
|
+
const canonical_schema_1 = require("../types/canonical.schema");
|
|
10
|
+
const errors_1 = require("../utils/errors");
|
|
11
|
+
const logger_1 = require("../utils/logger");
|
|
12
|
+
const http_client_1 = require("./http-client");
|
|
13
|
+
const attempt_targets_1 = require("./attempt-targets");
|
|
14
|
+
/** spec §3.6: each provider's model list is cached in memory for 60 s. */
|
|
15
|
+
const MODEL_LIST_TTL_MS = 60_000;
|
|
16
|
+
function createModelListCache(ttlMs = MODEL_LIST_TTL_MS, now = Date.now) {
|
|
17
|
+
const entries = new Map();
|
|
18
|
+
return {
|
|
19
|
+
get(provider, baseUrl) {
|
|
20
|
+
const hit = entries.get(`${provider} ${baseUrl}`);
|
|
21
|
+
if (hit === undefined || now() - hit.fetchedAt >= ttlMs) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return hit.list;
|
|
25
|
+
},
|
|
26
|
+
set(provider, baseUrl, list) {
|
|
27
|
+
entries.set(`${provider} ${baseUrl}`, { list, fetchedAt: now() });
|
|
28
|
+
},
|
|
29
|
+
clear() {
|
|
30
|
+
entries.clear();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const sharedCache = createModelListCache();
|
|
35
|
+
/** Tests reset the process-wide cache between cases. */
|
|
36
|
+
function resetModelListCache() {
|
|
37
|
+
sharedCache.clear();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One provider's model list, decoded by that provider's own adapter
|
|
41
|
+
* into canonical. Non-2xx upstream replies become an UpstreamError
|
|
42
|
+
* carrying upstreamStatus, exactly as the v1.1 single-provider flow.
|
|
43
|
+
*/
|
|
44
|
+
async function fetchProviderModels(entry, source, config, requestId) {
|
|
45
|
+
const targetRoute = transformers_1.PROVIDER_ROUTES[entry.type].find((r) => r.kind === "models");
|
|
46
|
+
const url = `${entry.baseUrl}${targetRoute.requestPath()}`;
|
|
47
|
+
logger_1.logger.info({
|
|
48
|
+
requestId,
|
|
49
|
+
sourceApi: source,
|
|
50
|
+
provider: entry.name,
|
|
51
|
+
targetApi: entry.type,
|
|
52
|
+
url,
|
|
53
|
+
}, "Forwarding models list request to upstream");
|
|
54
|
+
const upstream = await (0, http_client_1.getJson)({
|
|
55
|
+
url,
|
|
56
|
+
headers: (0, registry_1.authHeaders)(entry),
|
|
57
|
+
timeoutMs: entry.timeoutMs ?? config.upstreamTimeoutMs,
|
|
58
|
+
retries: config.upstreamRetries,
|
|
59
|
+
requestId,
|
|
60
|
+
});
|
|
61
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
62
|
+
const err = new errors_1.UpstreamError(`Upstream ${entry.type} returned ${upstream.status}`, upstream.status, upstream.body);
|
|
63
|
+
err.retryAfterSeconds = upstream.retryAfterSeconds;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
return transformers_1.adapters[entry.type].toCanonicalModelList(upstream.body);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* GET /models flow, spec §3.6: every registered provider's list is
|
|
70
|
+
* fetched in parallel (60 s cache, successful fetches only), merged
|
|
71
|
+
* and encoded once in the client's format. Order: aliases as bare
|
|
72
|
+
* names; the default provider's models bare (the v1.1 list); every
|
|
73
|
+
* other provider's models as <provider>/<model>. First occurrence
|
|
74
|
+
* wins on a duplicate id. A failed provider is omitted with a warn
|
|
75
|
+
* and x-babelgate-partial: true; if every provider fails, the default
|
|
76
|
+
* provider's error is returned exactly as v1.1 did.
|
|
77
|
+
*/
|
|
78
|
+
async function proxyModelList(source, route, config, requestId) {
|
|
79
|
+
const names = config.registry.names();
|
|
80
|
+
const defaultName = config.routing.defaultProvider;
|
|
81
|
+
const fetchCached = async (entry) => {
|
|
82
|
+
const cached = sharedCache.get(entry.name, entry.baseUrl);
|
|
83
|
+
if (cached !== undefined)
|
|
84
|
+
return cached;
|
|
85
|
+
const list = await fetchProviderModels(entry, source, config, requestId);
|
|
86
|
+
sharedCache.set(entry.name, entry.baseUrl, list);
|
|
87
|
+
return list;
|
|
88
|
+
};
|
|
89
|
+
const settled = await Promise.allSettled(names.map((name) => fetchCached(config.registry.get(name))));
|
|
90
|
+
const failed = settled
|
|
91
|
+
.map((r, i) => r.status === "rejected" ? { name: names[i], reason: r.reason } : null)
|
|
92
|
+
.filter((f) => f !== null);
|
|
93
|
+
if (failed.length === names.length) {
|
|
94
|
+
const reason = settled[names.indexOf(defaultName)].reason;
|
|
95
|
+
const entry = config.registry.get(defaultName);
|
|
96
|
+
throw reason instanceof errors_1.UpstreamError
|
|
97
|
+
? (0, attempt_targets_1.toClientFacing)(reason, entry.type)
|
|
98
|
+
: reason;
|
|
99
|
+
}
|
|
100
|
+
// One warn per failed provider: name and status/outcome only —
|
|
101
|
+
// never a key or a response body.
|
|
102
|
+
for (const f of failed) {
|
|
103
|
+
const err = f.reason;
|
|
104
|
+
logger_1.logger.warn({
|
|
105
|
+
requestId,
|
|
106
|
+
provider: f.name,
|
|
107
|
+
outcome: err instanceof errors_1.UpstreamError
|
|
108
|
+
? ((0, fallback_1.hopOutcome)(err) ?? "error")
|
|
109
|
+
: "error",
|
|
110
|
+
...(err instanceof errors_1.UpstreamError &&
|
|
111
|
+
err.upstreamStatus !== undefined
|
|
112
|
+
? { status: err.upstreamStatus }
|
|
113
|
+
: {}),
|
|
114
|
+
}, "Model list fetch failed; provider omitted");
|
|
115
|
+
}
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const merged = [];
|
|
118
|
+
const push = (model, id) => {
|
|
119
|
+
if (seen.has(id))
|
|
120
|
+
return;
|
|
121
|
+
seen.add(id);
|
|
122
|
+
merged.push({ ...model, id });
|
|
123
|
+
};
|
|
124
|
+
for (const alias of Object.keys(config.routing.aliases)) {
|
|
125
|
+
push({ id: alias }, alias);
|
|
126
|
+
}
|
|
127
|
+
const pushProvider = (name, prefix) => {
|
|
128
|
+
const r = settled[names.indexOf(name)];
|
|
129
|
+
if (r?.status !== "fulfilled")
|
|
130
|
+
return;
|
|
131
|
+
for (const m of r.value.models) {
|
|
132
|
+
push(m, prefix ? `${name}/${m.id}` : m.id);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
pushProvider(defaultName, false);
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
if (name !== defaultName)
|
|
138
|
+
pushProvider(name, true);
|
|
139
|
+
}
|
|
140
|
+
const mergedList = { models: merged };
|
|
141
|
+
if (process.env.NODE_ENV !== "production") {
|
|
142
|
+
const parsed = canonical_schema_1.canonicalModelListSchema.safeParse(mergedList);
|
|
143
|
+
if (!parsed.success) {
|
|
144
|
+
logger_1.logger.error({ requestId, issues: parsed.error.issues }, "Canonical model list failed schema validation — adapter bug");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
logger_1.logger.info({
|
|
148
|
+
requestId,
|
|
149
|
+
sourceApi: source,
|
|
150
|
+
modelCount: merged.length,
|
|
151
|
+
...(failed.length > 0 ? { partial: true } : {}),
|
|
152
|
+
}, "Models list completed");
|
|
153
|
+
const result = {
|
|
154
|
+
body: transformers_1.adapters[source].fromCanonicalModelList(mergedList),
|
|
155
|
+
};
|
|
156
|
+
if (failed.length > 0) {
|
|
157
|
+
result.headers = { "x-babelgate-partial": "true" };
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|