modelos-secure 0.1.1 → 0.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/hook/clients.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // The plain model clients: the prompt canary going in, and what the model asked for coming out.
2
2
  // Whatever happens here, the app's own call goes ahead as if the hook were not there.
3
3
 
4
+ import { sdkContext } from "./edge.mjs";
5
+
4
6
  function safely(work, fallback) {
5
7
  try {
6
8
  return work();
@@ -198,7 +200,9 @@ export function clientPatches({ canaryLine, seeModelCall }) {
198
200
  const original = prototype.create;
199
201
  prototype.create = function (body, options) {
200
202
  const prepared = safely(() => withCanary(body, canaryLine()), body);
201
- const result = original.call(this, prepared, options);
203
+ const result = sdkContext.run(true, () =>
204
+ original.call(this, prepared, options),
205
+ );
202
206
  const see = (data) => {
203
207
  if (body?.stream && data?.[Symbol.asyncIterator])
204
208
  return safely(
@@ -229,7 +233,9 @@ export function clientPatches({ canaryLine, seeModelCall }) {
229
233
  () => genAiWithCanary(params, canaryLine()),
230
234
  params,
231
235
  );
232
- const response = await original.call(this, prepared);
236
+ const response = await sdkContext.run(true, () =>
237
+ original.call(this, prepared),
238
+ );
233
239
  safely(() => report(params?.model, readGenAi(response)));
234
240
  return response;
235
241
  };
@@ -241,7 +247,9 @@ export function clientPatches({ canaryLine, seeModelCall }) {
241
247
  () => genAiWithCanary(params, canaryLine()),
242
248
  params,
243
249
  );
244
- const stream = await original.call(this, prepared);
250
+ const stream = await sdkContext.run(true, () =>
251
+ original.call(this, prepared),
252
+ );
245
253
  return safely(
246
254
  () => watchStream(stream, params?.model, genAiStreamReader()),
247
255
  stream,
package/hook/edge.mjs ADDED
@@ -0,0 +1,405 @@
1
+ // The edge: every framework ends at an HTTP call to a model, so the hook watches there too, below
2
+ // whatever framework the app uses. During an attempt it sees each model call (the tools offered, the
3
+ // tool calls the model makes and their arguments), adds the prompt canary, plants attacker text into
4
+ // a tool result, and stops every other call that leaves the machine, answering it as if it worked and
5
+ // recording it. A tool known to run, such as a read against a staging copy, keeps its calls. Outside
6
+ // an attempt it does nothing. No dependencies, Node 20 and later. ADR 0062.
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+ import http from "node:http";
9
+ import https from "node:https";
10
+ import { URL } from "node:url";
11
+ import { allowedHost } from "./hosts.mjs";
12
+
13
+ /** Which tool is running, so its own calls can be told apart from calls nobody declared. */
14
+ export const toolContext = new AsyncLocalStorage();
15
+ /** Set while a model client the hook already patches is calling, so the call is counted once. */
16
+ export const sdkContext = new AsyncLocalStorage();
17
+
18
+ const MODEL_PATHS = [
19
+ [/\/chat\/completions$/, "chat"],
20
+ [/\/responses$/, "responses"],
21
+ [/\/messages$/, "anthropic"],
22
+ [/:(stream)?[gG]enerateContent$/, "gemini"],
23
+ [/\/api\/chat$/, "ollama"],
24
+ ];
25
+
26
+ function safely(work, fallback) {
27
+ try {
28
+ return work();
29
+ } catch {
30
+ return fallback;
31
+ }
32
+ }
33
+
34
+ const parseArgs = (value) =>
35
+ value && typeof value === "object"
36
+ ? value
37
+ : safely(() => JSON.parse(value), { raw: String(value ?? "") });
38
+
39
+ /** The API a request speaks, from its path and body, or null when it is not a model call. */
40
+ export function modelShape(url, body) {
41
+ if (!body || typeof body !== "object") return null;
42
+ for (const [pattern, shape] of MODEL_PATHS)
43
+ if (pattern.test(url.pathname)) return shape;
44
+ if (Array.isArray(body.messages) && typeof body.model === "string")
45
+ return "chat";
46
+ if (Array.isArray(body.contents)) return "gemini";
47
+ return null;
48
+ }
49
+
50
+ const withLine = (content, line) =>
51
+ typeof content === "string"
52
+ ? `${content}\n${line}`
53
+ : Array.isArray(content)
54
+ ? [...content, { type: "text", text: line }]
55
+ : content;
56
+
57
+ /** The canary goes into the system prompt, whatever the API calls it, once. */
58
+ function addCanary(shape, body, line) {
59
+ if (!line || JSON.stringify(body).includes(line)) return body;
60
+ if (shape === "chat" || shape === "ollama") {
61
+ const i = body.messages.findIndex(
62
+ (m) => m?.role === "system" || m?.role === "developer",
63
+ );
64
+ if (i === -1) return body;
65
+ const messages = [...body.messages];
66
+ messages[i] = {
67
+ ...messages[i],
68
+ content: withLine(messages[i].content, line),
69
+ };
70
+ return { ...body, messages };
71
+ }
72
+ if (shape === "responses" && typeof body.instructions === "string")
73
+ return { ...body, instructions: `${body.instructions}\n${line}` };
74
+ if (shape === "anthropic" && body.system)
75
+ return {
76
+ ...body,
77
+ system:
78
+ typeof body.system === "string"
79
+ ? `${body.system}\n${line}`
80
+ : [...body.system, { type: "text", text: line }],
81
+ };
82
+ if (shape === "gemini") {
83
+ const instruction = body.systemInstruction ?? body.system_instruction;
84
+ if (!instruction?.parts) return body;
85
+ return {
86
+ ...body,
87
+ systemInstruction: {
88
+ ...instruction,
89
+ parts: [...instruction.parts, { text: line }],
90
+ },
91
+ };
92
+ }
93
+ return body;
94
+ }
95
+
96
+ /** The tool results in a request, with the tool each answers, so a plant lands where it reads. */
97
+ function plantIntoResults(shape, body, tool, text) {
98
+ let delivered = false;
99
+ const named = new Map();
100
+ const plantText = (value) => {
101
+ delivered = true;
102
+ return typeof value === "string"
103
+ ? `${value}\n\n${text}`
104
+ : Array.isArray(value)
105
+ ? [...value, { type: "text", text }]
106
+ : { ...value, note: text };
107
+ };
108
+ if (shape === "chat" || shape === "ollama") {
109
+ for (const m of body.messages)
110
+ for (const call of m?.tool_calls ?? [])
111
+ named.set(call.id, call.function?.name);
112
+ const messages = body.messages.map((m) =>
113
+ m?.role === "tool" &&
114
+ (named.get(m.tool_call_id) === tool || m.name === tool)
115
+ ? { ...m, content: plantText(m.content) }
116
+ : m,
117
+ );
118
+ return { body: { ...body, messages }, delivered };
119
+ }
120
+ if (shape === "anthropic") {
121
+ for (const m of body.messages ?? [])
122
+ for (const part of Array.isArray(m.content) ? m.content : [])
123
+ if (part.type === "tool_use") named.set(part.id, part.name);
124
+ const messages = (body.messages ?? []).map((m) =>
125
+ Array.isArray(m.content)
126
+ ? {
127
+ ...m,
128
+ content: m.content.map((part) =>
129
+ part.type === "tool_result" &&
130
+ named.get(part.tool_use_id) === tool
131
+ ? { ...part, content: plantText(part.content) }
132
+ : part,
133
+ ),
134
+ }
135
+ : m,
136
+ );
137
+ return { body: { ...body, messages }, delivered };
138
+ }
139
+ if (shape === "gemini") {
140
+ const contents = body.contents.map((c) => ({
141
+ ...c,
142
+ parts: (c.parts ?? []).map((p) =>
143
+ p.functionResponse?.name === tool
144
+ ? {
145
+ ...p,
146
+ functionResponse: {
147
+ ...p.functionResponse,
148
+ response: plantText(p.functionResponse.response ?? {}),
149
+ },
150
+ }
151
+ : p,
152
+ ),
153
+ }));
154
+ return { body: { ...body, contents }, delivered };
155
+ }
156
+ if (shape === "responses" && Array.isArray(body.input)) {
157
+ for (const item of body.input)
158
+ if (item.type === "function_call") named.set(item.call_id, item.name);
159
+ const input = body.input.map((item) =>
160
+ item.type === "function_call_output" && named.get(item.call_id) === tool
161
+ ? { ...item, output: plantText(item.output) }
162
+ : item,
163
+ );
164
+ return { body: { ...body, input }, delivered };
165
+ }
166
+ return { body, delivered };
167
+ }
168
+
169
+ /** The tools offered and the calls asked for, in any of the APIs above. */
170
+ export function readModel(shape, body, answer) {
171
+ const offered = [
172
+ ...(body.tools ?? []).map((t) => t.function?.name ?? t.name),
173
+ ...(body.tools ?? []).flatMap((t) =>
174
+ (t.functionDeclarations ?? t.function_declarations ?? []).map(
175
+ (d) => d.name,
176
+ ),
177
+ ),
178
+ ].filter(Boolean);
179
+ const calls = [];
180
+ for (const choice of answer.choices ?? [])
181
+ for (const call of choice.message?.tool_calls ?? [])
182
+ calls.push({
183
+ tool: call.function?.name,
184
+ args: parseArgs(call.function?.arguments),
185
+ });
186
+ for (const call of answer.message?.tool_calls ?? [])
187
+ calls.push({
188
+ tool: call.function?.name,
189
+ args: parseArgs(call.function?.arguments),
190
+ });
191
+ for (const item of answer.output ?? [])
192
+ if (item.type === "function_call")
193
+ calls.push({ tool: item.name, args: parseArgs(item.arguments) });
194
+ for (const part of Array.isArray(answer.content) ? answer.content : [])
195
+ if (part.type === "tool_use")
196
+ calls.push({ tool: part.name, args: part.input ?? {} });
197
+ for (const candidate of answer.candidates ?? [])
198
+ for (const part of candidate.content?.parts ?? [])
199
+ if (part.functionCall)
200
+ calls.push({
201
+ tool: part.functionCall.name,
202
+ args: part.functionCall.args ?? {},
203
+ });
204
+ const usage = answer.usage ?? answer.usageMetadata ?? {};
205
+ return {
206
+ model: body.model ?? answer.model,
207
+ offered,
208
+ calls: calls.filter((c) => c.tool),
209
+ inputTokens:
210
+ usage.prompt_tokens ?? usage.input_tokens ?? usage.promptTokenCount ?? 0,
211
+ outputTokens:
212
+ usage.completion_tokens ??
213
+ usage.output_tokens ??
214
+ usage.candidatesTokenCount ??
215
+ 0,
216
+ };
217
+ }
218
+
219
+ /** A streamed answer, read event by event into the same shape as a whole one. */
220
+ export function readStream(shape, body, text) {
221
+ const events = text
222
+ .split("\n")
223
+ .filter((l) => l.startsWith("data:"))
224
+ .map((l) => safely(() => JSON.parse(l.slice(5).trim()), null))
225
+ .filter(Boolean);
226
+ const pieces = new Map();
227
+ const whole = { choices: [], content: [], candidates: [], output: [] };
228
+ for (const event of events) {
229
+ for (const choice of event.choices ?? [])
230
+ for (const delta of choice.delta?.tool_calls ?? []) {
231
+ const piece = pieces.get(`c${delta.index}`) ?? { name: "", args: "" };
232
+ piece.name += delta.function?.name ?? "";
233
+ piece.args += delta.function?.arguments ?? "";
234
+ pieces.set(`c${delta.index}`, piece);
235
+ }
236
+ if (
237
+ event.type === "content_block_start" &&
238
+ event.content_block?.type === "tool_use"
239
+ )
240
+ pieces.set(`a${event.index}`, {
241
+ name: event.content_block.name,
242
+ args: "",
243
+ });
244
+ if (event.delta?.type === "input_json_delta") {
245
+ const piece = pieces.get(`a${event.index}`);
246
+ if (piece) piece.args += event.delta.partial_json ?? "";
247
+ }
248
+ if (event.candidates) whole.candidates.push(...event.candidates);
249
+ if (event.type === "response.completed")
250
+ whole.output = event.response?.output ?? [];
251
+ if (event.usage || event.usageMetadata)
252
+ whole.usage = event.usage ?? event.usageMetadata;
253
+ }
254
+ for (const piece of pieces.values())
255
+ whole.choices.push({
256
+ message: {
257
+ tool_calls: [{ function: { name: piece.name, arguments: piece.args } }],
258
+ },
259
+ });
260
+ return readModel(shape, body, whole);
261
+ }
262
+
263
+ /**
264
+ * Installs the edge. `hook` gives the attempt's state, the config's tools and ways in, what the
265
+ * person allowed through, and where a held call is recorded.
266
+ */
267
+ export function installEdge(hook) {
268
+ const allowed = (host) => allowedHost(host, hook.allow);
269
+ /** Inside a tool the person said may run, its calls go through, to a staging copy. */
270
+ const insideRunningTool = () => hook.mode(toolContext.getStore()) === "run";
271
+
272
+ function hold(method, url, bytes) {
273
+ hook
274
+ .state()
275
+ .edge.push({ method, host: url.hostname, path: url.pathname, bytes });
276
+ }
277
+
278
+ function seeModel(shape, body, answer, streamed, counted) {
279
+ const read = streamed
280
+ ? readStream(shape, body, answer)
281
+ : readModel(shape, body, answer);
282
+ const state = hook.state();
283
+ if (!counted)
284
+ state.modelCalls.push({
285
+ model: read.model,
286
+ toolCalls: read.calls,
287
+ offered: read.offered,
288
+ inputTokens: read.inputTokens,
289
+ outputTokens: read.outputTokens,
290
+ edge: true,
291
+ });
292
+ // A call to a tool marked held that no function-level hook caught: what the model asked for
293
+ // is the evidence, and the edge stops what the tool does.
294
+ for (const call of read.calls)
295
+ if (
296
+ hook.mode(call.tool) === "hold" &&
297
+ !state.held.some((h) => h.tool === call.tool)
298
+ )
299
+ state.held.push({ tool: call.tool, args: call.args, at: "edge" });
300
+ }
301
+
302
+ /** What a model call carries in, during an attempt: the canary, and a plant at a tool result. */
303
+ function prepare(shape, body) {
304
+ const state = hook.state();
305
+ let prepared = addCanary(shape, body, hook.canaryLine());
306
+ const plant = state.attempt?.plant;
307
+ const way = plant && hook.wayIn(plant.wayIn);
308
+ if (plant && way?.function && !state.plantDelivered) {
309
+ const planted = plantIntoResults(
310
+ shape,
311
+ prepared,
312
+ way.function,
313
+ plant.text,
314
+ );
315
+ prepared = planted.body;
316
+ if (planted.delivered) state.plantDelivered = true;
317
+ }
318
+ return prepared;
319
+ }
320
+
321
+ const original = globalThis.fetch;
322
+ if (original)
323
+ globalThis.fetch = async function modelosSecureFetch(input, init) {
324
+ if (!hook.state().attempt) return original(input, init);
325
+ const url = new URL(
326
+ typeof input === "string" || input instanceof URL ? input : input.url,
327
+ );
328
+ const text = typeof init?.body === "string" ? init.body : undefined;
329
+ const body = text ? safely(() => JSON.parse(text), null) : null;
330
+ const shape = modelShape(url, body);
331
+ if (shape) {
332
+ const counted = Boolean(sdkContext.getStore());
333
+ const response = await original(input, {
334
+ ...init,
335
+ body: JSON.stringify(prepare(shape, body)),
336
+ });
337
+ const streamed =
338
+ body.stream === true || /stream/i.test(url.pathname + url.search);
339
+ void response
340
+ .clone()
341
+ .text()
342
+ .then((answer) =>
343
+ safely(() =>
344
+ seeModel(
345
+ shape,
346
+ body,
347
+ streamed ? answer : JSON.parse(answer),
348
+ streamed,
349
+ counted,
350
+ ),
351
+ ),
352
+ )
353
+ .catch(() => undefined);
354
+ return response;
355
+ }
356
+ if (allowed(url.hostname) || insideRunningTool())
357
+ return original(input, init);
358
+ hold(init?.method ?? "GET", url, text?.length ?? 0);
359
+ return new globalThis.Response(
360
+ JSON.stringify({ status: "ok", id: `held-${Date.now()}` }),
361
+ {
362
+ status: 200,
363
+ headers: { "content-type": "application/json" },
364
+ },
365
+ );
366
+ };
367
+
368
+ // Clients built on http and https, such as axios and older SDKs: a call that would leave goes to
369
+ // the hook's own port instead, which answers as if it worked.
370
+ for (const module of [http, https]) {
371
+ for (const name of ["request", "get"]) {
372
+ const real = module[name];
373
+ module[name] = function modelosSecureRequest(...args) {
374
+ const state = hook.state();
375
+ const port = hook.controlPort();
376
+ if (!state.attempt || !port) return real.apply(this, args);
377
+ const target = safely(() => requestUrl(module, args), null);
378
+ if (!target || allowed(target.hostname) || insideRunningTool())
379
+ return real.apply(this, args);
380
+ hold(requestMethod(args), target, 0);
381
+ return http[name](
382
+ `http://127.0.0.1:${port}/held`,
383
+ ...args.filter((a) => typeof a === "function"),
384
+ );
385
+ };
386
+ }
387
+ }
388
+ }
389
+
390
+ function requestUrl(module, args) {
391
+ const [first, second] = args;
392
+ if (typeof first === "string" || first instanceof URL) return new URL(first);
393
+ const options = first ?? second ?? {};
394
+ const protocol = options.protocol ?? (module === https ? "https:" : "http:");
395
+ return new URL(
396
+ `${protocol}//${options.hostname ?? options.host ?? "localhost"}${options.path ?? "/"}`,
397
+ );
398
+ }
399
+
400
+ function requestMethod(args) {
401
+ const options = args.find(
402
+ (a) => a && typeof a === "object" && !(a instanceof URL),
403
+ );
404
+ return options?.method ?? "GET";
405
+ }
package/hook/hook.mjs CHANGED
@@ -8,6 +8,7 @@ import nodeModule from "node:module";
8
8
  import { pathToFileURL, URL } from "node:url";
9
9
  import { isMainThread } from "node:worker_threads";
10
10
  import { clientPatches } from "./clients.mjs";
11
+ import { installEdge, sdkContext, toolContext } from "./edge.mjs";
11
12
  import { rewriteLoaded, rewriteSource, urlToLoad } from "./rewrite.mjs";
12
13
 
13
14
  const raw = process.env.MODELOS_SECURE_HOOK;
@@ -19,6 +20,8 @@ if (raw && isMainThread) {
19
20
  const waysIn = new Map(
20
21
  config.waysIn.filter((w) => w.function).map((w) => [w.function, w]),
21
22
  );
23
+ const waysByName = new Map(config.waysIn.map((w) => [w.name, w]));
24
+ let controlPort = 0;
22
25
 
23
26
  let state = freshState();
24
27
  function freshState() {
@@ -29,6 +32,7 @@ if (raw && isMainThread) {
29
32
  ran: [],
30
33
  plantDelivered: false,
31
34
  retrieved: [],
35
+ edge: [],
32
36
  };
33
37
  }
34
38
 
@@ -138,7 +142,7 @@ if (raw && isMainThread) {
138
142
  return heldResult(name);
139
143
  }
140
144
  if (tool) state.ran.push(name);
141
- const result = original.apply(self, args);
145
+ const result = toolContext.run(name, () => original.apply(self, args));
142
146
  if (result && typeof result.then === "function")
143
147
  return result.then((value) => afterRun(name, value));
144
148
  return afterRun(name, result);
@@ -181,11 +185,13 @@ if (raw && isMainThread) {
181
185
  ];
182
186
  }),
183
187
  );
184
- const result = original({
185
- ...options,
186
- tools,
187
- system: promptWithCanary(options.system),
188
- });
188
+ const result = sdkContext.run(true, () =>
189
+ original({
190
+ ...options,
191
+ tools,
192
+ system: promptWithCanary(options.system),
193
+ }),
194
+ );
189
195
  if (result && typeof result.then === "function") {
190
196
  return result.then((value) => {
191
197
  for (const step of value.steps ?? []) {
@@ -210,6 +216,15 @@ if (raw && isMainThread) {
210
216
  ...clientPatches({ canaryLine, seeModelCall }),
211
217
  };
212
218
 
219
+ installEdge({
220
+ state: () => state,
221
+ canaryLine,
222
+ mode: (name) => toolsByName.get(name)?.mode,
223
+ wayIn: (name) => waysByName.get(name),
224
+ allow: config.edge?.allow ?? [],
225
+ controlPort: () => controlPort,
226
+ });
227
+
213
228
  // Source rewriting, so nothing in the app's code changes.
214
229
  const options = {
215
230
  held: config.tools
@@ -257,22 +272,35 @@ if (raw && isMainThread) {
257
272
  ran,
258
273
  plantDelivered,
259
274
  retrieved,
275
+ edge,
260
276
  } = state;
261
277
  state = freshState();
278
+ // The edge sees a tool call before its function runs. Where a hook then held the function
279
+ // itself, that record is the one kept, so a call counts once.
280
+ const byHook = new Set(
281
+ heldCalls.filter((h) => h.at !== "edge").map((h) => h.tool),
282
+ );
262
283
  return send({
263
- heldCalls,
284
+ heldCalls: heldCalls.filter(
285
+ (h) => h.at !== "edge" || !byHook.has(h.tool),
286
+ ),
264
287
  modelCalls,
265
288
  ranTools: ran,
266
289
  plantDelivered,
267
290
  retrieved,
291
+ edgeHeld: edge,
268
292
  });
269
293
  }
294
+ // A call the edge stopped lands here, and is answered as if it worked.
295
+ if (request.url === "/held")
296
+ return send({ status: "ok", id: `held-${Date.now().toString()}` });
270
297
  if (request.url === "/hello") return send({ ok: true, hook: "node" });
271
298
  response.writeHead(404);
272
299
  response.end();
273
300
  });
274
301
  server.listen(0, "127.0.0.1", () => {
275
302
  const { port } = server.address();
303
+ controlPort = port;
276
304
  server.unref();
277
305
  fetch(`${process.env.MODELOS_SECURE_RUNNER_URL}/ready`, {
278
306
  method: "POST",
@@ -0,0 +1,2 @@
1
+ /** Whether a call to this host may leave during an attempt. */
2
+ export function allowedHost(host: string, allow?: string[]): boolean;
package/hook/hosts.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // The hosts a review lets an app reach: its own machine, model providers and the sign-in hosts
2
+ // their clients need, and what the person allowed in modelos-secure.yaml. Shared by the in-process
3
+ // edge and the runner's proxy, so both stop the same calls. ADR 0062.
4
+
5
+ const MODEL_HOSTS = [
6
+ /(^|\.)api\.openai\.com$/,
7
+ /\.openai\.azure\.com$/,
8
+ /(^|\.)api\.anthropic\.com$/,
9
+ /(^|\.)generativelanguage\.googleapis\.com$/,
10
+ /aiplatform\.googleapis\.com$/,
11
+ /^(oauth2|sts|iamcredentials)\.googleapis\.com$/,
12
+ /^bedrock-runtime\.[\w-]+\.amazonaws\.com$/,
13
+ /^(api\.mistral\.ai|api\.groq\.com|api\.cohere\.com|openrouter\.ai|api\.together\.xyz|api\.deepseek\.com|api\.x\.ai|api\.fireworks\.ai|router\.huggingface\.co|api-inference\.huggingface\.co)$/,
14
+ /^login\.microsoftonline\.com$/,
15
+ ];
16
+ const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0)$/;
17
+
18
+ /** Whether a call to this host may leave during an attempt. */
19
+ export function allowedHost(host, allow = []) {
20
+ const name = String(host).toLowerCase();
21
+ return (
22
+ LOOPBACK.test(name) ||
23
+ MODEL_HOSTS.some((p) => p.test(name)) ||
24
+ allow.some((a) => name === a || name.endsWith(`.${a}`))
25
+ );
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelos-secure",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -3,6 +3,7 @@
3
3
  Whatever happens here, the app's own call goes ahead as if the hook were not there.
4
4
  """
5
5
 
6
+ from modelos_secure import edge
6
7
  import contextvars
7
8
  import functools
8
9
  import inspect
@@ -142,7 +143,11 @@ def _watch_call(original, prepare, read, stream_reader=None, always_streams=Fals
142
143
  @functools.wraps(original)
143
144
  async def wrapper(*args, **kwargs):
144
145
  args, kwargs, watching = before(args, kwargs)
145
- result = await original(*args, **kwargs)
146
+ counted = edge.in_sdk.set(True)
147
+ try:
148
+ result = await original(*args, **kwargs)
149
+ finally:
150
+ edge.in_sdk.reset(counted)
146
151
  return after(args, kwargs, result) if watching else result
147
152
 
148
153
  else:
@@ -150,7 +155,11 @@ def _watch_call(original, prepare, read, stream_reader=None, always_streams=Fals
150
155
  @functools.wraps(original)
151
156
  def wrapper(*args, **kwargs):
152
157
  args, kwargs, watching = before(args, kwargs)
153
- result = original(*args, **kwargs)
158
+ counted = edge.in_sdk.set(True)
159
+ try:
160
+ result = original(*args, **kwargs)
161
+ finally:
162
+ edge.in_sdk.reset(counted)
154
163
  if not watching:
155
164
  return result
156
165
  if inspect.isawaitable(result):
@@ -406,9 +415,11 @@ def _patch_langchain(module):
406
415
  def wrapper(self, messages, *args, **kwargs):
407
416
  messages = _safely(_prepare_langchain_messages, messages, fallback=messages)
408
417
  token = _inside_framework.set(True)
418
+ counted = edge.in_sdk.set(True)
409
419
  try:
410
420
  result = original(self, messages, *args, **kwargs)
411
421
  finally:
422
+ edge.in_sdk.reset(counted)
412
423
  _inside_framework.reset(token)
413
424
  _safely(_read_langchain, _langchain_model_name(self), result)
414
425
  return result
@@ -421,9 +432,11 @@ def _patch_langchain(module):
421
432
  async def wrapper(self, messages, *args, **kwargs):
422
433
  messages = _safely(_prepare_langchain_messages, messages, fallback=messages)
423
434
  token = _inside_framework.set(True)
435
+ counted = edge.in_sdk.set(True)
424
436
  try:
425
437
  result = await original(self, messages, *args, **kwargs)
426
438
  finally:
439
+ edge.in_sdk.reset(counted)
427
440
  _inside_framework.reset(token)
428
441
  _safely(_read_langchain, _langchain_model_name(self), result)
429
442
  return result