modelos-secure 0.0.0-stage → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "modelos-secure",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mn-chetan/modelos-secure.git"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "modelos-secure": "cli.js"
13
+ },
14
+ "exports": {
15
+ "./hook": "./hook/hook.mjs"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ }
6
20
  }
File without changes
@@ -0,0 +1,444 @@
1
+ """The model clients: the prompt canary going in, and what the model asked for coming out.
2
+
3
+ Whatever happens here, the app's own call goes ahead as if the hook were not there.
4
+ """
5
+
6
+ import contextvars
7
+ import functools
8
+ import inspect
9
+ import json
10
+
11
+ _MARK = "__modelos_secure_wrapped__"
12
+
13
+ # A framework's model call is reported once, by the framework's own patch, not again by the client
14
+ # underneath it. A context variable follows the call through threads and tasks.
15
+ _inside_framework = contextvars.ContextVar("modelos_secure_inside_framework", default=False)
16
+
17
+ _canary_line = None
18
+ _see_model_call = None
19
+ _safely = None
20
+
21
+
22
+ def connect(canary_line, see_model_call, safely):
23
+ """What this module needs from the hook, which holds the state of the current attempt."""
24
+ global _canary_line, _see_model_call, _safely
25
+ _canary_line = canary_line
26
+ _see_model_call = see_model_call
27
+ _safely = safely
28
+
29
+
30
+ def _with_line(content, line):
31
+ """Adds a line to a message's content, whether it is text or a list of parts."""
32
+ if isinstance(content, str):
33
+ return f"{content}\n{line}"
34
+ if isinstance(content, list):
35
+ return [*content, {"type": "text", "text": line}]
36
+ return content
37
+
38
+
39
+ def _parse_args(text):
40
+ if isinstance(text, dict):
41
+ return text
42
+ try:
43
+ parsed = json.loads(text)
44
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
45
+ except (TypeError, ValueError):
46
+ return {"raw": str(text)}
47
+
48
+
49
+ class _Watched:
50
+ """A stream the app reads as usual. What the model asked for is known once it has been read."""
51
+
52
+ def __init__(self, stream, reader, report):
53
+ self._stream = stream
54
+ self._reader = reader
55
+ self._report = report
56
+ self._iterator = None
57
+ self._done = False
58
+
59
+ def __getattr__(self, name):
60
+ return getattr(self._stream, name)
61
+
62
+ @property
63
+ def __class__(self):
64
+ return type(self._stream)
65
+
66
+ def _see(self, chunk):
67
+ _safely(self._reader.add, chunk)
68
+ return chunk
69
+
70
+ def _finish(self):
71
+ if not self._done:
72
+ self._done = True
73
+ _safely(self._report, self._reader.finish())
74
+
75
+ def __iter__(self):
76
+ self._iterator = iter(self._stream)
77
+ return self
78
+
79
+ def __next__(self):
80
+ if self._iterator is None:
81
+ self._iterator = iter(self._stream)
82
+ try:
83
+ return self._see(next(self._iterator))
84
+ except StopIteration:
85
+ self._finish()
86
+ raise
87
+
88
+ def __aiter__(self):
89
+ self._iterator = self._stream.__aiter__()
90
+ return self
91
+
92
+ async def __anext__(self):
93
+ if self._iterator is None:
94
+ self._iterator = self._stream.__aiter__()
95
+ try:
96
+ return self._see(await self._iterator.__anext__())
97
+ except StopAsyncIteration:
98
+ self._finish()
99
+ raise
100
+
101
+ def __enter__(self):
102
+ self._stream.__enter__()
103
+ return self
104
+
105
+ def __exit__(self, *details):
106
+ self._finish()
107
+ return self._stream.__exit__(*details)
108
+
109
+ async def __aenter__(self):
110
+ await self._stream.__aenter__()
111
+ return self
112
+
113
+ async def __aexit__(self, *details):
114
+ self._finish()
115
+ return await self._stream.__aexit__(*details)
116
+
117
+
118
+ def _watch_call(original, prepare, read, stream_reader=None, always_streams=False):
119
+ """Wraps a model client's method. prepare adds the canary; read says what came back."""
120
+
121
+ def before(args, kwargs):
122
+ if _inside_framework.get():
123
+ return args, kwargs, False
124
+ prepared = _safely(prepare, args, kwargs, fallback=None)
125
+ args, kwargs = prepared if prepared else (args, kwargs)
126
+ return args, kwargs, True
127
+
128
+ def after(args, kwargs, result):
129
+ def report(found):
130
+ calls, input_tokens, output_tokens = found
131
+ _see_model_call(_model_argument(args, kwargs), calls, input_tokens, output_tokens)
132
+
133
+ if stream_reader and (always_streams or kwargs.get("stream")):
134
+ return _Watched(result, stream_reader(), report)
135
+ found = _safely(read, result)
136
+ if found:
137
+ _safely(report, found)
138
+ return result
139
+
140
+ if inspect.iscoroutinefunction(original):
141
+
142
+ @functools.wraps(original)
143
+ async def wrapper(*args, **kwargs):
144
+ args, kwargs, watching = before(args, kwargs)
145
+ result = await original(*args, **kwargs)
146
+ return after(args, kwargs, result) if watching else result
147
+
148
+ else:
149
+
150
+ @functools.wraps(original)
151
+ def wrapper(*args, **kwargs):
152
+ args, kwargs, watching = before(args, kwargs)
153
+ result = original(*args, **kwargs)
154
+ if not watching:
155
+ return result
156
+ if inspect.isawaitable(result):
157
+ return _after_awaited(args, kwargs, result, after)
158
+ return after(args, kwargs, result)
159
+
160
+ setattr(wrapper, _MARK, True)
161
+ return wrapper
162
+
163
+
164
+ async def _after_awaited(args, kwargs, awaitable, after):
165
+ return after(args, kwargs, await awaitable)
166
+
167
+
168
+ def _model_argument(args, kwargs):
169
+ model = kwargs.get("model")
170
+ return model if isinstance(model, str) else None
171
+
172
+
173
+ def _replace(cls, attr, make):
174
+ original = vars(cls).get(attr)
175
+ if original is not None and not getattr(original, _MARK, False):
176
+ setattr(cls, attr, make(original))
177
+
178
+
179
+ # openai: chat completions and responses.
180
+
181
+
182
+ def _prepare_chat(args, kwargs):
183
+ line = _canary_line()
184
+ messages = kwargs.get("messages")
185
+ if not line or not isinstance(messages, list):
186
+ return args, kwargs
187
+ for index, message in enumerate(messages):
188
+ if isinstance(message, dict) and message.get("role") in ("system", "developer"):
189
+ changed = {**message, "content": _with_line(message.get("content"), line)}
190
+ return args, {**kwargs, "messages": [*messages[:index], changed, *messages[index + 1 :]]}
191
+ return args, kwargs
192
+
193
+
194
+ def _read_chat(response):
195
+ if not hasattr(response, "choices") and hasattr(response, "parse"):
196
+ response = response.parse()
197
+ calls = []
198
+ for choice in response.choices or []:
199
+ for call in getattr(choice.message, "tool_calls", None) or []:
200
+ function = getattr(call, "function", None)
201
+ if function:
202
+ calls.append({"tool": function.name, "args": _parse_args(function.arguments)})
203
+ usage = getattr(response, "usage", None)
204
+ return calls, getattr(usage, "prompt_tokens", 0), getattr(usage, "completion_tokens", 0)
205
+
206
+
207
+ class _ChatStreamReader:
208
+ def __init__(self):
209
+ self.calls = {}
210
+ self.usage = None
211
+
212
+ def add(self, chunk):
213
+ self.usage = getattr(chunk, "usage", None) or self.usage
214
+ for choice in getattr(chunk, "choices", None) or []:
215
+ for delta in getattr(choice.delta, "tool_calls", None) or []:
216
+ call = self.calls.setdefault(delta.index, {"tool": "", "text": ""})
217
+ function = delta.function
218
+ if function is not None:
219
+ call["tool"] += function.name or ""
220
+ call["text"] += function.arguments or ""
221
+
222
+ def finish(self):
223
+ calls = [{"tool": c["tool"], "args": _parse_args(c["text"])} for c in self.calls.values()]
224
+ usage = self.usage
225
+ return calls, getattr(usage, "prompt_tokens", 0), getattr(usage, "completion_tokens", 0)
226
+
227
+
228
+ def _patch_openai_chat(module):
229
+ for name in ("Completions", "AsyncCompletions"):
230
+ cls = getattr(module, name, None)
231
+ if isinstance(cls, type):
232
+ _replace(
233
+ cls,
234
+ "create",
235
+ lambda original: _watch_call(original, _prepare_chat, _read_chat, _ChatStreamReader),
236
+ )
237
+
238
+
239
+ def _prepare_responses(args, kwargs):
240
+ line = _canary_line()
241
+ if not line:
242
+ return args, kwargs
243
+ if isinstance(kwargs.get("instructions"), str):
244
+ return args, {**kwargs, "instructions": f"{kwargs['instructions']}\n{line}"}
245
+ items = kwargs.get("input")
246
+ if isinstance(items, list):
247
+ prepared = _prepare_chat(args, {**kwargs, "messages": items})
248
+ changed = dict(prepared[1])
249
+ changed["input"] = changed.pop("messages")
250
+ return args, changed
251
+ return args, kwargs
252
+
253
+
254
+ def _read_responses(response):
255
+ if not hasattr(response, "output") and hasattr(response, "parse"):
256
+ response = response.parse()
257
+ calls = [
258
+ {"tool": item.name, "args": _parse_args(item.arguments)}
259
+ for item in response.output or []
260
+ if getattr(item, "type", None) == "function_call"
261
+ ]
262
+ usage = getattr(response, "usage", None)
263
+ return calls, getattr(usage, "input_tokens", 0), getattr(usage, "output_tokens", 0)
264
+
265
+
266
+ class _ResponsesStreamReader:
267
+ def __init__(self):
268
+ self.found = ([], 0, 0)
269
+
270
+ def add(self, event):
271
+ if getattr(event, "type", None) == "response.completed":
272
+ self.found = _read_responses(event.response)
273
+
274
+ def finish(self):
275
+ return self.found
276
+
277
+
278
+ def _patch_openai_responses(module):
279
+ for name in ("Responses", "AsyncResponses"):
280
+ cls = getattr(module, name, None)
281
+ if isinstance(cls, type):
282
+ _replace(
283
+ cls,
284
+ "create",
285
+ lambda original: _watch_call(
286
+ original, _prepare_responses, _read_responses, _ResponsesStreamReader
287
+ ),
288
+ )
289
+
290
+
291
+ # google.genai: every real model call, which the client's automatic function calling loops over.
292
+
293
+
294
+ def _with_instruction(instruction, line):
295
+ if isinstance(instruction, str):
296
+ return f"{instruction}\n{line}"
297
+ if isinstance(instruction, list):
298
+ return [*instruction, line]
299
+ parts = getattr(instruction, "parts", None)
300
+ if parts:
301
+ return instruction.model_copy(update={"parts": [*parts, type(parts[0])(text=line)]})
302
+ if isinstance(instruction, dict) and isinstance(instruction.get("parts"), list):
303
+ return {**instruction, "parts": [*instruction["parts"], {"text": line}]}
304
+ return instruction
305
+
306
+
307
+ def _prepare_genai(args, kwargs):
308
+ line = _canary_line()
309
+ config = kwargs.get("config")
310
+ if not line or config is None:
311
+ return args, kwargs
312
+ if isinstance(config, dict):
313
+ for key in ("system_instruction", "systemInstruction"):
314
+ if config.get(key):
315
+ changed = {**config, key: _with_instruction(config[key], line)}
316
+ return args, {**kwargs, "config": changed}
317
+ return args, kwargs
318
+ instruction = getattr(config, "system_instruction", None)
319
+ if instruction:
320
+ changed = config.model_copy(
321
+ update={"system_instruction": _with_instruction(instruction, line)}
322
+ )
323
+ return args, {**kwargs, "config": changed}
324
+ return args, kwargs
325
+
326
+
327
+ def _read_genai(response):
328
+ calls = [
329
+ {"tool": call.name, "args": dict(call.args or {})}
330
+ for call in getattr(response, "function_calls", None) or []
331
+ ]
332
+ usage = getattr(response, "usage_metadata", None)
333
+ return (
334
+ calls,
335
+ getattr(usage, "prompt_token_count", 0),
336
+ getattr(usage, "candidates_token_count", 0),
337
+ )
338
+
339
+
340
+ class _GenAiStreamReader:
341
+ def __init__(self):
342
+ self.calls = []
343
+ self.usage = (0, 0)
344
+
345
+ def add(self, chunk):
346
+ calls, input_tokens, output_tokens = _read_genai(chunk)
347
+ self.calls += calls
348
+ self.usage = (input_tokens or self.usage[0], output_tokens or self.usage[1])
349
+
350
+ def finish(self):
351
+ return self.calls, self.usage[0], self.usage[1]
352
+
353
+
354
+ def _patch_genai(module):
355
+ for name in ("Models", "AsyncModels"):
356
+ cls = getattr(module, name, None)
357
+ if not isinstance(cls, type):
358
+ continue
359
+ _replace(
360
+ cls, "_generate_content", lambda original: _watch_call(original, _prepare_genai, _read_genai)
361
+ )
362
+ _replace(
363
+ cls,
364
+ "_generate_content_stream",
365
+ lambda original: _watch_call(
366
+ original, _prepare_genai, _read_genai, _GenAiStreamReader, always_streams=True
367
+ ),
368
+ )
369
+
370
+
371
+ # LangChain chat models: every provider's model passes through these two.
372
+
373
+
374
+ def _prepare_langchain_messages(messages):
375
+ line = _canary_line()
376
+ if not line or not isinstance(messages, list):
377
+ return messages
378
+ for index, message in enumerate(messages):
379
+ if getattr(message, "type", None) == "system":
380
+ changed = message.model_copy(update={"content": _with_line(message.content, line)})
381
+ return [*messages[:index], changed, *messages[index + 1 :]]
382
+ return messages
383
+
384
+
385
+ def _read_langchain(model, result):
386
+ message = result.generations[0].message
387
+ calls = [
388
+ {"tool": call["name"], "args": call["args"]}
389
+ for call in getattr(message, "tool_calls", None) or []
390
+ ]
391
+ usage = getattr(message, "usage_metadata", None) or {}
392
+ _see_model_call(model, calls, usage.get("input_tokens", 0), usage.get("output_tokens", 0))
393
+
394
+
395
+ def _langchain_model_name(chat_model):
396
+ return getattr(chat_model, "model_name", None) or getattr(chat_model, "model", None)
397
+
398
+
399
+ def _patch_langchain(module):
400
+ cls = getattr(module, "BaseChatModel", None)
401
+ if not isinstance(cls, type):
402
+ return
403
+
404
+ def sync_call(original):
405
+ @functools.wraps(original)
406
+ def wrapper(self, messages, *args, **kwargs):
407
+ messages = _safely(_prepare_langchain_messages, messages, fallback=messages)
408
+ token = _inside_framework.set(True)
409
+ try:
410
+ result = original(self, messages, *args, **kwargs)
411
+ finally:
412
+ _inside_framework.reset(token)
413
+ _safely(_read_langchain, _langchain_model_name(self), result)
414
+ return result
415
+
416
+ setattr(wrapper, _MARK, True)
417
+ return wrapper
418
+
419
+ def async_call(original):
420
+ @functools.wraps(original)
421
+ async def wrapper(self, messages, *args, **kwargs):
422
+ messages = _safely(_prepare_langchain_messages, messages, fallback=messages)
423
+ token = _inside_framework.set(True)
424
+ try:
425
+ result = await original(self, messages, *args, **kwargs)
426
+ finally:
427
+ _inside_framework.reset(token)
428
+ _safely(_read_langchain, _langchain_model_name(self), result)
429
+ return result
430
+
431
+ setattr(wrapper, _MARK, True)
432
+ return wrapper
433
+
434
+ _replace(cls, "_generate_with_cache", sync_call)
435
+ _replace(cls, "_agenerate_with_cache", async_call)
436
+
437
+
438
+ LIBRARIES = {
439
+ "openai.resources.chat.completions.completions": _patch_openai_chat,
440
+ "openai.resources.chat.completions": _patch_openai_chat,
441
+ "openai.resources.responses.responses": _patch_openai_responses,
442
+ "google.genai.models": _patch_genai,
443
+ "langchain_core.language_models.chat_models": _patch_langchain,
444
+ }