-
Notifications
You must be signed in to change notification settings - Fork 1
fix: accept Chat Completions streaming usage options #914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6441b55
f6cfa90
25c79c9
e7d6a48
3db6b77
ce26cd9
6861747
3a64317
c648102
3000306
6c2f2a8
4324220
97e4ed6
abbb8cc
c2d2a1d
d610e92
8c9c39f
d57563a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1686,11 +1686,11 @@ def stream_chat( | |
| """ | ||
| if type(include_usage) is not bool: | ||
| raise TypeError("include_usage must be a boolean") | ||
| self._local.usage = None | ||
| if not is_chat_compatible_model_id(agent.model): | ||
| raise ValueError( | ||
| f"model {agent.model!r} is not chat-compatible and cannot serve {agent.id!r}" | ||
| ) | ||
| self._local.usage = None | ||
| if agent.base_url.startswith("mock://"): | ||
| answer = self._mock(agent, messages) | ||
| for start in range(0, len(answer), 24): | ||
|
|
@@ -1754,10 +1754,14 @@ def _stream_send( | |
| chunk = json.loads(data) | ||
| except json.JSONDecodeError: | ||
| continue | ||
| choices = chunk.get("choices") | ||
| usage = chunk.get("usage") | ||
| if isinstance(usage, dict): | ||
| self._local.usage = usage | ||
| choices = chunk.get("choices") or [{}] | ||
| if choices == []: | ||
| continue | ||
| if not isinstance(choices, list) or not choices: | ||
| continue | ||
| delta = (choices[0] or {}).get("delta", {}).get("content") | ||
| if delta: | ||
| yield delta | ||
|
|
@@ -13838,23 +13842,41 @@ def chat_completion_chunks( | |
| include_trace: bool = False, | ||
| include_usage: bool = False, | ||
| ) -> list[dict[str, Any]]: | ||
| """Frame an orchestration result as OpenAI-compatible ``chat.completion.chunk`` deltas. | ||
| """Frame an orchestration result as OpenAI-compatible chat completion chunks. | ||
|
|
||
| The engine produces the full answer before framing, so this yields a correct-shape | ||
| SSE stream (role delta, content deltas, terminal stop delta) rather than true | ||
| token-by-token streaming — real token streaming requires a streaming ModelClient. | ||
| Only provider-reported usage may be emitted. Gateway estimates remain internal | ||
| because presenting estimates as provider usage would violate the wire contract. | ||
| """ | ||
| answer = result.get("answer", "") | ||
| completion_id = _new_chat_completion_id() | ||
| created = int(time.time()) | ||
| base = {"id": completion_id, "object": "chat.completion.chunk", "created": created, "model": model} | ||
| base = { | ||
| "id": completion_id, | ||
| "object": "chat.completion.chunk", | ||
| "created": created, | ||
| "model": model, | ||
| } | ||
| if include_usage: | ||
| base["usage"] = None | ||
|
|
||
| chunks: list[dict[str, Any]] = [ | ||
| {**base, "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]} | ||
| { | ||
| **base, | ||
| "choices": [ | ||
| {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None} | ||
| ], | ||
| } | ||
| ] | ||
| for start in range(0, len(answer), _STREAM_CHUNK_SIZE): | ||
| piece = answer[start : start + _STREAM_CHUNK_SIZE] | ||
| chunks.append({**base, "choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}]}) | ||
| for offset in range(0, len(answer), _STREAM_CHUNK_SIZE): | ||
| piece = answer[offset : offset + _STREAM_CHUNK_SIZE] | ||
| chunks.append( | ||
| { | ||
| **base, | ||
| "choices": [ | ||
| {"index": 0, "delta": {"content": piece}, "finish_reason": None} | ||
| ], | ||
| } | ||
| ) | ||
|
|
||
| orchestration = { | ||
| "workflow_run_id": result.get("workflow_run_id"), | ||
|
|
@@ -13863,30 +13885,25 @@ def chat_completion_chunks( | |
| } | ||
| if include_trace and "trace" in result: | ||
| orchestration["trace"] = redact_value(result["trace"]) | ||
| final = {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]} | ||
| final["orchestration"] = {key: value for key, value in orchestration.items() if value is not None} | ||
|
|
||
| final = { | ||
| **base, | ||
| "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], | ||
| "orchestration": { | ||
| key: value for key, value in orchestration.items() if value is not None | ||
| }, | ||
| } | ||
| chunks.append(final) | ||
| if include_usage: | ||
| reported_usage = result.get("usage") | ||
| if isinstance(reported_usage, dict): | ||
| usage = {**reported_usage, "usage_source": "reported"} | ||
| else: | ||
| prompt_text = result.get("prompt_text", "") | ||
| estimated_prompt_tokens = estimate_tokens( | ||
| prompt_text if isinstance(prompt_text, str) else str(prompt_text) | ||
| ) | ||
| estimated_completion_tokens = estimate_tokens(answer) | ||
| usage = { | ||
| "prompt_tokens": estimated_prompt_tokens, | ||
| "completion_tokens": estimated_completion_tokens, | ||
| "total_tokens": estimated_prompt_tokens + estimated_completion_tokens, | ||
| "usage_source": "estimated", | ||
| } | ||
| chunks.append({ | ||
| **base, | ||
| "choices": [], | ||
| "usage": usage, | ||
| }) | ||
|
|
||
| usage = result.get("usage") | ||
| cost = result.get("cost") | ||
| if ( | ||
| include_usage | ||
| and isinstance(cost, dict) | ||
| and cost.get("measurement_status") == "measured" | ||
| and isinstance(usage, dict) | ||
|
Comment on lines
+13902
to
+13904
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Cached streams fabricate provider usage On a cached conducted stream, Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| ): | ||
|
seonghobae marked this conversation as resolved.
|
||
| chunks.append({**base, "choices": [], "usage": usage}) | ||
| return chunks | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6492,6 +6492,12 @@ def register_video_job(agent: ModelAgent, provider_result: dict[str, Any]) -> di | |
| # Explicit JSON null on trigger keys is omit-equivalent (SDK optional | ||
| # defaults) — do not force single-agent passthrough for null-only keys. | ||
| if body.get("response_format") or tools_list: | ||
| if stream and include_usage: | ||
| raise RequestError( | ||
| 400, | ||
| "invalid_stream_options", | ||
| "stream_options.include_usage=true is not supported with tools or response_format", | ||
| ) | ||
|
Comment on lines
+6495
to
+6500
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| if explicit_trace: | ||
| raise RequestError( | ||
| 400, | ||
|
|
@@ -7923,11 +7929,10 @@ def _stream_route_completion( | |
| *, | ||
| include_usage: bool = False, | ||
| ) -> None: | ||
| """Pipe a worker's live deltas out as OpenAI chat.completion.chunk SSE frames.""" | ||
| """Pipe live provider deltas as OpenAI chat-completion SSE frames.""" | ||
| run_id = f"run_{uuid.uuid4().hex}" | ||
| completion_id = _new_chat_completion_id() | ||
| created = int(time.time()) | ||
| streamed_parts: list[str] = [] | ||
| stream_usage: dict[str, Any] | None = None | ||
|
|
||
| def capture_usage(usage: dict[str, Any] | None) -> None: | ||
|
|
@@ -7940,13 +7945,30 @@ def frame(delta: dict[str, Any], finish: str | None = None) -> str: | |
| "object": "chat.completion.chunk", | ||
| "created": created, | ||
| "model": model_name, | ||
| "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], | ||
| "choices": [ | ||
| {"index": 0, "delta": delta, "finish_reason": finish} | ||
| ], | ||
| } | ||
| if include_usage: | ||
| payload["usage"] = None | ||
| return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" | ||
|
|
||
| def usage_frame(usage: dict[str, Any]) -> str: | ||
| payload = { | ||
| "id": completion_id, | ||
| "object": "chat.completion.chunk", | ||
| "created": created, | ||
| "model": model_name, | ||
| "choices": [], | ||
| "usage": usage, | ||
| } | ||
| return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| security.acquire_run_slot() | ||
| try: | ||
| if not self._begin_sse() or not self._write_sse(frame({"role": "assistant"})): | ||
| if not self._begin_sse() or not self._write_sse( | ||
| frame({"role": "assistant"}) | ||
| ): | ||
| return | ||
| try: | ||
| stream_kwargs: dict[str, Any] = { | ||
|
|
@@ -7958,39 +7980,16 @@ def frame(delta: dict[str, Any], finish: str | None = None) -> str: | |
| {"include_usage": True, "usage_callback": capture_usage} | ||
| ) | ||
| for delta in orchestrator.stream_route(messages, **stream_kwargs): | ||
| streamed_parts.append(delta) | ||
| if not self._write_sse(frame({"content": delta})): | ||
| return | ||
| if not self._write_sse(frame({}, finish="stop")): | ||
| return | ||
| if include_usage: | ||
| if isinstance(stream_usage, dict): | ||
| usage = {**stream_usage, "usage_source": "reported"} | ||
| else: | ||
| estimated_prompt_tokens = estimate_tokens( | ||
| json.dumps(messages, ensure_ascii=False) | ||
| ) | ||
| estimated_completion_tokens = estimate_tokens( | ||
| "".join(streamed_parts) | ||
| ) | ||
| usage = { | ||
| "prompt_tokens": estimated_prompt_tokens, | ||
| "completion_tokens": estimated_completion_tokens, | ||
| "total_tokens": estimated_prompt_tokens + estimated_completion_tokens, | ||
| "usage_source": "estimated", | ||
| } | ||
| usage_payload = { | ||
| "id": completion_id, | ||
| "object": "chat.completion.chunk", | ||
| "created": created, | ||
| "model": model_name, | ||
| "choices": [], | ||
| "usage": usage, | ||
| } | ||
| if not self._write_sse( | ||
| f"data: {json.dumps(usage_payload, ensure_ascii=False)}\n\n" | ||
| ): | ||
| return | ||
| if ( | ||
| include_usage | ||
| and isinstance(stream_usage, dict) | ||
| and not self._write_sse(usage_frame(stream_usage)) | ||
| ): | ||
| return | ||
| except ToolFallbackStoppedError as exc: | ||
| detail = { | ||
| "request_id": uuid.uuid4().hex, | ||
|
|
@@ -8007,7 +8006,7 @@ def frame(delta: dict[str, Any], finish: str | None = None) -> str: | |
| return | ||
| if not self._write_sse(frame({}, finish="error")): | ||
| return | ||
| except Exception: # noqa: BLE001 - headers already sent; surface as a terminal error frame | ||
| except Exception: # noqa: BLE001 - headers already sent | ||
| if not self._write_sse(frame({}, finish="error")): | ||
| return | ||
| self._write_sse("data: [DONE]\n\n") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Live usage ordering remains intact
_stream_sendcaptures usage before skipping empty choices.stream_routedelivers it after provider iteration, preserving stop-then-usage ordering without stale values.Was this helpful? React with 👍 or 👎 to provide feedback.