traceAI-openwebui 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,359 @@
1
+ """Maintained Open WebUI Pipe Function for real, individually timed model calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import json
8
+ import logging
9
+ import weakref
10
+ from contextlib import nullcontext
11
+ from importlib.metadata import version
12
+ from typing import Any
13
+
14
+ from opentelemetry import trace
15
+ from opentelemetry.trace import SpanKind, Status, StatusCode
16
+ from packaging.version import Version
17
+ from pydantic import BaseModel, Field, SecretStr, field_serializer
18
+ from starlette.responses import Response, StreamingResponse
19
+
20
+ from ._provider import Providers
21
+ from ._response import Observation, is_chat_completion
22
+
23
+ __version__ = "0.1.0" # x-release-please-version
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def _check_host() -> None:
28
+ installed = Version(version("open-webui"))
29
+ if not Version("0.11.4") <= installed < Version("0.13"):
30
+ raise RuntimeError("traceAI-openwebui requires Open WebUI >=0.11.4,<0.13")
31
+
32
+
33
+ def _secret() -> Any:
34
+ return Field(
35
+ default=SecretStr(""), json_schema_extra={"input": {"type": "password"}}
36
+ )
37
+
38
+
39
+ async def _check_connection(request: Any, user: Any, model_id: str) -> None:
40
+ from open_webui.routers.openai import get_all_models, get_openai_connection
41
+
42
+ models = request.app.state.OPENAI_MODELS
43
+ if not models or model_id not in models:
44
+ await get_all_models(request, user=user)
45
+ models = request.app.state.OPENAI_MODELS
46
+ model = models.get(model_id)
47
+ if model is None:
48
+ raise ValueError(
49
+ "The target model's OpenAI-compatible connection is unavailable"
50
+ )
51
+ # Use the host's resolver, including index/legacy URL configuration lookup.
52
+ # Never cache this check: an administrator can change the connection's API.
53
+ _, _, config = await get_openai_connection(model["urlIdx"])
54
+ if config.get("api_type") == "responses":
55
+ # The host discards status/error/incomplete_details when a Responses
56
+ # endpoint returns JSON. Even stream=True can receive that lossy result.
57
+ # Reject before inference/span creation rather than inventing success.
58
+ raise ValueError(
59
+ "Responses API connections are not supported; choose a Chat Completions connection"
60
+ )
61
+
62
+
63
+ class Pipe:
64
+ class Valves(BaseModel):
65
+ target_model: str = Field(
66
+ default="",
67
+ description="Native Chat Completions model ID; not Responses API, Ollama, another Pipe or Arena.",
68
+ )
69
+ api_key: SecretStr = _secret()
70
+ secret_key: SecretStr = _secret()
71
+ endpoint: str = "https://api.futureagi.com"
72
+ project: str = "open-webui"
73
+ capture_content: bool = False
74
+ max_chars: int = Field(default=32000, ge=1, le=131072)
75
+
76
+ @field_serializer("api_key", "secret_key")
77
+ def stored_secret(self, value: SecretStr) -> str:
78
+ # Open WebUI persists valves through model_dump(). Repr stays masked.
79
+ return value.get_secret_value()
80
+
81
+ def __init__(self) -> None:
82
+ self.valves = self.Valves()
83
+ self._providers = Providers()
84
+ weakref.finalize(self, self._providers.close)
85
+
86
+ async def pipe(
87
+ self,
88
+ body: dict[str, Any],
89
+ __request__: Any,
90
+ __user__: dict[str, Any],
91
+ __metadata__: dict[str, Any] | None = None,
92
+ ) -> Any:
93
+ _check_host()
94
+ from open_webui.models.users import Users
95
+ from open_webui.utils.chat import generate_chat_completion
96
+
97
+ valves = self.valves.model_copy(deep=True)
98
+ target = __request__.app.state.MODELS.get(valves.target_model)
99
+ # Recursive Pipes/Arena routes could produce nested spans for one request
100
+ # or recurse indefinitely. Delegate only to a native provider entry.
101
+ if (
102
+ not target
103
+ or target.get("pipe")
104
+ or target.get("owned_by") == "arena"
105
+ or target.get("info", {}).get("base_model_id")
106
+ or valves.target_model == body.get("model")
107
+ ):
108
+ raise ValueError(
109
+ "Choose a native provider model as target_model, not a workspace preset, Pipe or Arena"
110
+ )
111
+ # The native Ollama dispatcher discards in-band errors, invents [DONE]
112
+ # at EOF and (on the floor host) invents missing usage as zero. Reject it
113
+ # before dispatch instead of presenting converted data as real telemetry.
114
+ if target.get("owned_by") == "ollama":
115
+ raise ValueError(
116
+ "Native Ollama connections are not supported; choose an OpenAI-compatible connection"
117
+ )
118
+ user = await Users.get_user_by_id(__user__.get("id"))
119
+ if user is None:
120
+ raise ValueError("Open WebUI user is unavailable")
121
+ await _check_connection(__request__, user, valves.target_model)
122
+ payload = copy.deepcopy(body)
123
+ payload["model"] = valves.target_model
124
+ if __metadata__ is not None:
125
+ payload["metadata"] = dict(__metadata__)
126
+ metadata = __metadata__ or {}
127
+ temporary = str(metadata.get("chat_id", "")).startswith(
128
+ ("temporary:", "local:")
129
+ )
130
+ capture = valves.capture_content and not temporary
131
+ span = None
132
+ release = lambda: None
133
+ try:
134
+ if (
135
+ valves.api_key.get_secret_value()
136
+ and valves.secret_key.get_secret_value()
137
+ ):
138
+ provider, release = self._providers.acquire(
139
+ (
140
+ valves.endpoint,
141
+ valves.project,
142
+ valves.api_key.get_secret_value(),
143
+ valves.secret_key.get_secret_value(),
144
+ )
145
+ )
146
+ span = provider.get_tracer("traceai_openwebui", __version__).start_span(
147
+ "openwebui.model",
148
+ kind=SpanKind.CLIENT,
149
+ attributes={
150
+ "fi.span.kind": "LLM",
151
+ "gen_ai.operation.name": "chat",
152
+ "gen_ai.request.model": valves.target_model,
153
+ "llm.model_name": valves.target_model,
154
+ "openwebui.tracing.scope": "model_call",
155
+ },
156
+ )
157
+ except Exception: # noqa: BLE001 - telemetry fails open without logging private payloads
158
+ release()
159
+ release = lambda: None
160
+ log.warning("Open WebUI tracing unavailable; model execution continues")
161
+ # Native routing applies stored model defaults after this point, including
162
+ # custom n parameters. Only an explicit n has authoritative precedence;
163
+ # otherwise require [DONE] rather than guessing from the model cache.
164
+ observation = (
165
+ Observation(span, capture, valves.max_chars, payload.get("n"))
166
+ if span is not None
167
+ else None
168
+ )
169
+ if span is not None and capture:
170
+ messages = [
171
+ {"role": m.get("role"), "content": m.get("content")}
172
+ for m in payload.get("messages", [])
173
+ if isinstance(m, dict)
174
+ ]
175
+ span.set_attribute(
176
+ "input.value",
177
+ json.dumps(messages, ensure_ascii=False)[: valves.max_chars],
178
+ )
179
+ span.set_attribute("input.mime_type", "application/json")
180
+
181
+ def active():
182
+ return (
183
+ trace.use_span(
184
+ span,
185
+ end_on_exit=False,
186
+ record_exception=False,
187
+ set_status_on_exception=False,
188
+ )
189
+ if span is not None
190
+ else nullcontext()
191
+ )
192
+
193
+ watched_tasks: weakref.WeakSet[asyncio.Task] = weakref.WeakSet()
194
+
195
+ def observe(method: str, *args: Any) -> None:
196
+ if observation is not None:
197
+ try:
198
+ getattr(observation, method)(*args)
199
+ except Exception: # noqa: BLE001 - telemetry fails open without logging private payloads
200
+ log.warning("Open WebUI response telemetry unavailable")
201
+
202
+ def finish(outcome: str) -> None:
203
+ nonlocal span
204
+ try:
205
+ if span is not None:
206
+ # Host cleanup may abandon our stream under shield before the
207
+ # cancelled consumer finishes. A pending cancel request is
208
+ # already known; do not overwrite it with "incomplete" on GC.
209
+ if outcome == "incomplete" and any(
210
+ task.cancelling() or task.cancelled() for task in watched_tasks
211
+ ):
212
+ outcome = "cancelled"
213
+ observe("finish")
214
+ if observation is not None and observation.failed:
215
+ outcome = "error"
216
+ span.set_attribute("openwebui.outcome", outcome)
217
+ if outcome != "completed":
218
+ span.set_status(
219
+ Status(
220
+ StatusCode.ERROR,
221
+ {
222
+ "error": "model request failed",
223
+ "cancelled": "model request cancelled",
224
+ "incomplete": "model stream ended without completion",
225
+ }[outcome],
226
+ )
227
+ )
228
+ span.end()
229
+ span = None
230
+ finally:
231
+ for task in watched_tasks:
232
+ task.remove_done_callback(task_done)
233
+ watched_tasks.clear()
234
+ release()
235
+
236
+ def task_done(task: asyncio.Task) -> None:
237
+ # A host may cancel while handling a chunk, outside our __anext__.
238
+ if task.cancelled():
239
+ finish("cancelled")
240
+
241
+ def watch_task() -> None:
242
+ task = asyncio.current_task()
243
+ if span is not None and task is not None and task not in watched_tasks:
244
+ watched_tasks.add(task)
245
+ task.add_done_callback(task_done)
246
+
247
+ watch_task()
248
+ try:
249
+ with active():
250
+ # Preserve upstream access checks. Never pass bypass_filter=True.
251
+ response = await generate_chat_completion(
252
+ __request__,
253
+ payload,
254
+ user,
255
+ bypass_system_prompt=getattr(
256
+ __request__.state, "bypass_system_prompt", False
257
+ ),
258
+ )
259
+ except asyncio.CancelledError:
260
+ finish("cancelled")
261
+ raise
262
+ except BaseException:
263
+ finish("error")
264
+ raise
265
+
266
+ if not isinstance(response, StreamingResponse):
267
+ data = response
268
+ if isinstance(response, Response):
269
+ try:
270
+ data = json.loads(response.body)
271
+ except (ValueError, TypeError):
272
+ data = None
273
+ invalid = not (
274
+ isinstance(data, dict)
275
+ and (data.get("error") or is_chat_completion(data))
276
+ )
277
+ http_error = isinstance(response, Response) and response.status_code >= 400
278
+ try:
279
+ observe("payload", data)
280
+ finally:
281
+ finish("error" if invalid or http_error else "completed")
282
+ # The native router normally returns decoded JSON, not a Response.
283
+ # Validate both forms even with tracing disabled: the Pipe dispatcher
284
+ # would otherwise fabricate an empty completion or treat a raw string
285
+ # as generated model content.
286
+ if http_error and (invalid or not data.get("error")):
287
+ return {
288
+ "error": {
289
+ "message": "model request failed",
290
+ "code": response.status_code,
291
+ }
292
+ }
293
+ if invalid:
294
+ return {"error": "model returned an invalid non-streaming response"}
295
+ return data
296
+
297
+ if observation is not None and response.status_code >= 400:
298
+ observation.failed = True
299
+
300
+ async def traced_stream():
301
+ watch_task()
302
+ outcome = "incomplete"
303
+ try:
304
+ iterator = response.body_iterator.__aiter__()
305
+ while True:
306
+ try:
307
+ # Do not leave the span attached across a yield: unrelated
308
+ # tool/retrieval work must not become a child of this call.
309
+ with active():
310
+ chunk = await iterator.__anext__()
311
+ except StopAsyncIteration:
312
+ if observation is not None:
313
+ observe("finish")
314
+ outcome = (
315
+ "completed" if observation.terminal else "incomplete"
316
+ )
317
+ else:
318
+ outcome = "completed"
319
+ break
320
+ observe("chunk", chunk)
321
+ yield chunk
322
+ except (asyncio.CancelledError, GeneratorExit):
323
+ outcome = "cancelled"
324
+ raise
325
+ except BaseException:
326
+ outcome = "error"
327
+ raise
328
+ finally:
329
+ try:
330
+ close = getattr(response.body_iterator, "aclose", None)
331
+ if close is not None:
332
+ await close()
333
+ # Open WebUI's Pipe dispatcher consumes only body_iterator;
334
+ # it does not run the returned response's background task.
335
+ if response.background is not None:
336
+ await response.background()
337
+ finally:
338
+ finish(outcome)
339
+
340
+ # Preserve the original stream exactly, including errors and [DONE].
341
+ stream = traced_stream()
342
+ # Includes a consumer that abandons the returned response before starting
343
+ # iteration. This is incomplete observation, never claimed as success.
344
+ weakref.finalize(stream, finish, "incomplete")
345
+ return StreamingResponse(
346
+ stream,
347
+ status_code=response.status_code,
348
+ headers=dict(response.headers),
349
+ media_type=response.media_type,
350
+ )
351
+
352
+ def flush(self, timeout_millis: int = 10000) -> bool:
353
+ return self._providers.flush(timeout_millis)
354
+
355
+ def close(self) -> None:
356
+ self._providers.close()
357
+
358
+
359
+ __all__ = ["Pipe"]
@@ -0,0 +1,210 @@
1
+ """Private provider: never forward Open WebUI's HTTP, auth or database spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from concurrent.futures import Future, TimeoutError
10
+ from urllib.parse import urlsplit
11
+
12
+ import certifi
13
+ import requests
14
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
15
+ Compression,
16
+ OTLPSpanExporter,
17
+ )
18
+ from opentelemetry.sdk.resources import Resource
19
+ from opentelemetry.sdk.trace import TracerProvider
20
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
21
+ from opentelemetry.sdk.trace.sampling import ALWAYS_ON
22
+
23
+
24
+ def endpoint_url(value: str) -> str:
25
+ parsed = urlsplit(value)
26
+ if (
27
+ parsed.scheme not in {"https", "http"}
28
+ or not parsed.hostname
29
+ or parsed.username
30
+ or parsed.password
31
+ or parsed.query
32
+ or parsed.fragment
33
+ ):
34
+ raise ValueError("Use a collector URL without credentials, query or fragment")
35
+ if parsed.scheme == "http" and parsed.hostname not in {
36
+ "localhost",
37
+ "127.0.0.1",
38
+ "::1",
39
+ }:
40
+ raise ValueError("Use HTTPS for a non-loopback collector")
41
+ return value.rstrip("/") + (
42
+ ""
43
+ if parsed.path.rstrip("/").endswith("/tracer/v1/traces")
44
+ else "/tracer/v1/traces"
45
+ )
46
+
47
+
48
+ class _ExportSession(requests.Session):
49
+ def __init__(self, auth_headers: dict[str, str]) -> None:
50
+ super().__init__()
51
+ self._auth_headers = auth_headers
52
+
53
+ def prepare_request(self, request):
54
+ prepared = super().prepare_request(request)
55
+ # Some SDK versions merge native OTEL_* headers even when headers were
56
+ # explicitly supplied. Allow only transport headers plus our own keys.
57
+ prepared.headers.clear()
58
+ prepared.headers.update(
59
+ {
60
+ "Content-Type": "application/x-protobuf",
61
+ "Content-Length": str(len(prepared.body or b"")),
62
+ }
63
+ )
64
+ prepared.headers.update(self._auth_headers)
65
+ return prepared
66
+
67
+ def request(self, method, url, **kwargs):
68
+ # The SDK's transport can pass mTLS from OTEL_* environment variables
69
+ # on each request. Override at the public requests boundary, not by
70
+ # temporarily mutating the process environment used by native telemetry.
71
+ kwargs["cert"] = None
72
+ kwargs["allow_redirects"] = False
73
+ return super().request(method, url, **kwargs)
74
+
75
+
76
+ def make_provider(
77
+ endpoint: str, project: str, api_key: str, secret_key: str
78
+ ) -> TracerProvider:
79
+ session = _ExportSession({"X-Api-Key": api_key, "X-Secret-Key": secret_key})
80
+ # Never inherit proxy credentials or .netrc authentication from the host.
81
+ session.trust_env = False
82
+ exporter = OTLPSpanExporter(
83
+ endpoint=endpoint_url(endpoint),
84
+ headers={"X-Api-Key": api_key, "X-Secret-Key": secret_key},
85
+ timeout=10,
86
+ compression=Compression.NoCompression,
87
+ certificate_file=certifi.where(),
88
+ session=session,
89
+ )
90
+ # An unrelated native OTLP client certificate must not be sent to our endpoint.
91
+ session.cert = None
92
+ provider = TracerProvider(
93
+ resource=Resource(
94
+ {
95
+ "project_name": project,
96
+ "project_type": "observe",
97
+ "service.name": "open-webui",
98
+ }
99
+ ),
100
+ sampler=ALWAYS_ON,
101
+ )
102
+ provider.add_span_processor(
103
+ BatchSpanProcessor(
104
+ exporter,
105
+ max_queue_size=2048,
106
+ max_export_batch_size=512,
107
+ schedule_delay_millis=5000,
108
+ export_timeout_millis=10000,
109
+ )
110
+ )
111
+ return provider
112
+
113
+
114
+ class _Generation:
115
+ def __init__(self, key: tuple[str, ...], provider: TracerProvider) -> None:
116
+ self.key = key
117
+ self.provider = provider
118
+ self.users = 0
119
+ self.retired = False
120
+
121
+
122
+ class Providers:
123
+ """Keep in-flight calls on their original project when an admin edits valves."""
124
+
125
+ def __init__(self) -> None:
126
+ self._lock = threading.Lock()
127
+ self._flush_lock = threading.Lock()
128
+ self._current: _Generation | None = None
129
+
130
+ @staticmethod
131
+ def _retire(generation: _Generation) -> None:
132
+ threading.Thread(
133
+ target=generation.provider.shutdown,
134
+ name="traceai-openwebui-close",
135
+ daemon=True,
136
+ ).start()
137
+
138
+ def acquire(
139
+ self, key: tuple[str, str, str, str]
140
+ ) -> tuple[TracerProvider, Callable[[], None]]:
141
+ with self._lock:
142
+ current = self._current
143
+ if current is None or current.key != key:
144
+ new = _Generation(key, make_provider(*key))
145
+ if current is not None:
146
+ current.retired = True
147
+ if current.users == 0:
148
+ self._retire(current)
149
+ self._current = current = new
150
+ current.users += 1
151
+ released = False
152
+
153
+ def release() -> None:
154
+ nonlocal released
155
+ with self._lock:
156
+ if released:
157
+ return
158
+ released = True
159
+ current.users -= 1
160
+ if current.retired and current.users == 0:
161
+ self._retire(current)
162
+
163
+ return current.provider, release
164
+
165
+ def flush(self, timeout_millis: int = 10000) -> bool:
166
+ """Bound the caller's wait, even when the SDK ignores its flush timeout."""
167
+ deadline = time.monotonic() + max(0, timeout_millis) / 1000
168
+ with self._lock:
169
+ provider = self._current.provider if self._current else None
170
+ if provider is None:
171
+ return True
172
+ # One worker at a time, including after timeout. Do not create a thread
173
+ # per retry or return a previous flush's result for newly queued spans.
174
+ if not self._flush_lock.acquire(timeout=max(0, deadline - time.monotonic())):
175
+ return False
176
+ result: Future[bool] = Future()
177
+
178
+ def flush_provider() -> None:
179
+ try:
180
+ result.set_result(
181
+ provider.force_flush(
182
+ max(1, int((deadline - time.monotonic()) * 1000))
183
+ )
184
+ )
185
+ except Exception: # noqa: BLE001 - fixed diagnostic; never export private errors
186
+ logging.getLogger(__name__).warning("Open WebUI tracing flush failed")
187
+ result.set_result(False)
188
+ finally:
189
+ self._flush_lock.release()
190
+
191
+ try:
192
+ threading.Thread(
193
+ target=flush_provider, name="traceai-openwebui-flush", daemon=True
194
+ ).start()
195
+ except BaseException:
196
+ self._flush_lock.release()
197
+ raise
198
+ try:
199
+ return result.result(timeout=max(0, deadline - time.monotonic()))
200
+ except TimeoutError:
201
+ # Export can finish in the daemon worker; it cannot hold up shutdown.
202
+ return False
203
+
204
+ def close(self) -> None:
205
+ with self._lock:
206
+ current, self._current = self._current, None
207
+ if current is not None:
208
+ current.retired = True
209
+ if current.users == 0:
210
+ self._retire(current)
@@ -0,0 +1,234 @@
1
+ """Observe provider responses, never reconstruct operations from a chat transcript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import json
7
+ from typing import Any
8
+
9
+ from opentelemetry.trace import Span
10
+
11
+ _MAX_FRAME = 1_048_576
12
+
13
+
14
+ def is_chat_completion(value: Any) -> bool:
15
+ """Require completed messages, not just a JSON object or streaming deltas.
16
+
17
+ Compatible providers can omit IDs, timestamps, usage, role and finish_reason.
18
+ Empty/filtered content, tool calls, refusals and audio are still completions.
19
+ """
20
+ if not isinstance(value, dict):
21
+ return False
22
+ choices = value.get("choices")
23
+ if not isinstance(choices, list) or not choices:
24
+ return False
25
+ for choice in choices:
26
+ if not isinstance(choice, dict):
27
+ return False
28
+ message = choice.get("message")
29
+ if not isinstance(message, dict):
30
+ return False
31
+ if "content" in message:
32
+ if message["content"] is not None and not isinstance(
33
+ message["content"], str
34
+ ):
35
+ return False
36
+ elif isinstance(message.get("refusal"), str):
37
+ continue
38
+ elif isinstance(message.get("tool_calls"), list) and message["tool_calls"]:
39
+ if not all(
40
+ isinstance(call, dict) and call for call in message["tool_calls"]
41
+ ):
42
+ return False
43
+ elif any(
44
+ isinstance(message.get(key), dict) and message[key]
45
+ for key in ("function_call", "audio")
46
+ ):
47
+ continue
48
+ else:
49
+ return False
50
+ return True
51
+
52
+
53
+ class Observation:
54
+ def __init__(
55
+ self,
56
+ span: Span,
57
+ capture_content: bool,
58
+ max_chars: int,
59
+ choice_count: Any = None,
60
+ ) -> None:
61
+ self.span = span
62
+ self.capture_content = capture_content
63
+ self.max_chars = max_chars
64
+ self.usage: dict[str, int] = {}
65
+ self.output = ""
66
+ self._terminal = False
67
+ self._choice_count = (
68
+ choice_count if type(choice_count) is int and choice_count > 0 else 0
69
+ )
70
+ self._finished_choices: set[int] = set()
71
+ self._ambiguous_choices = self._choice_count == 0
72
+ self.failed = False
73
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
74
+ self._buffer = ""
75
+ self._data: list[str] = []
76
+ self._size = 0
77
+ self._dropping = False
78
+ self._observation_lost = False
79
+
80
+ @property
81
+ def terminal(self) -> bool:
82
+ # A discarded event may contain a provider error. Even [DONE] or fully
83
+ # finished choices cannot establish success after losing that evidence.
84
+ return not self._observation_lost and (
85
+ self._terminal
86
+ or (
87
+ not self._ambiguous_choices
88
+ and len(self._finished_choices) == self._choice_count
89
+ )
90
+ )
91
+
92
+ def payload(self, value: Any) -> None:
93
+ if not isinstance(value, dict):
94
+ return
95
+ if value.get("error") or value.get("type") in {"error", "response.failed"}:
96
+ self.failed = True
97
+ response = value.get("response")
98
+ if isinstance(response, dict):
99
+ if response.get("error") or response.get("status") in {
100
+ "failed",
101
+ "incomplete",
102
+ "cancelled",
103
+ }:
104
+ self.failed = True
105
+ self._usage(response.get("usage"))
106
+ self._usage(value.get("usage"))
107
+ if value.get("type") in {
108
+ "response.completed",
109
+ "response.failed",
110
+ "response.incomplete",
111
+ }:
112
+ self._terminal = True
113
+ if value["type"] != "response.completed":
114
+ self.failed = True
115
+ if value.get("type") == "response.output_text.delta":
116
+ self._text(value.get("delta"))
117
+ choices = value.get("choices")
118
+ if isinstance(choices, list):
119
+ for choice in choices:
120
+ if not isinstance(choice, dict):
121
+ self._ambiguous_choices = True
122
+ continue
123
+ # A choice finishing is not a request-level terminator. Account
124
+ # for n even when a missing choice never appears on the wire.
125
+ index = choice.get(
126
+ "index",
127
+ 0 if self._choice_count == 1 and len(choices) == 1 else None,
128
+ )
129
+ if type(index) is not int or not 0 <= index < self._choice_count:
130
+ # Cannot correlate ambiguous choices; require [DONE] instead.
131
+ self._ambiguous_choices = True
132
+ elif (
133
+ isinstance(choice.get("finish_reason"), str)
134
+ and choice["finish_reason"]
135
+ ):
136
+ self._finished_choices.add(index)
137
+ else:
138
+ self._finished_choices.discard(index)
139
+ message = choice.get("delta") or choice.get("message")
140
+ if isinstance(message, dict):
141
+ self._text(message.get("content"))
142
+ # Responses API non-streaming output. Streamed output is captured only
143
+ # from deltas, not again from response.completed's repeated output.
144
+ if isinstance(value.get("output"), list):
145
+ for item in value["output"]:
146
+ if (
147
+ isinstance(item, dict)
148
+ and item.get("type") == "message"
149
+ and isinstance(item.get("content"), list)
150
+ ):
151
+ for part in item["content"]:
152
+ if isinstance(part, dict) and part.get("type") == "output_text":
153
+ self._text(part.get("text"))
154
+
155
+ def _usage(self, usage: Any) -> None:
156
+ if not isinstance(usage, dict):
157
+ return
158
+ for key, aliases in (
159
+ ("input", ("prompt_tokens", "input_tokens")),
160
+ ("output", ("completion_tokens", "output_tokens")),
161
+ ):
162
+ for alias in aliases:
163
+ value = usage.get(alias)
164
+ if type(value) is int and 0 <= value < 2**63:
165
+ # A provider's usage object is a snapshot, not a delta.
166
+ self.usage[key] = value
167
+ break
168
+
169
+ def _text(self, value: Any) -> None:
170
+ if self.capture_content and isinstance(value, str):
171
+ self.output += value[: max(0, self.max_chars - len(self.output))]
172
+
173
+ def chunk(self, chunk: str | bytes) -> None:
174
+ text = self._decoder.decode(chunk) if isinstance(chunk, bytes) else chunk
175
+ if not isinstance(text, str):
176
+ return
177
+ # Do not retain an oversized event or parse its suffix as a new event.
178
+ for piece in text.splitlines(keepends=True):
179
+ self._size += len(piece)
180
+ if self._size > _MAX_FRAME:
181
+ self._observation_lost = True
182
+ self._dropping = True
183
+ self._buffer = ""
184
+ self._data.clear()
185
+ if not self._dropping:
186
+ self._buffer += piece
187
+ if piece.endswith("\n"):
188
+ line = self._buffer.rstrip("\r\n")
189
+ if self._dropping:
190
+ # A blank physical line terminates the dropped SSE frame.
191
+ if piece in {"\n", "\r\n"}:
192
+ self._reset()
193
+ elif not line:
194
+ self._frame()
195
+ self._reset()
196
+ elif line.startswith("data:"):
197
+ self._data.append(line[5:].lstrip(" "))
198
+ self._buffer = ""
199
+
200
+ def _reset(self) -> None:
201
+ self._buffer = ""
202
+ self._data.clear()
203
+ self._size = 0
204
+ self._dropping = False
205
+
206
+ def _frame(self) -> None:
207
+ data = "\n".join(self._data).strip()
208
+ if data == "[DONE]":
209
+ self._terminal = True
210
+ elif data:
211
+ try:
212
+ self.payload(json.loads(data))
213
+ except (ValueError, TypeError):
214
+ pass
215
+
216
+ def finish(self) -> None:
217
+ if not self._dropping:
218
+ # Native Pipe wrappers can end with an unterminated [DONE] line.
219
+ if self._buffer.startswith("data:"):
220
+ self._data.append(self._buffer[5:].strip())
221
+ self._frame()
222
+ self._reset()
223
+ if self._observation_lost:
224
+ self.span.set_attribute("openwebui.observation_lost", True)
225
+ for key, count in self.usage.items():
226
+ self.span.set_attribute(f"gen_ai.usage.{key}_tokens", count)
227
+ legacy = "prompt" if key == "input" else "completion"
228
+ self.span.set_attribute(f"llm.token_count.{legacy}", count)
229
+ if len(self.usage) == 2 and sum(self.usage.values()) < 2**63:
230
+ self.span.set_attribute("llm.token_count.total", sum(self.usage.values()))
231
+ if not self.usage:
232
+ self.span.set_attribute("gen_ai.usage.unavailable", True)
233
+ if self.capture_content:
234
+ self.span.set_attribute("output.value", self.output)
@@ -0,0 +1,42 @@
1
+ """Generate Open WebUI's supported Function import file from the installed package."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+
9
+
10
+ def function_document() -> dict:
11
+ source = f'''"""
12
+ title: Traced model
13
+ version: {__version__}
14
+ license: Apache-2.0
15
+ required_open_webui_version: 0.11.4
16
+ requirements: traceAI-openwebui=={__version__}
17
+ """
18
+ from traceai_openwebui import Pipe as _Pipe
19
+
20
+ class Pipe(_Pipe):
21
+ pass
22
+ '''
23
+ return {
24
+ "id": "traceai_openwebui",
25
+ "name": "Traced model",
26
+ "type": "pipe",
27
+ "content": source,
28
+ "meta": {"description": "Trace individual native model calls in Future AGI"},
29
+ "is_active": False,
30
+ "is_global": False,
31
+ }
32
+
33
+
34
+ def main() -> None:
35
+ parser = argparse.ArgumentParser(description=__doc__)
36
+ parser.add_argument("command", choices=["export-function"])
37
+ parser.add_argument("--output", type=Path, required=True)
38
+ args = parser.parse_args()
39
+ # Never overwrite a user's existing Function export or configuration.
40
+ with args.output.open("x", encoding="utf-8") as handle:
41
+ json.dump([function_document()], handle, indent=2)
42
+ handle.write("\n")
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: traceAI-openwebui
3
+ Version: 0.1.0
4
+ Summary: Trace real Open WebUI model calls with a maintained Pipe Function
5
+ Project-URL: Repository, https://github.com/future-agi/traceAI
6
+ Project-URL: Documentation, https://docs.futureagi.com/docs/integrations/no-code/open-webui
7
+ Author-email: Future AGI <no-reply@futureagi.com>
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: <3.13,>=3.11
10
+ Requires-Dist: certifi>=2024.7.4
11
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.27
12
+ Requires-Dist: opentelemetry-sdk<2,>=1.27
13
+ Requires-Dist: packaging<27,>=24
14
+ Requires-Dist: pydantic<3,>=2.9
15
+ Requires-Dist: starlette<1,>=0.40
16
+ Provides-Extra: dev
17
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
18
+ Requires-Dist: mypy<2,>=1.18; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
20
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
21
+ Requires-Dist: ruff<1,>=0.14; extra == 'dev'
22
+ Requires-Dist: types-requests>=2.32; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # traceAI-openwebui
26
+
27
+ Trace each model call made through an Open WebUI Pipe Function in Future AGI, with streaming duration, provider-reported usage, and failure or cancellation status.
28
+
29
+ ## Install
30
+
31
+ Use Python 3.11 or 3.12 and Open WebUI 0.11.4–0.12.x. Configure an OpenAI-compatible **Chat Completions** connection model in Open WebUI first.
32
+
33
+ Native Ollama and **Responses API** connections are not supported. Open WebUI's conversions can hide failures or incomplete responses, so the Pipe rejects these connections before making a model request or recording a span. Responses API connections are rejected even with streaming enabled: a provider can return a non-streaming response to a streaming request. Choose a connection configured for Chat Completions instead.
34
+
35
+ Install the package in the same Python environment as Open WebUI, then generate its Function import file:
36
+
37
+ ```bash
38
+ pip install traceAI-openwebui
39
+ traceai-openwebui export-function --output traceai-openwebui.json
40
+ ```
41
+
42
+ For a container deployment, install the package in your image and run the export command in that image. Make the generated JSON file available to your browser. The file contains a small package loader, not instrumentation source to maintain.
43
+
44
+ ## Configure the Function
45
+
46
+ 1. In **Admin Panel → Functions**, import `traceai-openwebui.json`.
47
+ 2. Open the **Traced model** Function's valves. Set `target_model` to the exact model ID from your existing Chat Completions connection. Responses API connections, native Ollama models, workspace model presets, other Pipes, and Arena models cannot be targets.
48
+ 3. Set `api_key`, `secret_key`, and `project` to your Future AGI credentials and project name. The default endpoint is `https://api.futureagi.com`. For self-hosted Future AGI, set its HTTPS origin; `/tracer/v1/traces` is added automatically.
49
+ 4. Enable the Function, and select **Traced model** in a new chat. Access to the target model is still checked using the signed-in Open WebUI user.
50
+
51
+ Set `ENABLE_VALVE_ENCRYPTION=true` in Open WebUI before storing production credentials so its database encrypts the valves.
52
+
53
+ Use the wrapper model's settings for tools and knowledge. Target-model workspace presets are not inherited. Enable **Usage** in the wrapper model's capabilities when your provider supports streamed token counts. Missing usage stays missing; the integration does not estimate it.
54
+
55
+ Send a message such as “Explain how a cache works in two sentences.” In Future AGI, open **Observe**, choose your project, and inspect an `openwebui.model` span. Native tool loops that invoke this Pipe again produce a separate span for each actual model call, not one span for the whole conversation.
56
+
57
+ ## What is captured
58
+
59
+ - One `LLM` span per delegated model request, from dispatch through response consumption.
60
+ - Provider-reported input and output token counts on that call only. Repeated usage snapshots replace previous values; turn totals are never added again.
61
+ - Fixed error messages for failures, cancellation, or an incomplete stream. Provider exception text and stack traces are not exported.
62
+ - The active OpenTelemetry span as parent, when one exists. The package does not export Open WebUI's native HTTP, database, Redis, or authentication spans.
63
+
64
+ This is **model-call tracing**, not a trace of the entire chat workflow. Retrieval, tool execution, approval pauses, and calls to unwrapped models are outside the Pipe boundary. The integration does not fabricate spans for them or reconstruct model calls from the final transcript. If you also instrument the target gateway, avoid counting both its LLM spans and this Pipe's spans as separate model usage.
65
+
66
+ Prompts, responses, user identifiers, raw email, headers, URLs, tool arguments, and retrieval content are excluded by default. To include message content, enable `capture_content`; `max_chars` bounds each captured input/output to 32,000 characters by default. Temporary and local chats always exclude content. Model IDs and your configured project name are exported.
67
+
68
+ Open WebUI's own telemetry is independent. Enabling it can export additional personal data through its separately configured exporter; this package does not sanitize that exporter.
69
+
70
+ ## Replace the old Filter
71
+
72
+ Disable and delete the old Future AGI Filter Function before enabling this Pipe. The old filter recorded whole-turn summaries as an LLM child and could not observe failed or cancelled turns reliably. The Pipe is not a drop-in filter: choose **Traced model** for the calls you want to trace. Remove any separate tracing filter or gateway integration that would duplicate usage.
73
+
74
+ Existing Function copies in Open WebUI's database do not change when this repository changes. Import the generated Function after each package upgrade, then restart Open WebUI to load the new Python package.
75
+
76
+ ## Troubleshooting
77
+
78
+ **No spans:** Confirm you selected **Traced model**, supplied both Future AGI keys, and enabled the Function. Tracing configuration/export failures do not block the model response. Allow a few seconds for the batch exporter.
79
+
80
+ **Target model rejected:** Use an OpenAI-compatible Chat Completions connection model ID, not a Responses API connection, native Ollama model, workspace preset, Arena model, or another Pipe. The signed-in user must also have permission to use that target.
81
+
82
+ **Invalid non-streaming response:** The provider returned a scalar, array, or undecodable body instead of a completion object. Check the connection URL and API type; the Pipe returns an explicit error rather than presenting that body as generated text.
83
+
84
+ **No token counts:** Enable Usage on the wrapper model and check that the provider returns usage. Cancelled calls may never receive a final usage snapshot.
85
+
86
+ **Incomplete stream:** The provider ended its stream without confirmed completion, or the consumer closed it early. The span is not marked successful. When the request explicitly supplies a positive integer `n`, every requested choice must finish unless the provider sends `[DONE]`. Without explicit `n`, the Pipe requires `[DONE]`: native model defaults can change the choice count after dispatch, so a single finished choice does not prove the request completed. If your provider omits `[DONE]`, supply the intended `n` in the wrapper request rather than relying on target-model defaults. This does not change the response sent to your chat. An abrupt process kill cannot export pending spans.
87
+
88
+ **Oversized streaming event:** The Pipe inspects up to 1,048,576 decoded characters per SSE event, including framing and line endings. Larger events still reach your chat unchanged, but the Pipe discards them from telemetry parsing to bound its buffer. The span records `openwebui.observation_lost=true` and an `incomplete` outcome with error status, even if `[DONE]` or finished choices are observed; a separately observed failure or cancellation retains its own outcome. This indicates incomplete observation, not proof that the provider failed. Content and usage from discarded events are unavailable; token counts reflect only observed snapshots. Configure your provider to send smaller streaming events if you need complete telemetry. The `max_chars` content-capture setting does not change this inspection limit.
89
+
90
+ **Self-hosted collector:** Remote endpoints must use HTTPS; HTTP is accepted only for loopback testing. Redirects, host proxy settings, `.netrc`, and unrelated native OTLP client certificates are not used. Use a directly reachable collector endpoint.
91
+
92
+ ## Embedding lifecycle
93
+
94
+ If you manage the Pipe lifecycle in Python, `pipe.flush(timeout_millis=10000)` bounds the caller's wait and returns `False` when the flush does not complete within that budget. An in-progress export can continue in the background; retrying does not create concurrent flush workers. Call `pipe.close()` to retire the exporter after active model calls finish.
@@ -0,0 +1,8 @@
1
+ traceai_openwebui/__init__.py,sha256=wNI4yC38fRD0uMOnMs-fk6nsZ6r55hRe2cabdfJa46g,14690
2
+ traceai_openwebui/_provider.py,sha256=qWwnfMBzdLqFRIb3551g2-QRw6WXt3WDO33g1q_t1nM,7254
3
+ traceai_openwebui/_response.py,sha256=lm6c1jmKDlbwf5rnq7l__h5sw4VwQ0BPvXKa7fm9khw,9068
4
+ traceai_openwebui/cli.py,sha256=eESHASz89dn69pt2ozuCH7O9vIiOmkvKpnz6jwp1Mc0,1187
5
+ traceai_openwebui-0.1.0.dist-info/METADATA,sha256=lAbL_Z2BZIFk2uTpucWxBO_syz2eh8Z1mUXRB7BDXXU,8867
6
+ traceai_openwebui-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ traceai_openwebui-0.1.0.dist-info/entry_points.txt,sha256=jRV9AS90PhkYRPJ6nATcURjIugXksswLtgNVS2hEhCE,65
8
+ traceai_openwebui-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ traceai-openwebui = traceai_openwebui.cli:main